From b2219b6725e4653194606b098173182f337d594f Mon Sep 17 00:00:00 2001 From: Janlhin Date: Fri, 16 Oct 2026 06:46:30 +0800 Subject: [PATCH 1/5] i18n: offer Chinese by script, not by region (zh-Hans + zh-Hant) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether a reader wants simplified or traditional characters is not the same question as where they are, so Chinese is no longer split into zh-CN / zh-TW / zh-HK and the rest. Two scripts are offered instead, and each region maps onto one of them. - zh-CN.json becomes zh-Hans.json. - zh-Hant.json is new: all 167 keys, converted with OpenCC (s2twp). Two words that OpenCC renders the wrong way round are corrected by hand: 镜像 (not 映象) and 帳號 (not 賬號). - scriptFor() reads the script out of an OS language tag: zh-TW, zh-HK, zh-MO and any zh-Hant-* are traditional; every other Chinese region, and a bare zh, are simplified. A tag naming its own script wins over the region guess. - The catalog tests walk a list of catalogs, so a new language is one line there plus one in LOCALES. npm test: 310/310. --- cmd/opensave-app/frontend/src/lib/i18n.js | 31 ++- .../frontend/src/lib/i18n.test.js | 54 +++-- .../src/locales/{zh-CN.json => zh-Hans.json} | 0 .../frontend/src/locales/zh-Hant.json | 194 ++++++++++++++++++ 4 files changed, 260 insertions(+), 19 deletions(-) rename cmd/opensave-app/frontend/src/locales/{zh-CN.json => zh-Hans.json} (100%) create mode 100644 cmd/opensave-app/frontend/src/locales/zh-Hant.json diff --git a/cmd/opensave-app/frontend/src/lib/i18n.js b/cmd/opensave-app/frontend/src/lib/i18n.js index 7c4ca599..72e287ee 100644 --- a/cmd/opensave-app/frontend/src/lib/i18n.js +++ b/cmd/opensave-app/frontend/src/lib/i18n.js @@ -14,14 +14,20 @@ import { writable, derived } from 'svelte/store'; import en from '../locales/en.json'; -import zhCN from '../locales/zh-CN.json'; +import zhHans from '../locales/zh-Hans.json'; +import zhHant from '../locales/zh-Hant.json'; export const SYSTEM_LOCALE = 'system'; -/** The languages offered besides the system default. */ -export const LOCALES = { en: 'English', 'zh-CN': '简体中文' }; +/** The languages offered besides the system default. + * + * Chinese is split by script, not by region: which characters a reader wants + * is not the same question as where they are, so `zh-CN` / `zh-TW` / `zh-HK` + * and the rest are not offered as separate languages. Each region maps onto + * one of the two scripts here instead. */ +export const LOCALES = { en: 'English', 'zh-Hans': '简体中文', 'zh-Hant': '繁體中文' }; -const CATALOGS = { en, 'zh-CN': zhCN }; +const CATALOGS = { en, 'zh-Hans': zhHans, 'zh-Hant': zhHant }; const FALLBACK = 'en'; const KEY = 'opensave.locale'; @@ -49,10 +55,25 @@ export function saveLocale(l, storage = globalThis.localStorage) { } } +/** Regions whose Chinese is written with traditional characters. Every other + * Chinese region — and a bare 'zh' — is read as simplified. */ +const TRADITIONAL_REGIONS = ['tw', 'hk', 'mo']; + +/** The Chinese script an OS/browser language tag is written in, or null when + * the tag is not Chinese. A tag that already names its script wins over the + * region guess. */ +export function scriptFor(language) { + const tag = (language ?? '').toLowerCase(); + if (!tag.startsWith('zh')) return null; + if (tag.startsWith('zh-hant')) return 'zh-Hant'; + if (tag.startsWith('zh-hans')) return 'zh-Hans'; + return TRADITIONAL_REGIONS.includes(tag.split('-')[1]) ? 'zh-Hant' : 'zh-Hans'; +} + /** '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; + return scriptFor(language) ?? FALLBACK; } /** The chosen language. Setting it persists immediately ('system' unpersists). */ diff --git a/cmd/opensave-app/frontend/src/lib/i18n.test.js b/cmd/opensave-app/frontend/src/lib/i18n.test.js index efa936f3..b80e8932 100644 --- a/cmd/opensave-app/frontend/src/lib/i18n.test.js +++ b/cmd/opensave-app/frontend/src/lib/i18n.test.js @@ -1,30 +1,39 @@ 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'; +import zhHans from '../locales/zh-Hans.json'; +import zhHant from '../locales/zh-Hant.json'; +import { LOCALES, loadLocale, pluralRulesFor, resolveLocale, sanitizeLocale, scriptFor, 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); +/** Every catalog, as [locale id, catalog]. A new language is one line here + * plus one in LOCALES; the checks below then cover it for free. */ +const CATALOGS = [['en', en], ['zh-Hans', zhHans], ['zh-Hant', zhHant]]; + describe('locale catalogs', () => { it('declares every catalog id in LOCALES', () => { - for (const id of ['en', 'zh-CN']) expect(LOCALES[id], id).toBeTruthy(); + for (const [id] of CATALOGS) expect(LOCALES[id], id).toBeTruthy(); }); - it('has no keys in zh-CN that en.json does not have', () => { + it('has no key outside en.json in any catalog', () => { const enKeys = new Set(keysOf(en)); - for (const key of keysOf(zhCN)) expect(enKeys.has(key), `extra key: ${key}`).toBe(true); + for (const [name, catalog] of CATALOGS) { + for (const key of keysOf(catalog)) expect(enKeys.has(key), `${name}: 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('has a value for every key in en.json, in every catalog', () => { + for (const [name, catalog] of CATALOGS) { + const keys = new Set(keysOf(catalog)); + for (const key of keysOf(en)) expect(keys.has(key), `${name}: 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 [name, catalog] of CATALOGS) { 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'); @@ -74,22 +83,39 @@ describe('segments', () => { }); describe('locale choice', () => { - it('rejects unknown locale ids', () => { + it('rejects unknown locale ids, and region tags that are not languages', () => { expect(sanitizeLocale('fr')).toBe(null); - expect(sanitizeLocale('zh-CN')).toBe('zh-CN'); + expect(sanitizeLocale('zh-CN')).toBe(null); + expect(sanitizeLocale('zh-TW')).toBe(null); + expect(sanitizeLocale('zh-Hans')).toBe('zh-Hans'); + expect(sanitizeLocale('zh-Hant')).toBe('zh-Hant'); 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'); + expect(loadLocale({ getItem: () => 'zh-Hant' })).toBe('zh-Hant'); }); it('resolves system against the OS language, keeps explicit picks', () => { - expect(resolveLocale('system', 'zh-CN')).toBe('zh-CN'); + expect(resolveLocale('system', 'zh-CN')).toBe('zh-Hans'); + expect(resolveLocale('system', 'zh-TW')).toBe('zh-Hant'); expect(resolveLocale('system', 'en-US')).toBe('en'); expect(resolveLocale('system', '')).toBe('en'); - expect(resolveLocale('zh-CN', 'en-US')).toBe('zh-CN'); + expect(resolveLocale('zh-Hant', 'en-US')).toBe('zh-Hant'); + }); + + it('reads the Chinese script out of an OS language tag', () => { + expect(scriptFor('zh-CN')).toBe('zh-Hans'); + expect(scriptFor('zh-SG')).toBe('zh-Hans'); + expect(scriptFor('zh-Hans-CN')).toBe('zh-Hans'); + expect(scriptFor('zh')).toBe('zh-Hans'); + expect(scriptFor('zh-TW')).toBe('zh-Hant'); + expect(scriptFor('zh-HK')).toBe('zh-Hant'); + expect(scriptFor('zh-MO')).toBe('zh-Hant'); + expect(scriptFor('zh-Hant-HK')).toBe('zh-Hant'); + expect(scriptFor('en-US')).toBe(null); + expect(scriptFor('')).toBe(null); }); it('survives storage that refuses to read', () => { diff --git a/cmd/opensave-app/frontend/src/locales/zh-CN.json b/cmd/opensave-app/frontend/src/locales/zh-Hans.json similarity index 100% rename from cmd/opensave-app/frontend/src/locales/zh-CN.json rename to cmd/opensave-app/frontend/src/locales/zh-Hans.json diff --git a/cmd/opensave-app/frontend/src/locales/zh-Hant.json b/cmd/opensave-app/frontend/src/locales/zh-Hant.json new file mode 100644 index 00000000..0e211381 --- /dev/null +++ b/cmd/opensave-app/frontend/src/locales/zh-Hant.json @@ -0,0 +1,194 @@ +{ + "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": "手動跟蹤資料夾", + "home.findGame.title": "從遊戲資料庫跟蹤遊戲", + "home.findGame.findByName": "按名稱查詢", + "home.findGame.searchPlaceholder": "例如:Crimson Desert", + "home.findGame.searching": "搜尋中…", + "home.findGame.noSavesHere": "此電腦上未找到存檔", + "home.findGame.trackedAs": "已作為“{name}”跟蹤", + "home.findGame.nothingFound": "遊戲資料庫中沒有這個名稱的遊戲。", + "home.findGame.chooseProgram": "選擇遊戲程式…", + "home.findGame.chooseSaveFolder": "選擇其存檔資料夾…", + "home.findGame.programDialog": "選擇遊戲程式", + "home.findGame.saveFolderDialog": "選擇遊戲的存檔資料夾", + "home.findGame.saveFolderOf": "“{name}”的存檔資料夾", + "home.findGame.theGame": "該遊戲", + "home.findGame.notKnown": "不在遊戲資料庫中——請為它命名;如果尚未設定存檔資料夾,請選擇一個。", + "home.findGame.saveFolder": "存檔資料夾", + "home.findGame.noSavesYet": "此電腦上尚未找到它的存檔。請選擇它的存檔位置,或先玩一次再跟蹤。", + "home.findGame.otherSaveFolder": "選擇其他存檔資料夾…", + "home.findGame.program": "程式", + "home.findGame.pickAnother": "← 選擇其他遊戲", + "home.findGame.already": "此處已作為“{tracked}”跟蹤。再次跟蹤會將相同的檔案同步兩次;請改為將該條目關聯到 {name},使其獲得遊戲資料庫中的名稱、App ID 和封面。", + "home.findGame.link": "將“{tracked}”關聯到 {name}", + "home.findGame.linked": "“{tracked}”現在是 {name}", + "home.trackGame": "跟蹤遊戲", + "home.trackGameTitle": "按名稱查詢遊戲,或選擇其程式或存檔資料夾", + "home.addGame.recognisedAs": "已識別為“{name}”——封面來自遊戲資料庫。" +} From f37b98d439d9c7661fe4f643af54ab6657a59593 Mon Sep 17 00:00:00 2001 From: Janlhin Date: Fri, 16 Oct 2026 07:04:46 +0800 Subject: [PATCH 2/5] i18n: extract the window shell (title bar, status bar, transfers, toasts) Whatever page you are on, these are on screen: the minimise / maximise / close buttons, the status line, the transfers panel and the toast's dismiss button, plus the shared Close / Dismiss / Loading labels the dialogs and the skeleton use. - Two keys are plurals (statusBar.syncing, statusBar.devicesOnline). Chinese has a single form, so both Chinese catalogs carry a plain string with {n}. - transfers.readError fills {error}; the status bar's hand-built "1 game / 2 games" plural is replaced by the catalog's own. - 21 new keys in each catalog. Left for another PR, because they are not plain strings: - the paused status and PauseButton: their text embeds pauseLength() - CloudOfferBanner and PairingBanner: the sentence is assembled around a device name in English word order - Sidebar's "in progress", CommandPalette's entries, ConflictModal and LocationConflictModal npm test: 310/310. --- .../frontend/src/components/StatusBar.svelte | 22 +++++----- .../frontend/src/components/TitleBar.svelte | 7 ++-- .../frontend/src/components/Toasts.svelte | 4 +- .../src/components/TransfersPanel.svelte | 15 +++---- .../frontend/src/components/ui/Modal.svelte | 3 +- .../src/components/ui/Skeleton.svelte | 4 +- cmd/opensave-app/frontend/src/locales/en.json | 29 ++++++++++++- .../frontend/src/locales/zh-Hans.json | 23 ++++++++++- .../frontend/src/locales/zh-Hant.json | 41 +++++++++++++++++-- 9 files changed, 118 insertions(+), 30 deletions(-) diff --git a/cmd/opensave-app/frontend/src/components/StatusBar.svelte b/cmd/opensave-app/frontend/src/components/StatusBar.svelte index 3eb55698..8e943ec1 100644 --- a/cmd/opensave-app/frontend/src/components/StatusBar.svelte +++ b/cmd/opensave-app/frontend/src/components/StatusBar.svelte @@ -12,6 +12,7 @@ import { native } from '../lib/api.js'; import AboutModal from './AboutModal.svelte'; import { paletteOpen } from '../lib/shortcuts.js'; + import { t } from '../lib/i18n.js'; const modKey = /Mac/i.test(globalThis.navigator?.platform ?? '') ? '⌘' : 'Ctrl'; @@ -44,9 +45,6 @@ $: if ($wsConnected) connectedOnce = true; $: lost = connectedOnce && !$wsConnected; $: onlinePeers = Object.values($peers).filter((p) => p.status === 'online').length; - $: statusText = running.length - ? `Syncing ${running.length} game${running.length > 1 ? 's' : ''}…` - : 'No syncs in progress'; {#if $showAbout} @@ -62,38 +60,38 @@
{#if lost} - Reconnecting… + {$t('statusBar.reconnecting')} {:else if $syncPause.paused} Syncing paused · {pauseShort($syncPause, now)} - + {:else} - {statusText} + {#if running.length}{$t('statusBar.syncing', { n: running.length })}{:else}{$t('statusBar.noSyncs')}{/if} {#if running.length && running[0][1].percentage != null} {running[0][1].percentage}% {/if} - + {/if}
{#if $wanRoom?.connected} relay: {$wanRoom.roomCode} {/if} - {onlinePeers} {onlinePeers === 1 ? 'device' : 'devices'} online + {$t('statusBar.devicesOnline', { n: onlinePeers })} - - +
diff --git a/cmd/opensave-app/frontend/src/components/TitleBar.svelte b/cmd/opensave-app/frontend/src/components/TitleBar.svelte index 8f3396c9..8acbdd08 100644 --- a/cmd/opensave-app/frontend/src/components/TitleBar.svelte +++ b/cmd/opensave-app/frontend/src/components/TitleBar.svelte @@ -1,5 +1,6 @@ @@ -12,13 +13,13 @@
- - -
diff --git a/cmd/opensave-app/frontend/src/components/Toasts.svelte b/cmd/opensave-app/frontend/src/components/Toasts.svelte index 1f857291..9f02c45c 100644 --- a/cmd/opensave-app/frontend/src/components/Toasts.svelte +++ b/cmd/opensave-app/frontend/src/components/Toasts.svelte @@ -1,5 +1,7 @@ - diff --git a/cmd/opensave-app/frontend/src/components/ui/Skeleton.svelte b/cmd/opensave-app/frontend/src/components/ui/Skeleton.svelte index 7884f8c8..cd55207c 100644 --- a/cmd/opensave-app/frontend/src/components/ui/Skeleton.svelte +++ b/cmd/opensave-app/frontend/src/components/ui/Skeleton.svelte @@ -1,4 +1,6 @@ -
+
{#if kind === 'tiles'}
diff --git a/cmd/opensave-app/frontend/src/locales/en.json b/cmd/opensave-app/frontend/src/locales/en.json index 562805af..c717f972 100644 --- a/cmd/opensave-app/frontend/src/locales/en.json +++ b/cmd/opensave-app/frontend/src/locales/en.json @@ -231,5 +231,32 @@ }, "game.config.deviceSettings": "Device settings", "game.config.syncDeviceSettings": "Sync these settings between devices too", - "game.config.deviceSettingsHint": "The game database lists these as this game's settings, not its save, so each device keeps its own (graphics, resolution). They are still in every snapshot." + "game.config.deviceSettingsHint": "The game database lists these as this game's settings, not its save, so each device keeps its own (graphics, resolution). They are still in every snapshot.", + "common.close": "Close", + "common.dismiss": "Dismiss", + "common.loading": "Loading", + "titleBar.minimise": "Minimise", + "titleBar.maximise": "Maximise", + "statusBar.reconnecting": "Reconnecting…", + "statusBar.reconnectingTitle": "The app has lost touch with its background service and is reconnecting", + "statusBar.noSyncs": "No syncs in progress", + "statusBar.syncing": { + "one": "Syncing {n} game…", + "other": "Syncing {n} games…" + }, + "statusBar.devicesOnline": { + "one": "{n} device online", + "other": "{n} devices online" + }, + "statusBar.resume": "Resume", + "statusBar.resumeTitle": "Resume syncing and catch up", + "statusBar.pause": "Pause syncing", + "statusBar.transfersHint": "What is moving between your devices", + "statusBar.paletteHint": "Jump to a game, a page or an action", + "statusBar.aboutTitle": "About OpenSave", + "transfers.title": "Transfers", + "transfers.empty": "Nothing has moved between your devices since OpenSave started.", + "transfers.now": "Now", + "transfers.recently": "Recently", + "transfers.readError": "Couldn't read transfers: {error}" } diff --git a/cmd/opensave-app/frontend/src/locales/zh-Hans.json b/cmd/opensave-app/frontend/src/locales/zh-Hans.json index d4718ae1..1d25a16e 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-Hans.json +++ b/cmd/opensave-app/frontend/src/locales/zh-Hans.json @@ -204,5 +204,26 @@ "snapshots.incomplete.more": " 等另外 {n} 个文件", "game.config.deviceSettings": "设备设置", "game.config.syncDeviceSettings": "也在设备之间同步这些设置", - "game.config.deviceSettingsHint": "游戏数据库将这些文件列为该游戏的设置而非存档,因此每台设备保留各自的设置(画质、分辨率)。它们仍包含在每个快照中。" + "game.config.deviceSettingsHint": "游戏数据库将这些文件列为该游戏的设置而非存档,因此每台设备保留各自的设置(画质、分辨率)。它们仍包含在每个快照中。", + "common.close": "关闭", + "common.dismiss": "忽略", + "common.loading": "加载中", + "titleBar.minimise": "最小化", + "titleBar.maximise": "最大化", + "statusBar.reconnecting": "正在重连…", + "statusBar.reconnectingTitle": "应用与后台服务失去联系,正在重连", + "statusBar.noSyncs": "当前没有进行中的同步", + "statusBar.syncing": "正在同步 {n} 个游戏…", + "statusBar.devicesOnline": "{n} 台设备在线", + "statusBar.resume": "恢复", + "statusBar.resumeTitle": "恢复同步并补齐", + "statusBar.pause": "暂停同步", + "statusBar.transfersHint": "设备之间正在传输的内容", + "statusBar.paletteHint": "跳转到游戏、页面或操作", + "statusBar.aboutTitle": "关于 OpenSave", + "transfers.title": "传输", + "transfers.empty": "自 OpenSave 启动以来,设备之间还没有传输过任何内容。", + "transfers.now": "进行中", + "transfers.recently": "最近", + "transfers.readError": "无法读取传输记录:{error}" } diff --git a/cmd/opensave-app/frontend/src/locales/zh-Hant.json b/cmd/opensave-app/frontend/src/locales/zh-Hant.json index 0e211381..3932963c 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-Hant.json +++ b/cmd/opensave-app/frontend/src/locales/zh-Hant.json @@ -2,6 +2,7 @@ "settings.language": "語言", "settings.systemDefault": "跟隨系統", "common.cancel": "取消", + "common.open": "開啟", "common.nowTracking": "已開始跟蹤“{name}”", "home.addGame.adding": "新增中…", "home.addGame.changeFolder": "更換資料夾…", @@ -166,6 +167,9 @@ "home.welcome.scanForSaves": "掃描存檔", "home.welcome.title": "歡迎使用 OpenSave", "home.welcome.trackManually": "手動跟蹤資料夾", + "home.trackGame": "跟蹤遊戲", + "home.trackGameTitle": "按名稱查詢遊戲,或選擇其程式或存檔資料夾", + "home.addGame.recognisedAs": "已識別為“{name}”——封面來自遊戲資料庫。", "home.findGame.title": "從遊戲資料庫跟蹤遊戲", "home.findGame.findByName": "按名稱查詢", "home.findGame.searchPlaceholder": "例如:Crimson Desert", @@ -188,7 +192,38 @@ "home.findGame.already": "此處已作為“{tracked}”跟蹤。再次跟蹤會將相同的檔案同步兩次;請改為將該條目關聯到 {name},使其獲得遊戲資料庫中的名稱、App ID 和封面。", "home.findGame.link": "將“{tracked}”關聯到 {name}", "home.findGame.linked": "“{tracked}”現在是 {name}", - "home.trackGame": "跟蹤遊戲", - "home.trackGameTitle": "按名稱查詢遊戲,或選擇其程式或存檔資料夾", - "home.addGame.recognisedAs": "已識別為“{name}”——封面來自遊戲資料庫。" + "restoreProgress.rebuilding": "正在重建快照", + "restoreProgress.checking": "正在檢查快照", + "restoreProgress.writing": "正在寫入檔案", + "restoreProgress.restoring": "正在恢復", + "restoreProgress.line": "{phase} — {percent}%({done}/{total})", + "restoreProgress.lineWithTimeLeft": "{phase} — {percent}%({done}/{total}),還剩{left}", + "restoreProgress.underAMinute": "不到一分鐘", + "restoreProgress.aboutMinutes": "約 {n} 分鐘", + "snapshots.incomplete": "“{name}”:自動快照一直缺少遊戲正在佔用的 {files}。在這些檔案可以讀取之前(通常是遊戲關閉後),它的更改不會被備份或同步。", + "snapshots.incomplete.more": " 等另外 {n} 個檔案", + "game.config.deviceSettings": "裝置設定", + "game.config.syncDeviceSettings": "也在裝置之間同步這些設定", + "game.config.deviceSettingsHint": "遊戲資料庫將這些檔案列為該遊戲的設定而非存檔,因此每臺裝置保留各自的設定(畫質、解析度)。它們仍包含在每個快照中。", + "common.close": "關閉", + "common.dismiss": "忽略", + "common.loading": "載入中", + "titleBar.minimise": "最小化", + "titleBar.maximise": "最大化", + "statusBar.reconnecting": "正在重連…", + "statusBar.reconnectingTitle": "應用與後臺服務失去聯絡,正在重連", + "statusBar.noSyncs": "當前沒有進行中的同步", + "statusBar.syncing": "正在同步 {n} 個遊戲…", + "statusBar.devicesOnline": "{n} 臺裝置線上", + "statusBar.resume": "恢復", + "statusBar.resumeTitle": "恢復同步並補齊", + "statusBar.pause": "暫停同步", + "statusBar.transfersHint": "裝置之間正在傳輸的內容", + "statusBar.paletteHint": "跳轉到遊戲、頁面或操作", + "statusBar.aboutTitle": "關於 OpenSave", + "transfers.title": "傳輸", + "transfers.empty": "自 OpenSave 啟動以來,裝置之間還沒有傳輸過任何內容。", + "transfers.now": "進行中", + "transfers.recently": "最近", + "transfers.readError": "無法讀取傳輸記錄:{error}" } From 82e95c50a0300ae0056d7117e5313eec5e774229 Mon Sep 17 00:00:00 2001 From: Janlhin Date: Fri, 16 Oct 2026 23:16:31 +0800 Subject: [PATCH 3/5] zh: polish the Track game strings ported from #73 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review pass on the ported keys: - unify save-folder term (存档文件夹 / 存檔資料夾) in noSavesYet - unify program term (游戏程序 / 遊戲程式) with chooseProgram/programDialog - align noSavesHere / noSavesYet phrasing (没有找到它的存档) - theGame: 该游戏 -> 这款游戏 (more natural inline) - already: 请改为将该条目关联到 -> 请把该条目关联到 - rename home.trackGameTitle -> home.trackGameHint (it is the button's hint text, not a title; the dialog title is home.findGame.title) --- cmd/opensave-app/frontend/src/locales/en.json | 2 +- .../frontend/src/locales/zh-Hans.json | 12 +-- .../frontend/src/locales/zh-Hant.json | 82 +++++++++---------- .../frontend/src/views/Home.svelte | 2 +- 4 files changed, 49 insertions(+), 49 deletions(-) diff --git a/cmd/opensave-app/frontend/src/locales/en.json b/cmd/opensave-app/frontend/src/locales/en.json index c717f972..37e34a1f 100644 --- a/cmd/opensave-app/frontend/src/locales/en.json +++ b/cmd/opensave-app/frontend/src/locales/en.json @@ -189,7 +189,7 @@ "home.welcome.title": "Welcome to OpenSave", "home.welcome.trackManually": "Track a folder manually", "home.trackGame": "Track game", - "home.trackGameTitle": "Find a game by name, or choose its program or save folder", + "home.trackGameHint": "Find a game by name, or choose its program or save folder", "home.addGame.recognisedAs": "Recognised as \"{name}\" — its cover art comes from the game database.", "home.findGame.title": "Track a game from the game database", "home.findGame.findByName": "Find by name", diff --git a/cmd/opensave-app/frontend/src/locales/zh-Hans.json b/cmd/opensave-app/frontend/src/locales/zh-Hans.json index 1d25a16e..61697d1a 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-Hans.json +++ b/cmd/opensave-app/frontend/src/locales/zh-Hans.json @@ -168,13 +168,13 @@ "home.welcome.title": "欢迎使用 OpenSave", "home.welcome.trackManually": "手动跟踪文件夹", "home.trackGame": "跟踪游戏", - "home.trackGameTitle": "按名称查找游戏,或选择其程序或存档文件夹", + "home.trackGameHint": "按名称查找游戏,或选择其程序或存档文件夹", "home.addGame.recognisedAs": "已识别为“{name}”——封面来自游戏数据库。", "home.findGame.title": "从游戏数据库跟踪游戏", "home.findGame.findByName": "按名称查找", "home.findGame.searchPlaceholder": "例如:Crimson Desert", "home.findGame.searching": "搜索中…", - "home.findGame.noSavesHere": "此电脑上未找到存档", + "home.findGame.noSavesHere": "此电脑上没有找到它的存档", "home.findGame.trackedAs": "已作为“{name}”跟踪", "home.findGame.nothingFound": "游戏数据库中没有这个名称的游戏。", "home.findGame.chooseProgram": "选择游戏程序…", @@ -182,14 +182,14 @@ "home.findGame.programDialog": "选择游戏程序", "home.findGame.saveFolderDialog": "选择游戏的存档文件夹", "home.findGame.saveFolderOf": "“{name}”的存档文件夹", - "home.findGame.theGame": "该游戏", + "home.findGame.theGame": "这款游戏", "home.findGame.notKnown": "不在游戏数据库中——请为它命名;如果尚未设置存档文件夹,请选择一个。", "home.findGame.saveFolder": "存档文件夹", - "home.findGame.noSavesYet": "此电脑上尚未找到它的存档。请选择它的存档位置,或先玩一次再跟踪。", + "home.findGame.noSavesYet": "此电脑上还没有找到它的存档。请选择它的存档文件夹,或先玩一次再跟踪。", "home.findGame.otherSaveFolder": "选择其他存档文件夹…", - "home.findGame.program": "程序", + "home.findGame.program": "游戏程序", "home.findGame.pickAnother": "← 选择其他游戏", - "home.findGame.already": "此处已作为“{tracked}”跟踪。再次跟踪会将相同的文件同步两次;请改为将该条目关联到 {name},使其获得游戏数据库中的名称、App ID 和封面。", + "home.findGame.already": "此处已作为“{tracked}”跟踪。再次跟踪会将相同的文件同步两次;请把该条目关联到 {name},使其获得游戏数据库中的名称、App ID 和封面。", "home.findGame.link": "将“{tracked}”关联到 {name}", "home.findGame.linked": "“{tracked}”现在是 {name}", "restoreProgress.rebuilding": "正在重建快照", diff --git a/cmd/opensave-app/frontend/src/locales/zh-Hant.json b/cmd/opensave-app/frontend/src/locales/zh-Hant.json index 3932963c..4db04909 100644 --- a/cmd/opensave-app/frontend/src/locales/zh-Hant.json +++ b/cmd/opensave-app/frontend/src/locales/zh-Hant.json @@ -3,17 +3,17 @@ "settings.systemDefault": "跟隨系統", "common.cancel": "取消", "common.open": "開啟", - "common.nowTracking": "已開始跟蹤“{name}”", + "common.nowTracking": "已開始跟踪“{name}”", "home.addGame.adding": "新增中…", "home.addGame.changeFolder": "更換資料夾…", - "home.addGame.chooseFile": "選擇要跟蹤的存檔檔案", + "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.addGame.startTracking": "開始跟踪", + "home.addGame.title": "跟踪遊戲", + "home.addGame.trackFileInstead": "改為跟踪單個檔案…", "home.library.bulkDone": "已為 {n} 個遊戲{verb}", "home.library.bulkPartial": "已為 {done}/{total} 個遊戲{verb} — {failed}", "home.library.clearFilters": "清除篩選", @@ -38,7 +38,7 @@ "home.library.syncTitle": "將每個遊戲與你的裝置同步", "home.library.title": "遊戲庫", "home.library.unselectAll": "取消全選", - "home.library.untrackSelected": "取消跟蹤所選", + "home.library.untrackSelected": "取消跟踪所選", "home.library.verbFavourite": "加入收藏", "home.library.verbSnapshot": "建立快照", "home.library.verbSync": "完成同步", @@ -47,59 +47,59 @@ "home.library.viewLabel": "遊戲庫檢視", "home.offered.chooseFolder": "選擇資料夾…", "home.offered.decline": "拒絕", - "home.offered.declineToast": "不會再詢問你關於 {name} 的事,之後仍可自行跟蹤。", + "home.offered.declineToast": "不會再詢問你關於 {name} 的事,之後仍可自行跟踪。", "home.offered.folderPrompt": "在本機上為“{name}”選擇資料夾", "home.offered.intro": "其他裝置正在同步這些遊戲,但本機還不知道該把存檔放在哪裡。選擇資料夾後才會開始同步。", "home.offered.pathHint": "在另一臺裝置上位於 {path}。", "home.offered.title": "等待選擇資料夾", - "home.offered.trackedToast": "{name} 已在本機開始跟蹤", + "home.offered.trackedToast": "{name} 已在本機開始跟踪", "home.recent.allActivity": "全部動態", "home.recent.empty": "暫無記錄。快照和同步會在發生時顯示在這裡。", "home.recent.label": "最近動態", - "home.scan.alreadyTracked": "已跟蹤 ({n})", + "home.scan.alreadyTracked": "已跟踪 ({n})", "home.scan.badResponse": "掃描返回的不是存檔列表。請重啟 OpenSave 後重新掃描。", "home.scan.clear": "清除選擇", "home.scan.emptyNoMatches": "沒有符合此篩選條件的結果。", - "home.scan.emptyNothingDetected": "未檢測到任何存檔。你仍可以手動跟蹤任意資料夾。", + "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.headTick": "勾選屬於此存檔的資料夾——它們會作為一個遊戲來跟踪。", "home.scan.folderList.tagAlt": "另一個副本——可能是舊的安裝", "home.scan.folderList.tagInside": "已包含在上面的資料夾中", "home.scan.folderList.tagLocation": "同一存檔的一部分", "home.scan.folderList.tagSave": "存檔資料夾", - "home.scan.folderList.tagTracked": "已跟蹤", + "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.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.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.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": "硬碟版", @@ -116,7 +116,7 @@ "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.text": "OpenSave 會掃描 Steam、模擬器和常見的存檔資料夾,由你選擇要跟踪的內容。", "home.setup.stepSaves.title": "找到你的遊戲存檔", "home.setup.title": "開始設定 OpenSave", "home.summary.cloudBackup": "雲備份", @@ -150,7 +150,7 @@ "home.viewOptions.overview": "在主頁顯示最近動態", "home.viewOptions.tileSize": "磁貼大小", "home.viewOptions.tilesPerRow": "每行磁貼數", - "home.chooseFolderDialog": "選擇要跟蹤的存檔資料夾", + "home.chooseFolderDialog": "選擇要跟踪的存檔資料夾", "home.cloudBackup": "雲備份", "home.devices": "裝置", "home.scanSaves": "掃描存檔", @@ -160,36 +160,36 @@ "home.syncAllToast": "已觸發全部遊戲的同步", "home.syncPaused": "同步已暫停", "home.title": "主頁", - "home.trackFolder": "跟蹤資料夾", - "home.trackFolderTitle": "選擇要跟蹤的存檔資料夾或檔案", + "home.trackFolder": "跟踪資料夾", + "home.trackFolderTitle": "選擇要跟踪的存檔資料夾或檔案", "home.welcome.hint": "然後開啟 {devices} 配對另一臺電腦或 Steam Deck,或開啟 {cloud} 將快照鏡像到雲端。", "home.welcome.intro": "讓你的遊戲存檔在所有裝置間保持同步——無需帳號,不被雲端綁架。先找到你的存檔:", "home.welcome.scanForSaves": "掃描存檔", "home.welcome.title": "歡迎使用 OpenSave", - "home.welcome.trackManually": "手動跟蹤資料夾", - "home.trackGame": "跟蹤遊戲", - "home.trackGameTitle": "按名稱查詢遊戲,或選擇其程式或存檔資料夾", + "home.welcome.trackManually": "手動跟踪資料夾", + "home.trackGame": "跟踪遊戲", + "home.trackGameHint": "按名稱查詢遊戲,或選擇其程式或存檔資料夾", "home.addGame.recognisedAs": "已識別為“{name}”——封面來自遊戲資料庫。", - "home.findGame.title": "從遊戲資料庫跟蹤遊戲", + "home.findGame.title": "從遊戲資料庫跟踪遊戲", "home.findGame.findByName": "按名稱查詢", "home.findGame.searchPlaceholder": "例如:Crimson Desert", "home.findGame.searching": "搜尋中…", - "home.findGame.noSavesHere": "此電腦上未找到存檔", - "home.findGame.trackedAs": "已作為“{name}”跟蹤", + "home.findGame.noSavesHere": "此電腦上沒有找到它的存檔", + "home.findGame.trackedAs": "已作為“{name}”跟踪", "home.findGame.nothingFound": "遊戲資料庫中沒有這個名稱的遊戲。", "home.findGame.chooseProgram": "選擇遊戲程式…", "home.findGame.chooseSaveFolder": "選擇其存檔資料夾…", "home.findGame.programDialog": "選擇遊戲程式", "home.findGame.saveFolderDialog": "選擇遊戲的存檔資料夾", "home.findGame.saveFolderOf": "“{name}”的存檔資料夾", - "home.findGame.theGame": "該遊戲", + "home.findGame.theGame": "這款遊戲", "home.findGame.notKnown": "不在遊戲資料庫中——請為它命名;如果尚未設定存檔資料夾,請選擇一個。", "home.findGame.saveFolder": "存檔資料夾", - "home.findGame.noSavesYet": "此電腦上尚未找到它的存檔。請選擇它的存檔位置,或先玩一次再跟蹤。", + "home.findGame.noSavesYet": "此電腦上還沒有找到它的存檔。請選擇它的存檔資料夾,或先玩一次再跟踪。", "home.findGame.otherSaveFolder": "選擇其他存檔資料夾…", - "home.findGame.program": "程式", + "home.findGame.program": "遊戲程式", "home.findGame.pickAnother": "← 選擇其他遊戲", - "home.findGame.already": "此處已作為“{tracked}”跟蹤。再次跟蹤會將相同的檔案同步兩次;請改為將該條目關聯到 {name},使其獲得遊戲資料庫中的名稱、App ID 和封面。", + "home.findGame.already": "此處已作為“{tracked}”跟踪。再次跟踪會將相同的檔案同步兩次;請把該條目關聯到 {name},使其獲得遊戲資料庫中的名稱、App ID 和封面。", "home.findGame.link": "將“{tracked}”關聯到 {name}", "home.findGame.linked": "“{tracked}”現在是 {name}", "restoreProgress.rebuilding": "正在重建快照", diff --git a/cmd/opensave-app/frontend/src/views/Home.svelte b/cmd/opensave-app/frontend/src/views/Home.svelte index 0287353d..fcb53894 100644 --- a/cmd/opensave-app/frontend/src/views/Home.svelte +++ b/cmd/opensave-app/frontend/src/views/Home.svelte @@ -112,7 +112,7 @@ - +
From d8d60ed726d840a5d927e6e04820e95f49bd6b7c Mon Sep 17 00:00:00 2001 From: Janlhin Date: Sun, 18 Oct 2026 11:42:09 +0800 Subject: [PATCH 4/5] i18n: map a pre-split zh-CN choice through scriptFor A saved 'zh-CN' (or any other zh-* tag) from before the script split is no longer a LOCALES id, so it fell back to the system language and someone who picked Chinese silently lost it. sanitizeLocale now maps such tags through scriptFor: zh-CN -> zh-Hans, zh-TW/zh-HK/zh-MO -> zh-Hant, bare zh -> zh-Hans. Non-Chinese garbage still rejects to null. Requested by Omar-VF in #77. --- cmd/opensave-app/frontend/src/lib/i18n.js | 8 ++++++-- cmd/opensave-app/frontend/src/lib/i18n.test.js | 12 ++++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/cmd/opensave-app/frontend/src/lib/i18n.js b/cmd/opensave-app/frontend/src/lib/i18n.js index 72e287ee..4de76310 100644 --- a/cmd/opensave-app/frontend/src/lib/i18n.js +++ b/cmd/opensave-app/frontend/src/lib/i18n.js @@ -31,9 +31,13 @@ const CATALOGS = { en, 'zh-Hans': zhHans, 'zh-Hant': zhHant }; const FALLBACK = 'en'; const KEY = 'opensave.locale'; -/** A locale id if valid, otherwise null. */ +/** A locale id if valid, otherwise null. A Chinese tag saved before the + * script split (`zh-CN`, `zh-TW`, `zh-HK`, bare `zh`, …) maps through + * `scriptFor`, so someone who picked Chinese keeps it. */ export function sanitizeLocale(raw) { - return typeof raw === 'string' && (raw === SYSTEM_LOCALE || raw in LOCALES) ? raw : null; + if (typeof raw !== 'string') return null; + if (raw === SYSTEM_LOCALE || raw in LOCALES) return raw; + return scriptFor(raw); } /** The saved choice, or 'system' when the user has never picked one. */ diff --git a/cmd/opensave-app/frontend/src/lib/i18n.test.js b/cmd/opensave-app/frontend/src/lib/i18n.test.js index b80e8932..45e761e8 100644 --- a/cmd/opensave-app/frontend/src/lib/i18n.test.js +++ b/cmd/opensave-app/frontend/src/lib/i18n.test.js @@ -85,13 +85,21 @@ describe('segments', () => { describe('locale choice', () => { it('rejects unknown locale ids, and region tags that are not languages', () => { expect(sanitizeLocale('fr')).toBe(null); - expect(sanitizeLocale('zh-CN')).toBe(null); - expect(sanitizeLocale('zh-TW')).toBe(null); expect(sanitizeLocale('zh-Hans')).toBe('zh-Hans'); expect(sanitizeLocale('zh-Hant')).toBe('zh-Hant'); expect(sanitizeLocale('system')).toBe('system'); }); + it('maps a Chinese choice saved before the script split onto its script', () => { + expect(sanitizeLocale('zh-CN')).toBe('zh-Hans'); + expect(sanitizeLocale('zh-SG')).toBe('zh-Hans'); + expect(sanitizeLocale('zh-TW')).toBe('zh-Hant'); + expect(sanitizeLocale('zh-HK')).toBe('zh-Hant'); + expect(sanitizeLocale('zh')).toBe('zh-Hans'); + expect(loadLocale({ getItem: () => 'zh-CN' })).toBe('zh-Hans'); + expect(loadLocale({ getItem: () => 'zh-TW' })).toBe('zh-Hant'); + }); + it("reports 'system' when the user has never picked one", () => { expect(loadLocale({ getItem: () => null })).toBe('system'); expect(loadLocale({ getItem: () => 'zh-Hant' })).toBe('zh-Hant'); From 9adcd8069d0931b4df92c8307e78875c0f667282 Mon Sep 17 00:00:00 2001 From: Janlhin Date: Mon, 19 Oct 2026 18:21:59 +0800 Subject: [PATCH 5/5] test: migrate the restoreProgress language test from zh-CN to zh-Hans zh-CN is no longer a LOCALES id after the script split; the test must target zh-Hans, the Simplified catalog, to assert Chinese text. --- cmd/opensave-app/frontend/src/lib/restoreprogress.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/opensave-app/frontend/src/lib/restoreprogress.test.js b/cmd/opensave-app/frontend/src/lib/restoreprogress.test.js index 570bb7a2..8d0408cf 100644 --- a/cmd/opensave-app/frontend/src/lib/restoreprogress.test.js +++ b/cmd/opensave-app/frontend/src/lib/restoreprogress.test.js @@ -30,7 +30,7 @@ describe('restoreProgressText', () => { expect(say('en', { phase: 'later', done: 0, total: 0 })).toContain('Restoring'); }); it('says the line in the chosen language', () => { - const line = say('zh-CN', { phase: 'writing', done: 60000, total: 240000, secondsLeft: 150 }); + const line = say('zh-Hans', { phase: 'writing', done: 60000, total: 240000, secondsLeft: 150 }); expect(line).toContain('正在写入文件'); expect(line).toContain('25%'); expect(line).toContain('约 3 分钟');