From 144eb286d8372c9fe24c52b4730a837650731212 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Mon, 13 Jul 2026 14:28:18 +0000 Subject: [PATCH] feat: add i18n (multi-language) support (#38) Add an i18n layer (t(key, vars?)) with English (default) and Traditional Chinese translations, detecting the active locale via Obsidian's window.moment.locale() with a fallback to English for unsupported locales. Replace ~130 hardcoded UI strings across the settings tab, ribbon/ command labels, Notice messages, and the sync status view/modals with translated keys. Left untranslated on purpose: service provider names (GitHub/GitLab/Gitea), diff-format markers, URL placeholders, and changelog release-note content. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011BNWwPd5zudtW2JQr6ZAUm --- src/i18n/index.ts | 42 +++++++ src/i18n/locales/en.ts | 188 +++++++++++++++++++++++++++++ src/i18n/locales/zh-tw.ts | 189 ++++++++++++++++++++++++++++++ src/main.ts | 37 +++--- src/settings.ts | 122 +++++++++---------- src/ui/ConfirmModal.ts | 7 +- src/ui/SyncConflictModal.ts | 27 +++-- src/ui/SyncStatusView.ts | 86 ++++++++------ src/ui/WhatsNewModal.ts | 7 +- src/ui/components/ActionBar.ts | 13 +- src/ui/components/DiffPanel.ts | 5 +- src/ui/components/FileListItem.ts | 31 ++--- tests/i18n/index.test.ts | 54 +++++++++ 13 files changed, 655 insertions(+), 153 deletions(-) create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/locales/en.ts create mode 100644 src/i18n/locales/zh-tw.ts create mode 100644 tests/i18n/index.test.ts diff --git a/src/i18n/index.ts b/src/i18n/index.ts new file mode 100644 index 0000000..7f78619 --- /dev/null +++ b/src/i18n/index.ts @@ -0,0 +1,42 @@ +import en, { TranslationKey } from './locales/en'; +import zhTw from './locales/zh-tw'; + +export type { TranslationKey }; + +const locales: Record>> = { + en, + 'zh-tw': zhTw, +}; + +// Obsidian sets window.moment's locale to match the app's display language +// before plugins load. Not typed in the `obsidian` package, so read it off +// the global defensively. +function detectMomentLocale(): string { + const w = window as unknown as { moment?: { locale: () => string } }; + try { + return w.moment?.locale()?.toLowerCase() ?? ''; + } catch { + return ''; + } +} + +// Maps a moment locale code to one of our shipped locales, falling back to +// English when there's no matching translation. +function resolveLocale(rawLocale: string): string { + if (rawLocale in locales) return rawLocale; + if (rawLocale.startsWith('zh')) return 'zh-tw'; + return 'en'; +} + +export function getActiveLocale(): string { + return resolveLocale(detectMomentLocale()); +} + +export function t(key: TranslationKey, vars?: Record): string { + const dict = locales[getActiveLocale()] ?? en; + const template = dict[key] ?? en[key]; + if (!vars) return template; + return template.replace(/\{(\w+)\}/g, (match, name: string) => + name in vars ? String(vars[name]) : match + ); +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts new file mode 100644 index 0000000..988e94d --- /dev/null +++ b/src/i18n/locales/en.ts @@ -0,0 +1,188 @@ +// Source of truth for translation keys. Every key used by `t()` must exist +// here; other locales may omit keys and fall back to this file. +const en = { + 'settings.connectionStatus.checking': 'Checking…', + 'settings.connectionStatus.connected': 'Connected', + 'settings.connectionStatus.disconnected': 'Not connected', + 'settings.connectionStatus.withDetail': '{label} — {detail}', + + 'settings.gitService.name': 'Git service', + 'settings.gitService.desc': 'Choose your Git hosting service', + + 'settings.branch.name': 'Branch', + 'settings.branch.desc': 'Branch to push or pull from', + 'settings.branch.placeholder': 'Main', + + 'settings.rootPath.name': 'Root path', + 'settings.rootPath.desc': 'Optional: subfolder in repository (e.g. "notes")', + 'settings.rootPath.placeholder': 'Enter subfolder path', + + 'settings.vaultFolder.name': 'Vault folder', + 'settings.vaultFolder.desc': 'Optional: only sync files in this vault folder (e.g. "sync" to only sync files in the sync folder)', + 'settings.vaultFolder.placeholder': 'Leave empty to sync all files', + + 'settings.ignorePatterns.name': 'Ignore patterns', + 'settings.ignorePatterns.desc': 'Optional: .gitignore-style patterns (one per line) to exclude local files from sync, in addition to the repository\'s own .gitignore.', + + 'settings.symlinks.name': 'Symbolic links', + 'settings.symlinks.desc.supported': 'How to sync symlinks: "real" recreates the link on desktop and falls back to the target content on mobile, "follow" always syncs the target content, and "skip" ignores symlinks.', + 'settings.symlinks.desc.unsupported': 'How to sync symlinks: "follow" syncs the target content as a normal file, "skip" ignores symlinks. Real symlinks require GitHub.', + 'settings.symlinks.option.real': 'Real symlink (recommended)', + 'settings.symlinks.option.follow': 'Follow (sync target content)', + 'settings.symlinks.option.skip': 'Skip', + + 'settings.testConnection.name': 'Test connection', + 'settings.testConnection.desc': 'Verify your {service} settings', + 'settings.testConnection.button': 'Test connection', + 'settings.testConnection.failed': 'Connection failed: {reason}', + 'settings.testConnection.failed.unreachable': 'could not reach the repository', + 'settings.testConnection.branchNotFound.badge': 'branch "{branch}" not found', + 'settings.testConnection.branchNotFound.notice': 'Connected, but branch "{branch}" was not found. Check the Branch setting, or confirm the repository has a branch with this name.', + 'settings.testConnection.success': '{service} connection successful!', + + 'settings.token.placeholder': 'Enter your token', + 'settings.token.show': 'Show token', + 'settings.token.hide': 'Hide token', + + 'settings.gitlab.token.name': 'GitLab personal access token', + 'settings.gitlab.token.desc': 'Create a token in GitLab user settings > access tokens with "API" scope', + 'settings.gitlab.baseUrl.name': 'GitLab base URL', + 'settings.gitlab.baseUrl.desc': 'Defaults to https://gitlab.com', + 'settings.gitlab.projectId.name': 'Project ID', + 'settings.gitlab.projectId.desc': 'Found in GitLab project overview', + 'settings.gitlab.projectId.placeholder': 'Enter numeric project ID', + + 'settings.gitea.token.name': 'Gitea personal access token', + 'settings.gitea.token.desc': 'Create a token in user settings > applications > access tokens', + 'settings.gitea.baseUrl.name': 'Gitea base URL', + 'settings.gitea.baseUrl.desc': 'URL of your Gitea instance (e.g. https://gitea.example.com)', + + 'settings.github.token.name': 'GitHub personal access token', + 'settings.github.token.desc': 'Create a token in GitHub settings > developer settings > personal access tokens with "repo" scope', + + 'settings.repoOwner.name': 'Repository owner', + 'settings.repoOwner.desc.gitea': 'Gitea username or organization name', + 'settings.repoOwner.desc.github': 'GitHub username or organization name', + 'settings.repoOwner.placeholder': 'Username', + + 'settings.repoName.name': 'Repository name', + 'settings.repoName.desc.gitea': 'Name of the repository', + 'settings.repoName.desc.github': 'Name of the GitHub repository', + 'settings.repoName.placeholder': 'My notes', + + 'main.ribbon.openSyncStatus': 'Open sync status', + 'main.ribbon.push': 'Push', + 'main.ribbon.pushTo': 'Push to {service}', + 'main.command.openSyncStatus': 'Open sync status', + 'main.command.pushCurrentFile': 'Push current file', + 'main.command.pullCurrentFile': 'Pull current file', + 'main.command.pushAllFiles': 'Push all files', + 'main.command.pullAllFiles': 'Pull all files', + 'main.notice.noActiveNote': 'No active note to push', + 'main.contextMenu.pushTo': 'Push to {service}', + 'main.contextMenu.pullFrom': 'Pull from {service}', + 'main.notice.noFilesToRun': 'No files to {op} in the configured vault folder', + 'main.confirm.pushAll': 'Push {count} file(s) to {service}?', + 'main.confirm.pullAll': 'Pull {count} file(s) from {service}? This will overwrite local changes.', + 'main.progress.running': '{verb} 0/{total} files...', + 'main.progress.step': '{verb} {current}/{total}: {fileName}', + 'main.notice.runFailed': '{verb} failed: {message}', + 'main.op.push': 'push', + 'main.op.pull': 'pull', + 'main.verb.pushing': 'Pushing', + 'main.verb.pulling': 'Pulling', + 'main.verb.push': 'Push', + 'main.verb.pull': 'Pull', + + 'confirmModal.title': 'Confirm', + 'confirmModal.confirm': 'Confirm', + 'confirmModal.cancel': 'Cancel', + + 'whatsNew.title': "What's new", + 'whatsNew.viewChangelog': 'View full changelog', + 'whatsNew.gotIt': 'Got it', + + 'syncStatus.viewTitle': 'Sync status', + 'syncStatus.emptyPrompt': 'Click "Refresh" to check sync status', + 'syncStatus.progress.checkingWithCount': 'Checking files… {current}/{total} ({pct}%)', + 'syncStatus.progress.checking': 'Checking files…', + 'syncStatus.lastSync': 'Last sync: {time}', + 'syncStatus.tab.all': 'All', + 'syncStatus.tab.synced': 'Synced', + 'syncStatus.tab.modified': 'Changed', + 'syncStatus.tab.unsynced': 'Local only', + 'syncStatus.tab.remote-only': 'Remote', + 'syncStatus.noFilesForFilter': 'No {filter} files', + 'syncStatus.confirmDeleteLocal': 'Delete local file "{path}"? Handled per your vault\'s "Deleted files" setting.', + 'syncStatus.notice.deleted': 'Deleted {path}', + 'syncStatus.notice.deleteFailed': 'Failed to delete: {message}', + 'syncStatus.notice.opFailed': '{verb} failed: {message}', + 'syncStatus.notice.alreadyRefreshing': 'Already refreshing…', + 'syncStatus.notice.refreshed': 'Checked {local} local + {remote} remote files', + 'syncStatus.notice.refreshFailed': 'Failed to refresh: {message}', + 'syncStatus.notice.noPushableFiles.selected': 'No pushable files selected.', + 'syncStatus.notice.noPushableFiles.found': 'No pushable files found.', + 'syncStatus.notice.noPullableFiles.selected': 'No pullable files selected.', + 'syncStatus.notice.noPullableFiles.found': 'No pullable files found.', + 'syncStatus.confirm.pushSelected': 'Push {count} file(s) to {service}?', + 'syncStatus.confirm.pullSelected': 'Pull {count} file(s) from {service}? This will overwrite local changes.', + 'syncStatus.notice.opCompleted': '{verb} completed. Refreshing…', + 'syncStatus.notice.nothingToDelete': 'Nothing to delete', + 'syncStatus.notice.noFilesSelected': 'No files selected', + 'syncStatus.confirmDelete.localAndRemote': "Delete {local} local file(s) (per your vault's trash setting) and {remote} remote file(s) (cannot be undone)?", + 'syncStatus.confirmDelete.localOnly': 'Delete {local} local file(s)? They\'ll be handled per your vault\'s "Deleted files" setting.', + 'syncStatus.confirmDelete.remoteOnly': 'Delete {remote} remote file(s)? This cannot be undone.', + 'syncStatus.notice.deleteResult.partial': 'Deleted {succeeded}/{total}. {failed} failed.', + 'syncStatus.notice.deleteResult.success': 'Deleted {total} files', + 'syncStatus.progress.deleting': 'Deleting 0/{total} files…', + 'syncStatus.progress.pushing': 'Pushing {current}/{total}: {name}', + 'syncStatus.progress.pulling': 'Pulling {current}/{total}: {name}', + 'syncStatus.progress.deletingLocal': 'Deleting local {current}/{total}: {path}', + 'syncStatus.progress.deletingRemote': 'Deleting remote {current}/{total}: {path}', + + 'actionBar.select': 'Select', + 'actionBar.refresh': ' Refresh', + 'actionBar.refreshAll': 'Refresh all statuses', + 'actionBar.pushCount': ' Push ({count})', + 'actionBar.pushFiles': 'Push {count} files', + 'actionBar.pullCount': ' Pull ({count})', + 'actionBar.pullFiles': 'Pull {count} files', + 'actionBar.deleteCount': ' Delete ({count})', + 'actionBar.deleteFiles': 'Delete {count} files', + + 'syncStatus.status.checking': 'Checking', + + 'fileListItem.action.push': ' Push', + 'fileListItem.action.pull': ' Pull', + 'fileListItem.action.remove': ' Remove', + 'fileListItem.action.diff': ' Diff', + 'fileListItem.action.hide': ' Hide', + 'fileListItem.tooltip.pushToRemote': 'Push to remote', + 'fileListItem.tooltip.pullFromRemote': 'Pull from remote', + 'fileListItem.tooltip.deleteLocalFile': 'Delete local file', + 'fileListItem.tooltip.toggleDiff': 'Toggle diff view', + 'fileListItem.diff.symlinkChanged': 'Symlink target changed', + 'fileListItem.diff.loading': 'Loading diff…', + 'fileListItem.diff.clickToLoad': 'Click Diff to load…', + 'fileListItem.diff.binaryChanged': 'Binary file changed', + + 'diffPanel.remote': 'Remote', + 'diffPanel.local': 'Local', + + 'syncConflictModal.title': 'Conflict in {fileName}', + 'syncConflictModal.description': 'The remote file has different content. Review the differences and choose which version to keep.', + 'syncConflictModal.tab.diff': 'Diff', + 'syncConflictModal.tab.local': 'Local', + 'syncConflictModal.tab.remote': 'Remote', + 'syncConflictModal.localVersion': 'Local version', + 'syncConflictModal.remoteVersion': 'Remote version', + 'syncConflictModal.differences': 'Differences', + 'syncConflictModal.keepLocal': 'Keep local', + 'syncConflictModal.keepLocal.tooltip': 'Overwrite remote with your local content', + 'syncConflictModal.keepRemote': 'Keep remote', + 'syncConflictModal.keepRemote.tooltip': 'Overwrite local with remote content', + 'syncConflictModal.cancel': 'Cancel', +}; + +export default en; +export type TranslationKey = keyof typeof en; diff --git a/src/i18n/locales/zh-tw.ts b/src/i18n/locales/zh-tw.ts new file mode 100644 index 0000000..4377a9f --- /dev/null +++ b/src/i18n/locales/zh-tw.ts @@ -0,0 +1,189 @@ +import type { TranslationKey } from './en'; + +// Only needs to cover the keys that have a translation; anything missing +// falls back to en.ts at lookup time. +const zhTw: Partial> = { + 'settings.connectionStatus.checking': '檢查中…', + 'settings.connectionStatus.connected': '已連線', + 'settings.connectionStatus.disconnected': '未連線', + 'settings.connectionStatus.withDetail': '{label} — {detail}', + + 'settings.gitService.name': 'Git 服務', + 'settings.gitService.desc': '選擇您的 Git 代管服務', + + 'settings.branch.name': '分支', + 'settings.branch.desc': '要推送或拉取的分支', + 'settings.branch.placeholder': 'Main', + + 'settings.rootPath.name': '根路徑', + 'settings.rootPath.desc': '選填:儲存庫中的子資料夾(例如「notes」)', + 'settings.rootPath.placeholder': '輸入子資料夾路徑', + + 'settings.vaultFolder.name': '保存庫資料夾', + 'settings.vaultFolder.desc': '選填:僅同步此保存庫資料夾內的檔案(例如「sync」則只同步 sync 資料夾內的檔案)', + 'settings.vaultFolder.placeholder': '留空以同步所有檔案', + + 'settings.ignorePatterns.name': '忽略規則', + 'settings.ignorePatterns.desc': '選填:以 .gitignore 語法(每行一條)排除本機檔案,會與遠端儲存庫的 .gitignore 規則一併套用。', + + 'settings.symlinks.name': '符號連結', + 'settings.symlinks.desc.supported': '符號連結的同步方式:「real」會在桌面版重建連結,行動裝置則改為同步目標內容;「follow」永遠同步目標內容;「skip」則忽略符號連結。', + 'settings.symlinks.desc.unsupported': '符號連結的同步方式:「follow」以一般檔案同步目標內容,「skip」則忽略符號連結。真實符號連結僅支援 GitHub。', + 'settings.symlinks.option.real': '真實符號連結(建議)', + 'settings.symlinks.option.follow': 'Follow(同步目標內容)', + 'settings.symlinks.option.skip': 'Skip(忽略)', + + 'settings.testConnection.name': '測試連線', + 'settings.testConnection.desc': '驗證您的 {service} 設定', + 'settings.testConnection.button': '測試連線', + 'settings.testConnection.failed': '連線失敗:{reason}', + 'settings.testConnection.failed.unreachable': '無法連線至儲存庫', + 'settings.testConnection.branchNotFound.badge': '找不到分支「{branch}」', + 'settings.testConnection.branchNotFound.notice': '已連線,但找不到分支「{branch}」。請檢查分支設定,或確認儲存庫中存在此分支。', + 'settings.testConnection.success': '{service} 連線成功!', + + 'settings.token.placeholder': '輸入您的權杖', + 'settings.token.show': '顯示權杖', + 'settings.token.hide': '隱藏權杖', + + 'settings.gitlab.token.name': 'GitLab 個人存取權杖', + 'settings.gitlab.token.desc': '請在 GitLab 使用者設定 > access tokens 建立具有「API」範圍的權杖', + 'settings.gitlab.baseUrl.name': 'GitLab 基礎 URL', + 'settings.gitlab.baseUrl.desc': '預設為 https://gitlab.com', + 'settings.gitlab.projectId.name': '專案 ID', + 'settings.gitlab.projectId.desc': '可在 GitLab 專案總覽中找到', + 'settings.gitlab.projectId.placeholder': '輸入數字專案 ID', + + 'settings.gitea.token.name': 'Gitea 個人存取權杖', + 'settings.gitea.token.desc': '請在使用者設定 > applications > access tokens 建立權杖', + 'settings.gitea.baseUrl.name': 'Gitea 基礎 URL', + 'settings.gitea.baseUrl.desc': '您的 Gitea 執行個體網址(例如 https://gitea.example.com)', + + 'settings.github.token.name': 'GitHub 個人存取權杖', + 'settings.github.token.desc': '請在 GitHub 設定 > developer settings > personal access tokens 建立具有「repo」範圍的權杖', + + 'settings.repoOwner.name': '儲存庫擁有者', + 'settings.repoOwner.desc.gitea': 'Gitea 使用者名稱或組織名稱', + 'settings.repoOwner.desc.github': 'GitHub 使用者名稱或組織名稱', + 'settings.repoOwner.placeholder': '使用者名稱', + + 'settings.repoName.name': '儲存庫名稱', + 'settings.repoName.desc.gitea': '儲存庫的名稱', + 'settings.repoName.desc.github': 'GitHub 儲存庫的名稱', + 'settings.repoName.placeholder': '我的筆記', + + 'main.ribbon.openSyncStatus': '開啟同步狀態', + 'main.ribbon.push': '推送', + 'main.ribbon.pushTo': '推送至 {service}', + 'main.command.openSyncStatus': '開啟同步狀態', + 'main.command.pushCurrentFile': '推送目前檔案', + 'main.command.pullCurrentFile': '拉取目前檔案', + 'main.command.pushAllFiles': '推送所有檔案', + 'main.command.pullAllFiles': '拉取所有檔案', + 'main.notice.noActiveNote': '沒有可推送的目前筆記', + 'main.contextMenu.pushTo': '推送至 {service}', + 'main.contextMenu.pullFrom': '從 {service} 拉取', + 'main.notice.noFilesToRun': '設定的保存庫資料夾中沒有可{op}的檔案', + 'main.confirm.pushAll': '要推送 {count} 個檔案至 {service} 嗎?', + 'main.confirm.pullAll': '要從 {service} 拉取 {count} 個檔案嗎?這將覆蓋本機變更。', + 'main.progress.running': '{verb} 0/{total} 個檔案...', + 'main.progress.step': '{verb} {current}/{total}:{fileName}', + 'main.notice.runFailed': '{verb}失敗:{message}', + 'main.op.push': '推送', + 'main.op.pull': '拉取', + 'main.verb.pushing': '推送中', + 'main.verb.pulling': '拉取中', + 'main.verb.push': '推送', + 'main.verb.pull': '拉取', + + 'confirmModal.title': '確認', + 'confirmModal.confirm': '確認', + 'confirmModal.cancel': '取消', + + 'whatsNew.title': '有什麼新功能', + 'whatsNew.viewChangelog': '查看完整更新日誌', + 'whatsNew.gotIt': '知道了', + + 'syncStatus.viewTitle': '同步狀態', + 'syncStatus.emptyPrompt': '點擊「重新整理」以檢查同步狀態', + 'syncStatus.progress.checkingWithCount': '檢查檔案中… {current}/{total}({pct}%)', + 'syncStatus.progress.checking': '檢查檔案中…', + 'syncStatus.lastSync': '上次同步:{time}', + 'syncStatus.tab.all': '全部', + 'syncStatus.tab.synced': '已同步', + 'syncStatus.tab.modified': '有變更', + 'syncStatus.tab.unsynced': '僅本機', + 'syncStatus.tab.remote-only': '遠端', + 'syncStatus.noFilesForFilter': '沒有{filter}的檔案', + 'syncStatus.confirmDeleteLocal': '刪除本機檔案「{path}」?將依您保存庫的「已刪除的檔案」設定處理。', + 'syncStatus.notice.deleted': '已刪除 {path}', + 'syncStatus.notice.deleteFailed': '刪除失敗:{message}', + 'syncStatus.notice.opFailed': '{verb}失敗:{message}', + 'syncStatus.notice.alreadyRefreshing': '正在重新整理中…', + 'syncStatus.notice.refreshed': '已檢查 {local} 個本機檔案與 {remote} 個遠端檔案', + 'syncStatus.notice.refreshFailed': '重新整理失敗:{message}', + 'syncStatus.notice.noPushableFiles.selected': '未選取任何可推送的檔案。', + 'syncStatus.notice.noPushableFiles.found': '沒有可推送的檔案。', + 'syncStatus.notice.noPullableFiles.selected': '未選取任何可拉取的檔案。', + 'syncStatus.notice.noPullableFiles.found': '沒有可拉取的檔案。', + 'syncStatus.confirm.pushSelected': '要推送 {count} 個檔案至 {service} 嗎?', + 'syncStatus.confirm.pullSelected': '要從 {service} 拉取 {count} 個檔案嗎?這將覆蓋本機變更。', + 'syncStatus.notice.opCompleted': '{verb}完成,重新整理中…', + 'syncStatus.notice.nothingToDelete': '沒有可刪除的項目', + 'syncStatus.notice.noFilesSelected': '尚未選取任何檔案', + 'syncStatus.confirmDelete.localAndRemote': '要刪除 {local} 個本機檔案(依保存庫的垃圾桶設定處理)與 {remote} 個遠端檔案(無法復原)嗎?', + 'syncStatus.confirmDelete.localOnly': '要刪除 {local} 個本機檔案嗎?將依您保存庫的「已刪除的檔案」設定處理。', + 'syncStatus.confirmDelete.remoteOnly': '要刪除 {remote} 個遠端檔案嗎?此操作無法復原。', + 'syncStatus.notice.deleteResult.partial': '已刪除 {succeeded}/{total} 個,{failed} 個失敗。', + 'syncStatus.notice.deleteResult.success': '已刪除 {total} 個檔案', + 'syncStatus.progress.deleting': '刪除中 0/{total} 個檔案…', + 'syncStatus.progress.pushing': '推送中 {current}/{total}:{name}', + 'syncStatus.progress.pulling': '拉取中 {current}/{total}:{name}', + 'syncStatus.progress.deletingLocal': '刪除本機 {current}/{total}:{path}', + 'syncStatus.progress.deletingRemote': '刪除遠端 {current}/{total}:{path}', + + 'actionBar.select': '選取', + 'actionBar.refresh': ' 重新整理', + 'actionBar.refreshAll': '重新整理所有狀態', + 'actionBar.pushCount': ' 推送({count})', + 'actionBar.pushFiles': '推送 {count} 個檔案', + 'actionBar.pullCount': ' 拉取({count})', + 'actionBar.pullFiles': '拉取 {count} 個檔案', + 'actionBar.deleteCount': ' 刪除({count})', + 'actionBar.deleteFiles': '刪除 {count} 個檔案', + + 'syncStatus.status.checking': '檢查中', + + 'fileListItem.action.push': ' 推送', + 'fileListItem.action.pull': ' 拉取', + 'fileListItem.action.remove': ' 移除', + 'fileListItem.action.diff': ' 差異', + 'fileListItem.action.hide': ' 隱藏', + 'fileListItem.tooltip.pushToRemote': '推送至遠端', + 'fileListItem.tooltip.pullFromRemote': '從遠端拉取', + 'fileListItem.tooltip.deleteLocalFile': '刪除本機檔案', + 'fileListItem.tooltip.toggleDiff': '切換差異檢視', + 'fileListItem.diff.symlinkChanged': '符號連結目標已變更', + 'fileListItem.diff.loading': '載入差異中…', + 'fileListItem.diff.clickToLoad': '點擊「差異」以載入…', + 'fileListItem.diff.binaryChanged': '二進位檔案已變更', + + 'diffPanel.remote': '遠端', + 'diffPanel.local': '本機', + + 'syncConflictModal.title': '{fileName} 發生衝突', + 'syncConflictModal.description': '遠端檔案內容有所不同。請檢視差異並選擇要保留的版本。', + 'syncConflictModal.tab.diff': '差異', + 'syncConflictModal.tab.local': '本機', + 'syncConflictModal.tab.remote': '遠端', + 'syncConflictModal.localVersion': '本機版本', + 'syncConflictModal.remoteVersion': '遠端版本', + 'syncConflictModal.differences': '差異', + 'syncConflictModal.keepLocal': '保留本機', + 'syncConflictModal.keepLocal.tooltip': '以本機內容覆蓋遠端', + 'syncConflictModal.keepRemote': '保留遠端', + 'syncConflictModal.keepRemote.tooltip': '以遠端內容覆蓋本機', + 'syncConflictModal.cancel': '取消', +}; + +export default zhTw; diff --git a/src/main.ts b/src/main.ts index ccd9e9f..8e10948 100644 --- a/src/main.ts +++ b/src/main.ts @@ -12,6 +12,7 @@ import { ConfirmModal } from './ui/ConfirmModal'; import { WhatsNewModal } from './ui/WhatsNewModal'; import { CHANGELOG, getUnseenReleases } from './changelog'; import { compareVersions } from './utils/version'; +import { t } from './i18n'; export default class GitLabFilesPush extends Plugin { settings: GitLabFilesPushSettings; @@ -29,13 +30,13 @@ export default class GitLabFilesPush extends Plugin { (leaf) => new SyncStatusView(leaf, this) ); - this.addRibbonIcon('git-compare', 'Open sync status', async () => { + this.addRibbonIcon('git-compare', t('main.ribbon.openSyncStatus'), async () => { await this.activateSyncStatusView(); }); this.addCommand({ id: 'open-sync-status', - name: 'Open sync status', + name: t('main.command.openSyncStatus'), callback: async () => { await this.activateSyncStatusView(); } @@ -50,7 +51,7 @@ export default class GitLabFilesPush extends Plugin { if (activeView && activeView.file instanceof TFile) { await this.sync.pushFile(activeView.file); } else { - new Notice('No active note to push'); + new Notice(t('main.notice.noActiveNote')); } }); @@ -60,7 +61,7 @@ export default class GitLabFilesPush extends Plugin { // leave a stale name in the Command Palette until Obsidian reloads. this.addCommand({ id: 'push-current-file', - name: 'Push current file', + name: t('main.command.pushCurrentFile'), callback: async () => { const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (activeView && activeView.file instanceof TFile) { @@ -71,7 +72,7 @@ export default class GitLabFilesPush extends Plugin { this.addCommand({ id: 'pull-current-file', - name: 'Pull current file', + name: t('main.command.pullCurrentFile'), callback: async () => { const activeView = this.app.workspace.getActiveViewOfType(MarkdownView); if (activeView && activeView.file instanceof TFile) { @@ -82,7 +83,7 @@ export default class GitLabFilesPush extends Plugin { this.addCommand({ id: 'push-all-files', - name: 'Push all files', + name: t('main.command.pushAllFiles'), callback: async () => { await this.pushAllFiles(); } @@ -90,7 +91,7 @@ export default class GitLabFilesPush extends Plugin { this.addCommand({ id: 'pull-all-files', - name: 'Pull all files', + name: t('main.command.pullAllFiles'), callback: async () => { await this.pullAllFiles(); } @@ -100,12 +101,12 @@ export default class GitLabFilesPush extends Plugin { this.app.workspace.on('file-menu', (menu, file) => { if (file instanceof TFile) { menu.addItem((item) => { - item.setTitle(`Push to ${this.serviceName}`) + item.setTitle(t('main.contextMenu.pushTo', { service: this.serviceName })) .setIcon('upload-cloud') .onClick(async () => { await this.sync.pushFile(file); }); }); menu.addItem((item) => { - item.setTitle(`Pull from ${this.serviceName}`) + item.setTitle(t('main.contextMenu.pullFrom', { service: this.serviceName })) .setIcon('download-cloud') .onClick(async () => { await this.sync.pullFile(file); }); }); @@ -144,7 +145,7 @@ export default class GitLabFilesPush extends Plugin { } private pushRibbonLabel(): string { - return Platform.isMobile ? 'Push' : `Push to ${this.serviceName}`; + return Platform.isMobile ? t('main.ribbon.push') : t('main.ribbon.pushTo', { service: this.serviceName }); } // The ribbon icon's tooltip is set once when addRibbonIcon runs, so it goes @@ -205,26 +206,27 @@ export default class GitLabFilesPush extends Plugin { const files = allPaths.filter(p => !this.gitignoreManager.isIgnored(this.getNormalizedPath(p))); if (files.length === 0) { - new Notice(`No files to ${op} in the configured vault folder`); + new Notice(t('main.notice.noFilesToRun', { op: op === 'push' ? t('main.op.push') : t('main.op.pull') })); return; } const msg = op === 'push' - ? `Push ${files.length} file(s) to ${this.serviceName}?` - : `Pull ${files.length} file(s) from ${this.serviceName}? This will overwrite local changes.`; + ? t('main.confirm.pushAll', { count: files.length, service: this.serviceName }) + : t('main.confirm.pullAll', { count: files.length, service: this.serviceName }); const confirmed = await this.showConfirmDialog(msg); if (!confirmed) return; - const progressNotice = new Notice(`${op === 'push' ? 'Pushing' : 'Pulling'} 0/${files.length} files...`, 0); + const runVerb = op === 'push' ? t('main.verb.pushing') : t('main.verb.pulling'); + const progressNotice = new Notice(t('main.progress.running', { verb: runVerb, total: files.length }), 0); try { const results = op === 'push' ? await this.sync.pushAllFiles(files, (current, total, fileName) => { - progressNotice.setMessage(`Pushing ${current}/${total}: ${fileName}`); + progressNotice.setMessage(t('main.progress.step', { verb: t('main.verb.pushing'), current, total, fileName })); }) : await this.sync.pullAllFiles(files, (current, total, fileName) => { - progressNotice.setMessage(`Pulling ${current}/${total}: ${fileName}`); + progressNotice.setMessage(t('main.progress.step', { verb: t('main.verb.pulling'), current, total, fileName })); }); progressNotice.hide(); @@ -235,7 +237,8 @@ export default class GitLabFilesPush extends Plugin { } catch (e) { progressNotice.hide(); logger.error(String(e)); - new Notice(`${op === 'push' ? 'Push' : 'Pull'} failed: ${e instanceof Error ? e.message : String(e)}`); + const failVerb = op === 'push' ? t('main.verb.push') : t('main.verb.pull'); + new Notice(t('main.notice.runFailed', { verb: failVerb, message: e instanceof Error ? e.message : String(e) })); } } diff --git a/src/settings.ts b/src/settings.ts index b5f01f0..1e4d7ae 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -2,6 +2,7 @@ import {App, PluginSettingTab, Setting, Notice, TextComponent} from 'obsidian'; import GitLabFilesPush from "./main"; import {FolderSuggest} from "./ui/FolderSuggest"; import { ConnectionTestResult } from "./services/git-service-base"; +import { t } from "./i18n"; // Minimal shape of Obsidian >= 1.13's SettingDefinitionItem. Declared locally so // the plugin still type-checks against older Obsidian typings (minAppVersion @@ -154,12 +155,12 @@ export class GitLabSyncSettingTab extends PluginSettingTab { badge.addClass(`is-${state}`); const labels: Record = { - checking: 'Checking…', - connected: 'Connected', - disconnected: 'Not connected' + checking: t('settings.connectionStatus.checking'), + connected: t('settings.connectionStatus.connected'), + disconnected: t('settings.connectionStatus.disconnected') }; const label = labels[state]; - badge.setText(detail ? `${label} — ${detail}` : label); + badge.setText(detail ? t('settings.connectionStatus.withDetail', { label, detail }) : label); } // Debounced so token/branch fields (which call this on every keystroke) @@ -183,9 +184,9 @@ export class GitLabSyncSettingTab extends PluginSettingTab { if (seq !== this.connectionTestSeq) return result; if (!result.repoOk) { - this.setStatusBadge('disconnected', result.error ?? 'could not reach the repository'); + this.setStatusBadge('disconnected', result.error ?? t('settings.testConnection.failed.unreachable')); } else if (!result.branchOk) { - this.setStatusBadge('disconnected', `branch "${this.plugin.settings.branch}" not found`); + this.setStatusBadge('disconnected', t('settings.testConnection.branchNotFound.badge', { branch: this.plugin.settings.branch })); } else { this.setStatusBadge('connected'); } @@ -205,8 +206,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab { this.renderConnectionStatus(containerEl); new Setting(containerEl) - .setName('Git service') - .setDesc('Choose your Git hosting service') + .setName(t('settings.gitService.name')) + .setDesc(t('settings.gitService.desc')) .addDropdown(dropdown => dropdown .addOption('gitlab', 'GitLab') .addOption('github', 'GitHub') @@ -230,10 +231,10 @@ export class GitLabSyncSettingTab extends PluginSettingTab { } new Setting(containerEl) - .setName('Branch') - .setDesc('Branch to push or pull from') + .setName(t('settings.branch.name')) + .setDesc(t('settings.branch.desc')) .addText(text => text - .setPlaceholder('Main') + .setPlaceholder(t('settings.branch.placeholder')) .setValue(this.plugin.settings.branch) .onChange((value) => { this.plugin.settings.branch = value || 'main'; @@ -242,10 +243,10 @@ export class GitLabSyncSettingTab extends PluginSettingTab { })); new Setting(containerEl) - .setName('Root path') - .setDesc('Optional: subfolder in repository (e.g. "notes")') + .setName(t('settings.rootPath.name')) + .setDesc(t('settings.rootPath.desc')) .addText(text => { - text.setPlaceholder('Enter subfolder path') + text.setPlaceholder(t('settings.rootPath.placeholder')) .setValue(this.plugin.settings.rootPath) .onChange((value) => { this.plugin.settings.rootPath = value.replace(/^\/|\/$/g, ''); @@ -256,10 +257,10 @@ export class GitLabSyncSettingTab extends PluginSettingTab { }); new Setting(containerEl) - .setName('Vault folder') - .setDesc('Optional: only sync files in this vault folder (e.g. "sync" to only sync files in the sync folder)') + .setName(t('settings.vaultFolder.name')) + .setDesc(t('settings.vaultFolder.desc')) .addText(text => { - text.setPlaceholder('Leave empty to sync all files') + text.setPlaceholder(t('settings.vaultFolder.placeholder')) .setValue(this.plugin.settings.vaultFolder) .onChange((value) => { this.plugin.settings.vaultFolder = value.replace(/^\/|\/$/g, ''); @@ -269,8 +270,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab { }); new Setting(containerEl) - .setName('Ignore patterns') - .setDesc('Optional: .gitignore-style patterns (one per line) to exclude local files from sync, in addition to the repository\'s own .gitignore.') + .setName(t('settings.ignorePatterns.name')) + .setDesc(t('settings.ignorePatterns.desc')) .addTextArea(text => { text.setPlaceholder(`${this.app.vault.configDir}/\n*.tmp`) .setValue(this.plugin.settings.ignorePatterns) @@ -285,15 +286,15 @@ export class GitLabSyncSettingTab extends PluginSettingTab { // other providers, offer follow/skip only so the option can't mislead. const supportsRealSymlink = this.plugin.settings.serviceType === 'github'; new Setting(containerEl) - .setName('Symbolic links') + .setName(t('settings.symlinks.name')) .setDesc(supportsRealSymlink - ? 'How to sync symlinks: "real" recreates the link on desktop and falls back to the target content on mobile, "follow" always syncs the target content, and "skip" ignores symlinks.' - : 'How to sync symlinks: "follow" syncs the target content as a normal file, "skip" ignores symlinks. Real symlinks require GitHub.') + ? t('settings.symlinks.desc.supported') + : t('settings.symlinks.desc.unsupported')) .addDropdown(dropdown => { - if (supportsRealSymlink) dropdown.addOption('real', 'Real symlink (recommended)'); + if (supportsRealSymlink) dropdown.addOption('real', t('settings.symlinks.option.real')); dropdown - .addOption('follow', 'Follow (sync target content)') - .addOption('skip', 'Skip') + .addOption('follow', t('settings.symlinks.option.follow')) + .addOption('skip', t('settings.symlinks.option.skip')) .setValue(getEffectiveSymlinkHandling(this.plugin.settings)) .onChange((value: string) => { this.plugin.settings.symlinkHandling = value as SymlinkHandling; @@ -302,27 +303,26 @@ export class GitLabSyncSettingTab extends PluginSettingTab { }); new Setting(containerEl) - .setName('Test connection') - .setDesc(`Verify your ${getServiceName(this.plugin.settings)} settings`) + .setName(t('settings.testConnection.name')) + .setDesc(t('settings.testConnection.desc', { service: getServiceName(this.plugin.settings) })) .addButton(button => button - .setButtonText('Test connection') + .setButtonText(t('settings.testConnection.button')) .onClick(async () => { try { const result = await this.testConnectionSilently(); if (!result.repoOk) { - new Notice(`Connection failed: ${result.error ?? 'could not reach the repository'}`); + new Notice(t('settings.testConnection.failed', { reason: result.error ?? t('settings.testConnection.failed.unreachable') })); } else if (!result.branchOk) { new Notice( - `Connected, but branch "${this.plugin.settings.branch}" was not found. ` + - 'Check the Branch setting, or confirm the repository has a branch with this name.', + t('settings.testConnection.branchNotFound.notice', { branch: this.plugin.settings.branch }), 8000 ); } else { - new Notice(`${getServiceName(this.plugin.settings)} connection successful!`); + new Notice(t('settings.testConnection.success', { service: getServiceName(this.plugin.settings) })); } } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e); - new Notice(`Connection failed: ${message}`); + new Notice(t('settings.testConnection.failed', { reason: message })); } })); @@ -340,18 +340,18 @@ export class GitLabSyncSettingTab extends PluginSettingTab { .addText(text => { textComponent = text; text.inputEl.type = 'password'; - text.setPlaceholder('Enter your token') + text.setPlaceholder(t('settings.token.placeholder')) .setValue(getValue()) .onChange(onChange); }) .addExtraButton(btn => { btn.setIcon('eye') - .setTooltip('Show token') + .setTooltip(t('settings.token.show')) .onClick(() => { const revealing = textComponent.inputEl.type === 'password'; textComponent.inputEl.type = revealing ? 'text' : 'password'; btn.setIcon(revealing ? 'eye-off' : 'eye'); - btn.setTooltip(revealing ? 'Hide token' : 'Show token'); + btn.setTooltip(revealing ? t('settings.token.hide') : t('settings.token.show')); }); }); } @@ -359,8 +359,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab { private displayGitLabSettings(containerEl: HTMLElement): void { this.addTokenSetting( containerEl, - 'GitLab personal access token', - 'Create a token in GitLab user settings > access tokens with "API" scope', + t('settings.gitlab.token.name'), + t('settings.gitlab.token.desc'), () => this.plugin.settings.gitlabToken, (value) => { this.plugin.settings.gitlabToken = value; @@ -371,8 +371,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab { ); new Setting(containerEl) - .setName('GitLab base URL') - .setDesc('Defaults to https://gitlab.com') + .setName(t('settings.gitlab.baseUrl.name')) + .setDesc(t('settings.gitlab.baseUrl.desc')) .addText(text => text .setPlaceholder('https://gitlab.com') .setValue(this.plugin.settings.gitlabBaseUrl) @@ -384,10 +384,10 @@ export class GitLabSyncSettingTab extends PluginSettingTab { })); new Setting(containerEl) - .setName('Project ID') - .setDesc('Found in GitLab project overview') + .setName(t('settings.gitlab.projectId.name')) + .setDesc(t('settings.gitlab.projectId.desc')) .addText(text => text - .setPlaceholder('Enter numeric project ID') + .setPlaceholder(t('settings.gitlab.projectId.placeholder')) .setValue(this.plugin.settings.projectId) .onChange((value) => { this.plugin.settings.projectId = value; @@ -400,8 +400,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab { private displayGiteaSettings(containerEl: HTMLElement): void { this.addTokenSetting( containerEl, - 'Gitea personal access token', - 'Create a token in user settings > applications > access tokens', + t('settings.gitea.token.name'), + t('settings.gitea.token.desc'), () => this.plugin.settings.giteaToken, (value) => { this.plugin.settings.giteaToken = value; @@ -412,8 +412,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab { ); new Setting(containerEl) - .setName('Gitea base URL') - .setDesc('URL of your Gitea instance (e.g. https://gitea.example.com)') + .setName(t('settings.gitea.baseUrl.name')) + .setDesc(t('settings.gitea.baseUrl.desc')) .addText(text => text .setPlaceholder('https://gitea.example.com') .setValue(this.plugin.settings.giteaBaseUrl) @@ -425,10 +425,10 @@ export class GitLabSyncSettingTab extends PluginSettingTab { })); new Setting(containerEl) - .setName('Repository owner') - .setDesc('Gitea username or organization name') + .setName(t('settings.repoOwner.name')) + .setDesc(t('settings.repoOwner.desc.gitea')) .addText(text => text - .setPlaceholder('Username') + .setPlaceholder(t('settings.repoOwner.placeholder')) .setValue(this.plugin.settings.giteaOwner) .onChange((value) => { this.plugin.settings.giteaOwner = value; @@ -438,10 +438,10 @@ export class GitLabSyncSettingTab extends PluginSettingTab { })); new Setting(containerEl) - .setName('Repository name') - .setDesc('Name of the repository') + .setName(t('settings.repoName.name')) + .setDesc(t('settings.repoName.desc.gitea')) .addText(text => text - .setPlaceholder('My notes') + .setPlaceholder(t('settings.repoName.placeholder')) .setValue(this.plugin.settings.giteaRepo) .onChange((value) => { this.plugin.settings.giteaRepo = value; @@ -454,8 +454,8 @@ export class GitLabSyncSettingTab extends PluginSettingTab { private displayGitHubSettings(containerEl: HTMLElement): void { this.addTokenSetting( containerEl, - 'GitHub personal access token', - 'Create a token in GitHub settings > developer settings > personal access tokens with "repo" scope', + t('settings.github.token.name'), + t('settings.github.token.desc'), () => this.plugin.settings.githubToken, (value) => { this.plugin.settings.githubToken = value; @@ -466,10 +466,10 @@ export class GitLabSyncSettingTab extends PluginSettingTab { ); new Setting(containerEl) - .setName('Repository owner') - .setDesc('GitHub username or organization name') + .setName(t('settings.repoOwner.name')) + .setDesc(t('settings.repoOwner.desc.github')) .addText(text => text - .setPlaceholder('Username') + .setPlaceholder(t('settings.repoOwner.placeholder')) .setValue(this.plugin.settings.githubOwner) .onChange((value) => { this.plugin.settings.githubOwner = value; @@ -479,10 +479,10 @@ export class GitLabSyncSettingTab extends PluginSettingTab { })); new Setting(containerEl) - .setName('Repository name') - .setDesc('Name of the GitHub repository') + .setName(t('settings.repoName.name')) + .setDesc(t('settings.repoName.desc.github')) .addText(text => text - .setPlaceholder('My notes') + .setPlaceholder(t('settings.repoName.placeholder')) .setValue(this.plugin.settings.githubRepo) .onChange((value) => { this.plugin.settings.githubRepo = value; diff --git a/src/ui/ConfirmModal.ts b/src/ui/ConfirmModal.ts index 58f6210..642931b 100644 --- a/src/ui/ConfirmModal.ts +++ b/src/ui/ConfirmModal.ts @@ -1,4 +1,5 @@ import { App, Modal, ButtonComponent } from 'obsidian'; +import { t } from '../i18n'; export class ConfirmModal extends Modal { private readonly message: string; @@ -14,20 +15,20 @@ export class ConfirmModal extends Modal { onOpen() { const { contentEl } = this; - contentEl.createEl('h3', { text: 'Confirm' }); + contentEl.createEl('h3', { text: t('confirmModal.title') }); contentEl.createEl('p', { text: this.message }); const buttonContainer = contentEl.createDiv({ cls: 'ssv-confirm-buttons modal-button-container' }); new ButtonComponent(buttonContainer) - .setButtonText('Cancel') + .setButtonText(t('confirmModal.cancel')) .onClick(() => { this.close(); if (this.onCancel) this.onCancel(); }); new ButtonComponent(buttonContainer) - .setButtonText('Confirm') + .setButtonText(t('confirmModal.confirm')) .setCta() .onClick(() => { this.close(); diff --git a/src/ui/SyncConflictModal.ts b/src/ui/SyncConflictModal.ts index 0f459ba..5e4cc2e 100644 --- a/src/ui/SyncConflictModal.ts +++ b/src/ui/SyncConflictModal.ts @@ -1,4 +1,5 @@ import { App, Modal, Setting } from 'obsidian'; +import { t } from '../i18n'; type ConflictPanelName = 'diff' | 'local' | 'remote'; @@ -35,9 +36,9 @@ export class SyncConflictModal extends Modal { const { contentEl } = this; contentEl.addClass('sync-conflict-modal'); - contentEl.createEl('h2', { text: `Conflict in ${this.fileName}` }); + contentEl.createEl('h2', { text: t('syncConflictModal.title', { fileName: this.fileName }) }); contentEl.createEl('p', { - text: 'The remote file has different content. Review the differences and choose which version to keep.', + text: t('syncConflictModal.description'), cls: 'conflict-description' }); @@ -52,7 +53,11 @@ export class SyncConflictModal extends Modal { }; const tabsContainer = contentEl.createDiv({ cls: 'conflict-tabs' }); - const tabLabels: Record = { diff: 'Diff', local: 'Local', remote: 'Remote' }; + const tabLabels: Record = { + diff: t('syncConflictModal.tab.diff'), + local: t('syncConflictModal.tab.local'), + remote: t('syncConflictModal.tab.remote') + }; (['diff', 'local', 'remote'] as const).forEach(name => { const tab = tabsContainer.createEl('button', { text: tabLabels[name], cls: 'conflict-tab' }); tab.addEventListener('click', () => setActivePanel(name)); @@ -64,19 +69,19 @@ export class SyncConflictModal extends Modal { const diffContainer = contentArea.createDiv({ cls: 'conflict-diff-container' }); const localSection = diffContainer.createDiv({ cls: 'conflict-section conflict-panel' }); - localSection.createEl('h3', { text: 'Local version' }); + localSection.createEl('h3', { text: t('syncConflictModal.localVersion') }); const localPre = localSection.createEl('pre', { cls: 'conflict-content' }); localPre.createEl('code', { text: this.localContent }); panels.local = localSection; const remoteSection = diffContainer.createDiv({ cls: 'conflict-section conflict-panel' }); - remoteSection.createEl('h3', { text: 'Remote version' }); + remoteSection.createEl('h3', { text: t('syncConflictModal.remoteVersion') }); const remotePre = remoteSection.createEl('pre', { cls: 'conflict-content' }); remotePre.createEl('code', { text: this.remoteContent }); panels.remote = remoteSection; const diffSection = contentArea.createDiv({ cls: 'conflict-diff-section conflict-panel' }); - diffSection.createEl('h3', { text: 'Differences' }); + diffSection.createEl('h3', { text: t('syncConflictModal.differences') }); const diffPre = diffSection.createEl('pre', { cls: 'conflict-diff' }); this.renderDiff(diffPre); panels.diff = diffSection; @@ -87,22 +92,22 @@ export class SyncConflictModal extends Modal { new Setting(buttonContainer) .addButton(btn => btn - .setButtonText('Keep local') - .setTooltip('Overwrite remote with your local content') + .setButtonText(t('syncConflictModal.keepLocal')) + .setTooltip(t('syncConflictModal.keepLocal.tooltip')) .setCta() .onClick(() => { this.onChoose('local'); this.close(); })) .addButton(btn => applyDestructiveStyle(btn) - .setButtonText('Keep remote') - .setTooltip('Overwrite local with remote content') + .setButtonText(t('syncConflictModal.keepRemote')) + .setTooltip(t('syncConflictModal.keepRemote.tooltip')) .onClick(() => { this.onChoose('remote'); this.close(); })) .addButton(btn => btn - .setButtonText('Cancel') + .setButtonText(t('syncConflictModal.cancel')) .onClick(() => { this.close(); })); diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index 2ab31f7..b3414ab 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -11,6 +11,7 @@ import { isBinaryPath, contentsEqual } from '../utils/path'; import { readLocalSymlinkTarget } from '../utils/symlink'; import { gitBlobSha } from '../utils/git-blob-sha'; import { type GitTreeEntry } from '../services/git-service-interface'; +import { t, type TranslationKey } from '../i18n'; export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view'; @@ -29,7 +30,7 @@ export class SyncStatusView extends ItemView { } getViewType(): string { return SYNC_STATUS_VIEW_TYPE; } - getDisplayText(): string { return 'Sync status'; } + getDisplayText(): string { return t('syncStatus.viewTitle'); } getIcon(): string { return 'git-compare'; } onOpen(): Promise { @@ -56,7 +57,7 @@ export class SyncStatusView extends ItemView { this.renderProgressBar(listEl); this.renderCheckedFilesDuringRefresh(listEl); } else if (this.fileStatuses.size === 0) { - listEl.createDiv({ cls: 'ssv-empty', text: 'Click "Refresh" to check sync status' }); + listEl.createDiv({ cls: 'ssv-empty', text: t('syncStatus.emptyPrompt') }); } else { this.renderFileList(listEl); } @@ -68,7 +69,7 @@ export class SyncStatusView extends ItemView { const prog = container.createDiv({ cls: 'ssv-progress' }); prog.createDiv({ cls: 'ssv-progress-text', - text: total > 0 ? `Checking files… ${current}/${total} (${pct}%)` : 'Checking files…' + text: total > 0 ? t('syncStatus.progress.checkingWithCount', { current, total, pct }) : t('syncStatus.progress.checking') }); const bar = prog.createDiv({ cls: 'ssv-progress-bar' }); bar.createDiv({ cls: 'ssv-progress-fill' }).setAttr('style', `width: ${pct}%`); @@ -114,7 +115,7 @@ export class SyncStatusView extends ItemView { cls: 'ssv-info-time', text: Platform.isMobile ? new Date(this.lastSyncTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) - : `Last sync: ${new Date(this.lastSyncTime).toLocaleTimeString()}` + : t('syncStatus.lastSync', { time: new Date(this.lastSyncTime).toLocaleTimeString() }) }); } } @@ -132,11 +133,11 @@ export class SyncStatusView extends ItemView { }; const tabs: Array<{ value: FilterValue; label: string }> = [ - { value: 'all', label: 'All' }, - { value: 'synced', label: 'Synced' }, - { value: 'modified', label: 'Changed' }, - { value: 'unsynced', label: 'Local only' }, - { value: 'remote-only', label: 'Remote' }, + { value: 'all', label: t('syncStatus.tab.all') }, + { value: 'synced', label: t('syncStatus.tab.synced') }, + { value: 'modified', label: t('syncStatus.tab.modified') }, + { value: 'unsynced', label: t('syncStatus.tab.unsynced') }, + { value: 'remote-only', label: t('syncStatus.tab.remote-only') }, ]; const tabsEl = container.createDiv({ cls: 'ssv-tabs' }); @@ -232,7 +233,8 @@ export class SyncStatusView extends ItemView { : all.filter(s => s.status === this.statusFilter); if (statuses.length === 0) { - container.createDiv({ cls: 'ssv-empty', text: `No ${this.statusFilter} files` }); + const filterLabel = this.statusFilter === 'all' ? t('syncStatus.tab.all') : statusMeta(this.statusFilter).label; + container.createDiv({ cls: 'ssv-empty', text: t('syncStatus.noFilesForFilter', { filter: filterLabel }) }); return; } @@ -245,7 +247,7 @@ export class SyncStatusView extends ItemView { // ── Single-file operations ────────────────────────────────────── private async handleLocalDelete(fileStatus: FileStatus): Promise { - const confirmed = await this.showConfirmDialog(`Delete local file "${fileStatus.path}"? Handled per your vault's "Deleted files" setting.`); + const confirmed = await this.showConfirmDialog(t('syncStatus.confirmDeleteLocal', { path: fileStatus.path })); if (!confirmed) return; try { if (fileStatus.file) { @@ -254,11 +256,11 @@ export class SyncStatusView extends ItemView { await this.app.vault.adapter.remove(fileStatus.path); } await this.plugin.sync.clearMetadata(fileStatus.path); - new Notice(`Deleted ${fileStatus.path}`); + new Notice(t('syncStatus.notice.deleted', { path: fileStatus.path })); this.fileStatuses.delete(fileStatus.path); this.renderView(); } catch (e) { - new Notice(`Failed to delete: ${e instanceof Error ? e.message : String(e)}`); + new Notice(t('syncStatus.notice.deleteFailed', { message: e instanceof Error ? e.message : String(e) })); } } @@ -277,7 +279,8 @@ export class SyncStatusView extends ItemView { await this.refreshFileStatus(fileStatus.file || fileStatus.path, undefined); this.renderView(); } catch (e) { - new Notice(`${op === 'push' ? 'Push' : 'Pull'} failed: ${e instanceof Error ? e.message : String(e)}`); + const verb = op === 'push' ? t('main.verb.push') : t('main.verb.pull'); + new Notice(t('syncStatus.notice.opFailed', { verb, message: e instanceof Error ? e.message : String(e) })); await this.refreshFileStatus(fileStatus.file || fileStatus.path, undefined); this.renderView(); } @@ -287,7 +290,7 @@ export class SyncStatusView extends ItemView { async refreshAllStatuses(): Promise { if (this.isRefreshing) { - new Notice('Already refreshing…'); + new Notice(t('syncStatus.notice.alreadyRefreshing')); return; } @@ -313,11 +316,11 @@ export class SyncStatusView extends ItemView { this.lastSyncTime = Date.now(); this.isRefreshing = false; // Set to false BEFORE final renderView this.renderView(); - new Notice(`Checked ${files.local.length + files.hiddenLocalPaths.size} local + ${files.remoteMap.size} remote files`); + new Notice(t('syncStatus.notice.refreshed', { local: files.local.length + files.hiddenLocalPaths.size, remote: files.remoteMap.size })); } catch (e) { this.isRefreshing = false; this.renderView(); - new Notice(`Failed to refresh: ${e instanceof Error ? e.message : String(e)}`); + new Notice(t('syncStatus.notice.refreshFailed', { message: e instanceof Error ? e.message : String(e) })); } } @@ -612,6 +615,11 @@ export class SyncStatusView extends ItemView { async pushSelected(): Promise { await this.runBatchOperation('selected', 'push'); } async pullSelected(): Promise { await this.runBatchOperation('selected', 'pull'); } + private static readonly NO_RUNNABLE_FILES_KEYS: Record<'push' | 'pull', Record<'selected' | 'found', TranslationKey>> = { + push: { selected: 'syncStatus.notice.noPushableFiles.selected', found: 'syncStatus.notice.noPushableFiles.found' }, + pull: { selected: 'syncStatus.notice.noPullableFiles.selected', found: 'syncStatus.notice.noPullableFiles.found' }, + }; + private async runBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull'): Promise { const targets = Array.from(this.fileStatuses.values()).filter(s => { if (filter === 'selected' && !this.selectedFiles.has(s.path)) return false; @@ -621,32 +629,40 @@ export class SyncStatusView extends ItemView { }); if (targets.length === 0) { - new Notice(`No ${op}able files ${filter === 'selected' ? 'selected' : 'found'}.`); + const scope = filter === 'selected' ? 'selected' : 'found'; + new Notice(t(SyncStatusView.NO_RUNNABLE_FILES_KEYS[op][scope])); return; } const files = targets.map(s => s.file || s.path); const serviceName = getServiceName(this.plugin.settings); const msg = op === 'push' - ? `Push ${files.length} file(s) to ${serviceName}?` - : `Pull ${files.length} file(s) from ${serviceName}? This will overwrite local changes.`; + ? t('syncStatus.confirm.pushSelected', { count: files.length, service: serviceName }) + : t('syncStatus.confirm.pullSelected', { count: files.length, service: serviceName }); if (!await this.showConfirmDialog(msg)) return; - const prog = new Notice(`${op === 'push' ? 'Pushing' : 'Pulling'} 0/${files.length} files…`, 0); + await this.executeBatchOperation(filter, op, files); + } + + private async executeBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull', files: Array): Promise { + const runVerb = op === 'push' ? t('main.verb.pushing') : t('main.verb.pulling'); + const prog = new Notice(t('main.progress.running', { verb: runVerb, total: files.length }), 0); try { const results = op === 'push' - ? await this.plugin.sync.pushAllFiles(files, (cur, total, name) => prog.setMessage(`Pushing ${cur}/${total}: ${name}`)) - : await this.plugin.sync.pullAllFiles(files, (cur, total, name) => prog.setMessage(`Pulling ${cur}/${total}: ${name}`)); + ? await this.plugin.sync.pushAllFiles(files, (cur, total, name) => prog.setMessage(t('syncStatus.progress.pushing', { current: cur, total, name }))) + : await this.plugin.sync.pullAllFiles(files, (cur, total, name) => prog.setMessage(t('syncStatus.progress.pulling', { current: cur, total, name }))); prog.hide(); if (results.errors.length > 0) logger.error(`${op} errors:`, results.errors); if (filter === 'selected') this.selectedFiles.clear(); - new Notice(`${op === 'push' ? 'Push' : 'Pull'} completed. Refreshing…`); + const doneVerb = op === 'push' ? t('main.verb.push') : t('main.verb.pull'); + new Notice(t('syncStatus.notice.opCompleted', { verb: doneVerb })); await this.refreshAllStatuses(); } catch (e) { prog.hide(); - new Notice(`${op === 'push' ? 'Push' : 'Pull'} failed: ${e instanceof Error ? e.message : String(e)}`); + const failVerb = op === 'push' ? t('main.verb.push') : t('main.verb.pull'); + new Notice(t('syncStatus.notice.opFailed', { verb: failVerb, message: e instanceof Error ? e.message : String(e) })); } } @@ -655,11 +671,11 @@ export class SyncStatusView extends ItemView { if (targets.length === 0) return; const { local, remote } = this.partitionTargets(targets); - if (local.length === 0 && remote.length === 0) { new Notice('Nothing to delete'); return; } + if (local.length === 0 && remote.length === 0) { new Notice(t('syncStatus.notice.nothingToDelete')); return; } if (!await this.confirmDeletion(local.length, remote.length)) return; const total = local.length + remote.length; - const prog = new Notice(`Deleting 0/${total} files…`, 0); + const prog = new Notice(t('syncStatus.progress.deleting', { total }), 0); const errors: string[] = []; await this.performLocalDeletion(local, total, prog, errors); @@ -667,14 +683,14 @@ export class SyncStatusView extends ItemView { prog.hide(); new Notice(errors.length > 0 - ? `Deleted ${total - errors.length}/${total}. ${errors.length} failed.` - : `Deleted ${total} files` + ? t('syncStatus.notice.deleteResult.partial', { succeeded: total - errors.length, total, failed: errors.length }) + : t('syncStatus.notice.deleteResult.success', { total }) ); this.renderView(); } private getSelectedTargets(): FileStatus[] { - if (this.selectedFiles.size === 0) { new Notice('No files selected'); return []; } + if (this.selectedFiles.size === 0) { new Notice(t('syncStatus.notice.noFilesSelected')); return []; } return Array.from(this.selectedFiles) .map(p => this.fileStatuses.get(p)) .filter(Boolean) as FileStatus[]; @@ -695,11 +711,11 @@ export class SyncStatusView extends ItemView { // recoverability; remote deletes are unconditionally permanent. let msg = ''; if (localCount > 0 && remoteCount > 0) { - msg = `Delete ${localCount} local file(s) (per your vault's trash setting) and ${remoteCount} remote file(s) (cannot be undone)?`; + msg = t('syncStatus.confirmDelete.localAndRemote', { local: localCount, remote: remoteCount }); } else if (localCount > 0) { - msg = `Delete ${localCount} local file(s)? They'll be handled per your vault's "Deleted files" setting.`; + msg = t('syncStatus.confirmDelete.localOnly', { local: localCount }); } else { - msg = `Delete ${remoteCount} remote file(s)? This cannot be undone.`; + msg = t('syncStatus.confirmDelete.remoteOnly', { remote: remoteCount }); } return this.showConfirmDialog(msg); } @@ -708,7 +724,7 @@ export class SyncStatusView extends ItemView { let cur = 0; for (const s of local) { cur++; - prog.setMessage(`Deleting local ${cur}/${total}: ${s.path}`); + prog.setMessage(t('syncStatus.progress.deletingLocal', { current: cur, total, path: s.path })); try { if (s.file) await this.app.fileManager.trashFile(s.file); else await this.app.vault.adapter.remove(s.path); @@ -723,7 +739,7 @@ export class SyncStatusView extends ItemView { let cur = localCount; for (const s of remote) { cur++; - prog.setMessage(`Deleting remote ${cur}/${total}: ${s.path}`); + prog.setMessage(t('syncStatus.progress.deletingRemote', { current: cur, total, path: s.path })); try { await this.plugin.gitService.deleteFile(s.path, this.plugin.settings.branch, `Delete ${s.path}`); this.fileStatuses.delete(s.path); diff --git a/src/ui/WhatsNewModal.ts b/src/ui/WhatsNewModal.ts index c7814cc..c269e2e 100644 --- a/src/ui/WhatsNewModal.ts +++ b/src/ui/WhatsNewModal.ts @@ -1,5 +1,6 @@ import { App, Modal, ButtonComponent } from 'obsidian'; import { type ChangelogRelease } from '../changelog'; +import { t } from '../i18n'; const CHANGELOG_URL = 'https://github.com/firstsun-dev/git-files-sync/blob/main/CHANGELOG.md'; @@ -13,7 +14,7 @@ export class WhatsNewModal extends Modal { onOpen() { const { contentEl } = this; - contentEl.createEl('h3', { text: "What's new" }); + contentEl.createEl('h3', { text: t('whatsNew.title') }); for (const release of this.releases) { contentEl.createEl('h4', { text: `v${release.version}` }); @@ -27,11 +28,11 @@ export class WhatsNewModal extends Modal { const buttonContainer = contentEl.createDiv({ cls: 'ssv-confirm-buttons modal-button-container' }); new ButtonComponent(buttonContainer) - .setButtonText('View full changelog') + .setButtonText(t('whatsNew.viewChangelog')) .onClick(() => window.open(CHANGELOG_URL, '_blank', 'noopener')); new ButtonComponent(buttonContainer) - .setButtonText('Got it') + .setButtonText(t('whatsNew.gotIt')) .setCta() .onClick(() => this.close()); } diff --git a/src/ui/components/ActionBar.ts b/src/ui/components/ActionBar.ts index e802e02..b1e98fd 100644 --- a/src/ui/components/ActionBar.ts +++ b/src/ui/components/ActionBar.ts @@ -1,5 +1,6 @@ import { setIcon, setTooltip } from 'obsidian'; import { ICONS } from './icons'; +import { t } from '../../i18n'; export interface ActionBarProps { hasFiles: boolean; @@ -25,17 +26,17 @@ export function renderActionBar(container: HTMLElement, props: ActionBarProps, c if (props.hasFiles) { bar.createDiv({ cls: 'ssv-bar-spacer' }); renderSelectAllRow(bar, props.allSelected, props.indeterminate, callbacks.onSelectAll); - renderLargeButton(bar, ICONS.push, ` Push (${props.canPush})`, `Push ${props.canPush} files`, callbacks.onPush, 'push', props.canPush === 0); - renderLargeButton(bar, ICONS.pull, ` Pull (${props.canPull})`, `Pull ${props.canPull} files`, callbacks.onPull, 'pull', props.canPull === 0); - renderLargeButton(bar, ICONS.delete, ` Delete (${props.canDelete})`, `Delete ${props.canDelete} files`, callbacks.onDelete, 'danger', props.canDelete === 0); + renderLargeButton(bar, ICONS.push, t('actionBar.pushCount', { count: props.canPush }), t('actionBar.pushFiles', { count: props.canPush }), callbacks.onPush, 'push', props.canPush === 0); + renderLargeButton(bar, ICONS.pull, t('actionBar.pullCount', { count: props.canPull }), t('actionBar.pullFiles', { count: props.canPull }), callbacks.onPull, 'pull', props.canPull === 0); + renderLargeButton(bar, ICONS.delete, t('actionBar.deleteCount', { count: props.canDelete }), t('actionBar.deleteFiles', { count: props.canDelete }), callbacks.onDelete, 'danger', props.canDelete === 0); } } function renderRefreshButton(bar: HTMLElement, onRefresh: () => void): void { const btn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-refresh' }); setIcon(btn.createSpan(), ICONS.refresh); - btn.createSpan({ cls: 'ssv-btn-label', text: ' Refresh' }); - setTooltip(btn, 'Refresh all statuses'); + btn.createSpan({ cls: 'ssv-btn-label', text: t('actionBar.refresh') }); + setTooltip(btn, t('actionBar.refreshAll')); btn.addEventListener('click', onRefresh); } @@ -44,7 +45,7 @@ function renderSelectAllRow(bar: HTMLElement, allSelected: boolean, indeterminat const cb = selectRow.createEl('input', { type: 'checkbox' }); cb.checked = allSelected; cb.indeterminate = indeterminate; - selectRow.createSpan({ cls: 'ssv-select-label', text: 'Select' }); + selectRow.createSpan({ cls: 'ssv-select-label', text: t('actionBar.select') }); cb.addEventListener('change', () => onSelectAll(cb.checked)); } diff --git a/src/ui/components/DiffPanel.ts b/src/ui/components/DiffPanel.ts index 4f9f7fb..c9b41b1 100644 --- a/src/ui/components/DiffPanel.ts +++ b/src/ui/components/DiffPanel.ts @@ -1,12 +1,13 @@ import { computeSideBySideDiff, type DiffSide } from '../../utils/diff'; +import { t } from '../../i18n'; /** Renders the side-by-side + unified diff body into an existing container. */ export function renderDiffPanel(container: HTMLElement, remoteContent: string, localContent: string): void { const rows = computeSideBySideDiff(remoteContent, localContent); const grid = container.createDiv({ cls: 'ssv-diff-split' }).createDiv({ cls: 'ssv-diff-grid' }); - grid.createDiv({ cls: 'ssv-diff-hd', text: 'Remote' }); - grid.createDiv({ cls: 'ssv-diff-hd', text: 'Local' }); + grid.createDiv({ cls: 'ssv-diff-hd', text: t('diffPanel.remote') }); + grid.createDiv({ cls: 'ssv-diff-hd', text: t('diffPanel.local') }); for (const row of rows) { renderDiffCell(grid, row.left); renderDiffCell(grid, row.right); diff --git a/src/ui/components/FileListItem.ts b/src/ui/components/FileListItem.ts index 18f362f..6927768 100644 --- a/src/ui/components/FileListItem.ts +++ b/src/ui/components/FileListItem.ts @@ -2,6 +2,7 @@ import { setIcon, setTooltip } from 'obsidian'; import { type FileStatus } from '../types'; import { renderDiffPanel } from './DiffPanel'; import { ICONS } from './icons'; +import { t } from '../../i18n'; export interface FileItemCallbacks { onSelect: (path: string, selected: boolean) => void; @@ -21,11 +22,11 @@ export interface FileItemCallbacks { // uses the same icon set and renders consistently across platforms. export function statusMeta(status: FileStatus['status']) { switch (status) { - case 'synced': return { icon: ICONS.synced, label: 'Synced', iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' }; - case 'modified': return { icon: ICONS.modified, label: 'Changed', iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' }; - case 'unsynced': return { icon: ICONS.push, label: 'Local only', iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' }; - case 'remote-only': return { icon: ICONS.pull, label: 'Remote', iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' }; - default: return { icon: ICONS.checking, label: 'Checking', iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' }; + case 'synced': return { icon: ICONS.synced, label: t('syncStatus.tab.synced'), iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' }; + case 'modified': return { icon: ICONS.modified, label: t('syncStatus.tab.modified'), iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' }; + case 'unsynced': return { icon: ICONS.push, label: t('syncStatus.tab.unsynced'), iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' }; + case 'remote-only': return { icon: ICONS.pull, label: t('syncStatus.tab.remote-only'), iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' }; + default: return { icon: ICONS.checking, label: t('syncStatus.status.checking'), iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' }; } } @@ -60,15 +61,15 @@ function renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus, callback } if (fileStatus.status === 'modified' || fileStatus.status === 'unsynced') { - renderActionBtn(actions, ICONS.push, ' Push', 'Push to remote', () => callbacks.onPush(fileStatus), 'push'); + renderActionBtn(actions, ICONS.push, t('fileListItem.action.push'), t('fileListItem.tooltip.pushToRemote'), () => callbacks.onPush(fileStatus), 'push'); } if (fileStatus.status === 'modified' || fileStatus.status === 'remote-only') { - renderActionBtn(actions, ICONS.pull, ' Pull', 'Pull from remote', () => callbacks.onPull(fileStatus), 'pull'); + renderActionBtn(actions, ICONS.pull, t('fileListItem.action.pull'), t('fileListItem.tooltip.pullFromRemote'), () => callbacks.onPull(fileStatus), 'pull'); } if (fileStatus.status === 'unsynced') { - renderActionBtn(actions, ICONS.delete, ' Remove', 'Delete local file', () => callbacks.onDelete(fileStatus), 'danger'); + renderActionBtn(actions, ICONS.delete, t('fileListItem.action.remove'), t('fileListItem.tooltip.deleteLocalFile'), () => callbacks.onDelete(fileStatus), 'danger'); } } @@ -76,21 +77,21 @@ function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileS const diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' }); const iconEl = diffBtn.createSpan(); setIcon(iconEl, ICONS.diff); - const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' }); + const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: t('fileListItem.action.diff') }); const diffEl = fileEl.createDiv({ cls: 'ssv-diff' }); renderDiffBody(diffEl, fileStatus); - setTooltip(diffBtn, 'Toggle diff view'); + setTooltip(diffBtn, t('fileListItem.tooltip.toggleDiff')); diffBtn.addEventListener('click', () => { const open = diffEl.hasClass('visible'); if (!open && needsContentFetch(fileStatus)) { diffEl.empty(); - diffEl.createDiv({ cls: 'ssv-diff-loading', text: 'Loading diff…' }); + diffEl.createDiv({ cls: 'ssv-diff-loading', text: t('fileListItem.diff.loading') }); void callbacks.onExpandDiff(fileStatus).then(() => renderDiffBody(diffEl, fileStatus)); } diffEl.toggleClass('visible', !open); - btnLabel.setText(open ? ' Diff' : ' Hide'); + btnLabel.setText(open ? t('fileListItem.action.diff') : t('fileListItem.action.hide')); setIcon(iconEl, open ? ICONS.diff : ICONS.diffOpen); }); } @@ -102,13 +103,13 @@ function needsContentFetch(fileStatus: FileStatus): boolean { function renderDiffBody(diffEl: HTMLElement, fileStatus: FileStatus): void { diffEl.empty(); if (fileStatus.isSymlink) { - diffEl.createDiv({ cls: 'ssv-diff-binary', text: 'Symlink target changed' }); + diffEl.createDiv({ cls: 'ssv-diff-binary', text: t('fileListItem.diff.symlinkChanged') }); } else if (typeof fileStatus.remoteContent === 'string' && typeof fileStatus.localContent === 'string') { renderDiffPanel(diffEl, fileStatus.remoteContent, fileStatus.localContent); } else if (fileStatus.remoteContent === undefined) { - diffEl.createDiv({ cls: 'ssv-diff-loading', text: 'Click Diff to load…' }); + diffEl.createDiv({ cls: 'ssv-diff-loading', text: t('fileListItem.diff.clickToLoad') }); } else { - diffEl.createDiv({ cls: 'ssv-diff-binary', text: 'Binary file changed' }); + diffEl.createDiv({ cls: 'ssv-diff-binary', text: t('fileListItem.diff.binaryChanged') }); } } diff --git a/tests/i18n/index.test.ts b/tests/i18n/index.test.ts new file mode 100644 index 0000000..4d69df1 --- /dev/null +++ b/tests/i18n/index.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { t, getActiveLocale } from '../../src/i18n'; + +type MomentGlobal = { moment?: { locale: () => string } }; + +function setMomentLocale(locale: string | undefined): void { + const w = window as unknown as MomentGlobal; + if (locale === undefined) { + delete w.moment; + } else { + w.moment = { locale: () => locale }; + } +} + +describe('i18n', () => { + afterEach(() => { + setMomentLocale(undefined); + }); + + it('falls back to English when window.moment is unavailable', () => { + setMomentLocale(undefined); + expect(getActiveLocale()).toBe('en'); + expect(t('confirmModal.cancel')).toBe('Cancel'); + }); + + it('falls back to English for an unsupported locale', () => { + setMomentLocale('fr'); + expect(getActiveLocale()).toBe('en'); + expect(t('confirmModal.cancel')).toBe('Cancel'); + }); + + it('resolves zh-tw translations when moment locale is zh-tw', () => { + setMomentLocale('zh-tw'); + expect(getActiveLocale()).toBe('zh-tw'); + expect(t('confirmModal.cancel')).toBe('取消'); + }); + + it('maps a bare "zh" locale to zh-tw', () => { + setMomentLocale('zh'); + expect(getActiveLocale()).toBe('zh-tw'); + }); + + it('interpolates variables into the template', () => { + setMomentLocale(undefined); + expect(t('settings.testConnection.success', { service: 'GitHub' })).toBe('GitHub connection successful!'); + }); + + it('falls back to English text when a zh-tw key is missing a translation', () => { + setMomentLocale('zh-tw'); + // Every key defined in en.ts should resolve to *some* string even if + // zh-tw hasn't translated it yet, rather than throwing or returning undefined. + expect(typeof t('confirmModal.title')).toBe('string'); + }); +});