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/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..7c4ca599 --- /dev/null +++ b/cmd/opensave-app/frontend/src/lib/i18n.js @@ -0,0 +1,113 @@ +// A tiny, dependency-free i18n layer. `t` is a Svelte store whose value is a +// 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 }; +const FALLBACK = 'en'; +const KEY = 'opensave.locale'; + +/** A locale id if valid, otherwise null. */ +export function sanitizeLocale(raw) { + return typeof raw === 'string' && (raw === SYSTEM_LOCALE || raw in LOCALES) ? raw : null; +} + +/** The saved choice, or 'system' when the user has never picked one. */ +export function loadLocale(storage = globalThis.localStorage) { + try { + return sanitizeLocale(storage?.getItem(KEY)) ?? SYSTEM_LOCALE; + } catch { + return SYSTEM_LOCALE; + } +} + +/** Persist an explicit pick; 'system' clears it, following the OS again. */ +export function saveLocale(l, storage = globalThis.localStorage) { + try { + if (l === SYSTEM_LOCALE) storage?.removeItem(KEY); + else storage?.setItem(KEY, l); + } catch { + // Storage refused: the choice lasts until restart. + } +} + +/** '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)); + +/** 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[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 new file mode 100644 index 00000000..efa936f3 --- /dev/null +++ b/cmd/opensave-app/frontend/src/lib/i18n.test.js @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import en from '../locales/en.json'; +import zhCN from '../locales/zh-CN.json'; +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', () => { + 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(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(keysOf(zhCN)); + for (const key of keysOf(en)) expect(zhKeys.has(key), `missing key: ${key}`).toBe(true); + }); + + 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); + } + } + } + }); +}); + +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('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("reports 'system' when the user has never picked one", () => { + expect(loadLocale({ getItem: () => null })).toBe('system'); + expect(loadLocale({ getItem: () => 'zh-CN' })).toBe('zh-CN'); + }); + + 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(loadLocale(storage)).toBe('system'); + }); +}); 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..7da165a7 --- /dev/null +++ b/cmd/opensave-app/frontend/src/locales/en.json @@ -0,0 +1,190 @@ +{ + "settings.language": "Language", + "settings.systemDefault": "System default", + "common.cancel": "Cancel", + "common.nowTracking": "Now tracking \"{name}\"", + "home.addGame.adding": "Adding…", + "home.addGame.changeFolder": "Change folder…", + "home.addGame.chooseFile": "Choose the save file to track", + "home.addGame.name": "Name", + "home.addGame.nameHint": "How it shows in your library, and on your other devices.", + "home.addGame.namePlaceholder": "e.g. Elden Ring", + "home.addGame.savePath": "Save folder or file", + "home.addGame.startTracking": "Start tracking", + "home.addGame.title": "Track a game", + "home.addGame.trackFileInstead": "Track a single file instead…", + "home.library.bulkDone": { + "one": "{verb} {n} game", + "other": "{verb} {n} games" + }, + "home.library.bulkPartial": "{verb} {done} of {total} — {failed}", + "home.library.clearFilters": "Clear filters", + "home.library.collectionsLabel": "Collections", + "home.library.empty": "No games match.", + "home.library.emptyFor": "No games match \"{query}\".", + "home.library.favourite": "Favourite", + "home.library.favouriteTitle": "Add each to Favourites", + "home.library.reverseTitle": "Reverse the order", + "home.library.reversedTitle": "Reversed — put back in order", + "home.library.searchLabel": "Find a game", + "home.library.searchPlaceholder": "Find a game…", + "home.library.select": "Select", + "home.library.selectAll": "Select all ({count})", + "home.library.selectedCount": "{n} selected", + "home.library.showAll": "Show all", + "home.library.showing": "Showing {shown} of {total}.", + "home.library.snapshot": "Snapshot", + "home.library.snapshotTitle": "Take a snapshot of each", + "home.library.sort": "Sort", + "home.library.sync": "Sync", + "home.library.syncTitle": "Sync each with your devices", + "home.library.title": "Library", + "home.library.unselectAll": "Unselect all", + "home.library.untrackSelected": "Untrack selected", + "home.library.verbFavourite": "Added to Favourites:", + "home.library.verbSnapshot": "Took a snapshot of", + "home.library.verbSync": "Started syncing", + "home.library.view": "View", + "home.library.viewHint": "Also in Settings → General.", + "home.library.viewLabel": "Library view", + "home.offered.chooseFolder": "Choose folder…", + "home.offered.decline": "Decline", + "home.offered.declineToast": "You won't be asked about {name} again. Tracking it yourself later still works.", + "home.offered.folderPrompt": "Folder for \"{name}\" on this device", + "home.offered.intro": { + "one": "Another device syncs this game, but this one doesn't know where to keep it yet. Nothing syncs until you choose.", + "other": "Other devices sync these games, but this one doesn't know where to keep them yet. Nothing syncs until you choose." + }, + "home.offered.pathHint": "Kept at {path} on the other device.", + "home.offered.title": "Waiting for a folder", + "home.offered.trackedToast": "{name} is now tracked here", + "home.recent.allActivity": "All activity", + "home.recent.empty": "Nothing yet. Snapshots and syncs show up here as they happen.", + "home.recent.label": "Recent", + "home.scan.alreadyTracked": "Already tracked ({n})", + "home.scan.badResponse": "The scan's answer was not a list of saves. Restart OpenSave and scan again.", + "home.scan.clear": "Clear", + "home.scan.emptyNoMatches": "No matches for this filter.", + "home.scan.emptyNothingDetected": "Nothing detected. You can still track any folder manually.", + "home.scan.exclude": "Exclude", + "home.scan.excludeTitle": "Stop offering this location in future scans", + "home.scan.excludeToast": "\"{name}\" won't be offered in scans again.", + "home.scan.excluding": "Excluding…", + "home.scan.filterPlaceholder": "Filter by name or path…", + "home.scan.folderList.headFound": "{name} was found in {n} places.", + "home.scan.folderList.headTick": "Tick the folders that belong to this save — they are tracked as one game.", + "home.scan.folderList.tagAlt": "another copy — probably an old install", + "home.scan.folderList.tagInside": "already inside the folder above", + "home.scan.folderList.tagLocation": "part of the same save", + "home.scan.folderList.tagSave": "the save folder", + "home.scan.folderList.tagTracked": "already tracked", + "home.scan.foundInFolders": { + "one": "found in {n} folder", + "other": "found in {n} folders" + }, + "home.scan.foundSummary": { + "one": "Found {n} save location — {m} available to track", + "other": "Found {n} save locations — {m} available to track" + }, + "home.scan.foundSummaryEmpty": "Found {n} save locations — {m} available to track, {e} empty hidden", + "home.scan.mergedPartialFail": "Tracked \"{name}\", but {n} folder(s) failed: {detail}", + "home.scan.oneSave": { + "one": "· {n} is one save", + "other": "· {n} are one save" + }, + "home.scan.scanningDetail": "Scanning Steam, emulators, and configured folders…", + "home.scan.selectAll": "Select all ({n})", + "home.scan.showEmpty": "Show {n} empty", + "home.scan.showEmptyTitle": "Folders that exist but hold no files — Steam makes one for every game you own, whether or not saves go there. A game with nothing saved anywhere is in here too.", + "home.scan.showTracked": "Show tracked", + "home.scan.showTrackedTitle": "Also show saves you already track", + "home.scan.subScanning": "Scanning your system…", + "home.scan.tab.all": "All", + "home.scan.tab.emulators": "Emulators", + "home.scan.tab.games": "Games", + "home.scan.tab.repacks": "Repacks", + "home.scan.title": "Scan results", + "home.scan.track": "Track", + "home.scan.trackAll": "Track all {n}", + "home.scan.trackAsOne": "Track as one game", + "home.scan.trackAsOneTitle": "Track everything ticked as a single game, whatever it was grouped under. For a split save the scan did not spot.", + "home.scan.trackPartialFail": "Tracking \"{name}\" — {n} folder(s) could not be added: {detail}", + "home.scan.trackSelected": "Track selected ({n})", + "home.scan.tracked": "Tracked", + "home.scan.trackedAcrossFolders": "Now tracking \"{name}\" across {n} folders", + "home.scan.trackedGames": { + "one": "Tracked {n} game", + "other": "Tracked {n} games" + }, + "home.scan.trackedGamesWithExtras": "Tracked {n} games with {m} extra locations", + "home.scan.type.emulator": "Emulator", + "home.scan.type.game": "Game", + "home.scan.type.repack": "Repack", + "home.setup.chooseWhere": "Choose where", + "home.setup.hideTitle": "Hide this guide", + "home.setup.later": "Later", + "home.setup.noCloudBackup": "No cloud backup", + "home.setup.onlyThisDevice": "Only this device for now", + "home.setup.pairDevice": "Pair a device", + "home.setup.pickFolder": "Pick a folder", + "home.setup.progress": "{n} of 3 done", + "home.setup.skipped": "Skipped", + "home.setup.stepCloud.text": "A copy of every snapshot in Google Drive, OneDrive, Dropbox or a folder of your own.", + "home.setup.stepCloud.title": "Back up to the cloud", + "home.setup.stepDevices.text": "A PC, a Steam Deck or a handheld with OpenSave on it — on your Wi-Fi, or anywhere over the internet.", + "home.setup.stepDevices.title": "Add your other devices", + "home.setup.stepSaves.text": "OpenSave looks through Steam, emulators and the usual save folders, and you choose what to keep.", + "home.setup.stepSaves.title": "Find your game saves", + "home.setup.title": "Get OpenSave set up", + "home.summary.cloudBackup": "Cloud backup", + "home.summary.cloudBackupTitle": "Open Cloud Backup", + "home.summary.cloudNotSetUp": "Not set up", + "home.summary.cloudOff": "Off", + "home.summary.cloudOn": "On", + "home.summary.devicesOnline": "{online} of {paired} online", + "home.summary.devicesTitle": "Open Devices", + "home.summary.lastSnapshot": "Last snapshot", + "home.summary.lastSnapshotTitle": "{name} · {when}", + "home.summary.lowSpace": "Only {size} free on the drive your snapshots are kept on — new ones may not fit.", + "home.summary.nonePaired": "None paired", + "home.summary.noneYet": "None yet", + "home.summary.offline": "offline", + "home.summary.online": "online", + "home.summary.pausedDetail": "Syncing is paused {length}. Snapshots are still taken, and everything catches up when it resumes.", + "home.summary.resumeNow": "Resume now", + "home.summary.seeSpace": "See what takes the space", + "home.summary.sharedSavingTitle": "On disk — {size} less than the snapshots' own sizes, for sharing unchanged files. Open Settings → Storage", + "home.summary.spaceUsed": "Space used", + "home.summary.spaceUsedTitle": "Open Settings → Storage", + "home.tile.inFavourites": "In Favourites", + "home.tile.onBranch": "on {branch}", + "home.tile.played": "{length} played", + "home.tile.snapshots": { + "one": "{n} snapshot", + "other": "{n} snapshots" + }, + "home.viewOptions.autoFit": "As many as fit", + "home.viewOptions.coverStyle": "Cover style", + "home.viewOptions.covers": "Covers", + "home.viewOptions.fixedNote": "With a set number per row, the tiles share the width between them.", + "home.viewOptions.overview": "Show recent activity on Home", + "home.viewOptions.tileSize": "Tile size", + "home.viewOptions.tilesPerRow": "Tiles per row", + "home.chooseFolderDialog": "Choose the save folder to track", + "home.cloudBackup": "Cloud Backup", + "home.devices": "Devices", + "home.scanSaves": "Scan saves", + "home.scanSavesTitle": "Look for game saves on this device", + "home.scanning": "Scanning…", + "home.syncAll": "Sync all", + "home.syncAllToast": "Sync triggered for all games", + "home.syncPaused": "Syncing is paused", + "home.title": "Home", + "home.trackFolder": "Track folder", + "home.trackFolderTitle": "Pick a save folder or file to track", + "home.welcome.hint": "Then open {devices} to pair another PC or Steam Deck, or {cloud} to mirror snapshots online.", + "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.title": "Welcome to OpenSave", + "home.welcome.trackManually": "Track a folder manually" +} 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..12fbb543 --- /dev/null +++ b/cmd/opensave-app/frontend/src/locales/zh-CN.json @@ -0,0 +1,169 @@ +{ + "settings.language": "语言", + "settings.systemDefault": "跟随系统", + "common.cancel": "取消", + "common.nowTracking": "已开始跟踪“{name}”", + "home.addGame.adding": "添加中…", + "home.addGame.changeFolder": "更换文件夹…", + "home.addGame.chooseFile": "选择要跟踪的存档文件", + "home.addGame.name": "名称", + "home.addGame.nameHint": "它显示在你的游戏库中,也会显示在其他设备上。", + "home.addGame.namePlaceholder": "例如:Elden Ring", + "home.addGame.savePath": "存档文件夹或文件", + "home.addGame.startTracking": "开始跟踪", + "home.addGame.title": "跟踪游戏", + "home.addGame.trackFileInstead": "改为跟踪单个文件…", + "home.library.bulkDone": "已为 {n} 个游戏{verb}", + "home.library.bulkPartial": "已为 {done}/{total} 个游戏{verb} — {failed}", + "home.library.clearFilters": "清除筛选", + "home.library.collectionsLabel": "收藏夹", + "home.library.empty": "没有符合条件的游戏。", + "home.library.emptyFor": "没有匹配“{query}”的游戏。", + "home.library.favourite": "收藏", + "home.library.favouriteTitle": "将每个游戏加入收藏", + "home.library.reverseTitle": "倒序排列", + "home.library.reversedTitle": "已倒序 — 恢复原来的顺序", + "home.library.searchLabel": "查找游戏", + "home.library.searchPlaceholder": "查找游戏…", + "home.library.select": "选择", + "home.library.selectAll": "全选({count})", + "home.library.selectedCount": "已选 {n} 个", + "home.library.showAll": "显示全部", + "home.library.showing": "显示 {shown}/{total} 个。", + "home.library.snapshot": "快照", + "home.library.snapshotTitle": "为每个游戏创建快照", + "home.library.sort": "排序", + "home.library.sync": "同步", + "home.library.syncTitle": "将每个游戏与你的设备同步", + "home.library.title": "游戏库", + "home.library.unselectAll": "取消全选", + "home.library.untrackSelected": "取消跟踪所选", + "home.library.verbFavourite": "加入收藏", + "home.library.verbSnapshot": "创建快照", + "home.library.verbSync": "完成同步", + "home.library.view": "视图", + "home.library.viewHint": "也可以在设置 → 常规中调整。", + "home.library.viewLabel": "游戏库视图", + "home.offered.chooseFolder": "选择文件夹…", + "home.offered.decline": "拒绝", + "home.offered.declineToast": "不会再询问你关于 {name} 的事,之后仍可自行跟踪。", + "home.offered.folderPrompt": "在本机上为“{name}”选择文件夹", + "home.offered.intro": "其他设备正在同步这些游戏,但本机还不知道该把存档放在哪里。选择文件夹后才会开始同步。", + "home.offered.pathHint": "在另一台设备上位于 {path}。", + "home.offered.title": "等待选择文件夹", + "home.offered.trackedToast": "{name} 已在本机开始跟踪", + "home.recent.allActivity": "全部动态", + "home.recent.empty": "暂无记录。快照和同步会在发生时显示在这里。", + "home.recent.label": "最近动态", + "home.scan.alreadyTracked": "已跟踪 ({n})", + "home.scan.badResponse": "扫描返回的不是存档列表。请重启 OpenSave 后重新扫描。", + "home.scan.clear": "清除选择", + "home.scan.emptyNoMatches": "没有符合此筛选条件的结果。", + "home.scan.emptyNothingDetected": "未检测到任何存档。你仍可以手动跟踪任意文件夹。", + "home.scan.exclude": "排除", + "home.scan.excludeTitle": "不再在后续扫描中显示此位置", + "home.scan.excludeToast": "“{name}”不会再出现在扫描结果中。", + "home.scan.excluding": "正在排除…", + "home.scan.filterPlaceholder": "按名称或路径筛选…", + "home.scan.folderList.headFound": "已在 {n} 个位置中找到 {name}。", + "home.scan.folderList.headTick": "勾选属于此存档的文件夹——它们会作为一个游戏来跟踪。", + "home.scan.folderList.tagAlt": "另一个副本——可能是旧的安装", + "home.scan.folderList.tagInside": "已包含在上面的文件夹中", + "home.scan.folderList.tagLocation": "同一存档的一部分", + "home.scan.folderList.tagSave": "存档文件夹", + "home.scan.folderList.tagTracked": "已跟踪", + "home.scan.foundInFolders": "在 {n} 个文件夹中找到", + "home.scan.foundSummary": "已找到 {n} 个存档位置——{m} 个可跟踪", + "home.scan.foundSummaryEmpty": "已找到 {n} 个存档位置——{m} 个可跟踪,{e} 个空文件夹已隐藏", + "home.scan.mergedPartialFail": "已跟踪“{name}”,但有 {n} 个文件夹失败:{detail}", + "home.scan.oneSave": "· 其中 {n} 个属于同一存档", + "home.scan.scanningDetail": "正在扫描 Steam、模拟器和已配置的文件夹…", + "home.scan.selectAll": "全选 ({n})", + "home.scan.showEmpty": "显示 {n} 个空文件夹", + "home.scan.showEmptyTitle": "这些文件夹确实存在但里面没有文件——Steam 会为你拥有的每个游戏都建一个,无论存档是否放在其中。完全没有存档的游戏也会出现在这里。", + "home.scan.showTracked": "显示已跟踪", + "home.scan.showTrackedTitle": "同时显示已跟踪的存档", + "home.scan.subScanning": "正在扫描你的系统…", + "home.scan.tab.all": "全部", + "home.scan.tab.emulators": "模拟器", + "home.scan.tab.games": "游戏", + "home.scan.tab.repacks": "硬盘版", + "home.scan.title": "扫描结果", + "home.scan.track": "跟踪", + "home.scan.trackAll": "跟踪全部 {n} 个", + "home.scan.trackAsOne": "作为一个游戏跟踪", + "home.scan.trackAsOneTitle": "将勾选的所有文件夹作为一个游戏跟踪,不考虑扫描给出的分组。适用于扫描未能识别的拆分存档。", + "home.scan.trackPartialFail": "正在跟踪“{name}”——有 {n} 个文件夹未能添加:{detail}", + "home.scan.trackSelected": "跟踪所选 ({n})", + "home.scan.tracked": "已跟踪", + "home.scan.trackedAcrossFolders": "已跟踪“{name}”,共 {n} 个文件夹", + "home.scan.trackedGames": "已跟踪 {n} 个游戏", + "home.scan.trackedGamesWithExtras": "已跟踪 {n} 个游戏,并附加 {m} 个位置", + "home.scan.type.emulator": "模拟器", + "home.scan.type.game": "游戏", + "home.scan.type.repack": "硬盘版", + "home.setup.chooseWhere": "选择位置", + "home.setup.hideTitle": "隐藏此引导", + "home.setup.later": "稍后", + "home.setup.noCloudBackup": "不做云备份", + "home.setup.onlyThisDevice": "暂时只用这台设备", + "home.setup.pairDevice": "配对设备", + "home.setup.pickFolder": "选择文件夹", + "home.setup.progress": "已完成 {n} / 3", + "home.setup.skipped": "已跳过", + "home.setup.stepCloud.text": "把每个快照的副本存到 Google Drive、OneDrive、Dropbox 或你自己的文件夹里。", + "home.setup.stepCloud.title": "备份到云端", + "home.setup.stepDevices.text": "一台电脑、一台 Steam Deck 或装有 OpenSave 的掌机——在同一个 Wi-Fi 下,或通过互联网从任意位置连接。", + "home.setup.stepDevices.title": "添加你的其他设备", + "home.setup.stepSaves.text": "OpenSave 会扫描 Steam、模拟器和常见的存档文件夹,由你选择要跟踪的内容。", + "home.setup.stepSaves.title": "找到你的游戏存档", + "home.setup.title": "开始设置 OpenSave", + "home.summary.cloudBackup": "云备份", + "home.summary.cloudBackupTitle": "打开云备份", + "home.summary.cloudNotSetUp": "未设置", + "home.summary.cloudOff": "已关闭", + "home.summary.cloudOn": "已开启", + "home.summary.devicesOnline": "共 {paired} 台,{online} 台在线", + "home.summary.devicesTitle": "打开设备", + "home.summary.lastSnapshot": "最近快照", + "home.summary.lastSnapshotTitle": "{name} · {when}", + "home.summary.lowSpace": "存放快照的磁盘只剩 {size} 可用空间,新的快照可能存不下。", + "home.summary.nonePaired": "未配对", + "home.summary.noneYet": "暂无", + "home.summary.offline": "离线", + "home.summary.online": "在线", + "home.summary.pausedDetail": "同步已暂停({length})。快照仍会创建,恢复后会自动补齐。", + "home.summary.resumeNow": "立即恢复", + "home.summary.seeSpace": "查看占用情况", + "home.summary.sharedSavingTitle": "磁盘占用比快照自身大小之和少 {size},因为未变动的文件只存一份。打开设置 → 存储", + "home.summary.spaceUsed": "已用空间", + "home.summary.spaceUsedTitle": "打开设置 → 存储", + "home.tile.inFavourites": "已在收藏中", + "home.tile.onBranch": "在 {branch} 上", + "home.tile.played": "已游玩 {length}", + "home.tile.snapshots": "{n} 个快照", + "home.viewOptions.autoFit": "自动排满", + "home.viewOptions.coverStyle": "封面样式", + "home.viewOptions.covers": "封面", + "home.viewOptions.fixedNote": "指定每行数量后,磁贴会平分这一行的宽度。", + "home.viewOptions.overview": "在主页显示最近动态", + "home.viewOptions.tileSize": "磁贴大小", + "home.viewOptions.tilesPerRow": "每行磁贴数", + "home.chooseFolderDialog": "选择要跟踪的存档文件夹", + "home.cloudBackup": "云备份", + "home.devices": "设备", + "home.scanSaves": "扫描存档", + "home.scanSavesTitle": "查找本机上的游戏存档", + "home.scanning": "扫描中…", + "home.syncAll": "全部同步", + "home.syncAllToast": "已触发全部游戏的同步", + "home.syncPaused": "同步已暂停", + "home.title": "主页", + "home.trackFolder": "跟踪文件夹", + "home.trackFolderTitle": "选择要跟踪的存档文件夹或文件", + "home.welcome.hint": "然后打开 {devices} 配对另一台电脑或 Steam Deck,或打开 {cloud} 将快照镜像到云端。", + "home.welcome.intro": "让你的游戏存档在所有设备间保持同步——无需账号,不被云端绑架。先找到你的存档:", + "home.welcome.scanForSaves": "扫描存档", + "home.welcome.title": "欢迎使用 OpenSave", + "home.welcome.trackManually": "手动跟踪文件夹" +} diff --git a/cmd/opensave-app/frontend/src/views/Home.svelte b/cmd/opensave-app/frontend/src/views/Home.svelte index bcfd5091..de8ecef4 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, 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'; @@ -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 @@
Keep your game saves in sync across every device — no accounts, no cloud lock-in. Start by finding your saves:
+{$t('home.welcome.intro')}
Then open Devices to pair another PC or Steam Deck, or Cloud Backup to mirror snapshots online.
++ {#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} +
{summary.headline}
{#if lowSpace !== null}
- Only {fmtSize(lowSpace)} free on the drive your snapshots are kept on — new ones may not fit.
-
- 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} -