mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 06:54:27 +00:00
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011BNWwPd5zudtW2JQr6ZAUm
This commit is contained in:
parent
4eebebc765
commit
144eb286d8
13 changed files with 655 additions and 153 deletions
42
src/i18n/index.ts
Normal file
42
src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import en, { TranslationKey } from './locales/en';
|
||||
import zhTw from './locales/zh-tw';
|
||||
|
||||
export type { TranslationKey };
|
||||
|
||||
const locales: Record<string, Partial<Record<TranslationKey, string>>> = {
|
||||
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, string | number>): 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
|
||||
);
|
||||
}
|
||||
188
src/i18n/locales/en.ts
Normal file
188
src/i18n/locales/en.ts
Normal file
|
|
@ -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;
|
||||
189
src/i18n/locales/zh-tw.ts
Normal file
189
src/i18n/locales/zh-tw.ts
Normal file
|
|
@ -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<Record<TranslationKey, string>> = {
|
||||
'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;
|
||||
37
src/main.ts
37
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) }));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
122
src/settings.ts
122
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<ConnectionStatusState, string> = {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<ConflictPanelName, string> = { diff: 'Diff', local: 'Local', remote: 'Remote' };
|
||||
const tabLabels: Record<ConflictPanelName, string> = {
|
||||
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();
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> { await this.runBatchOperation('selected', 'push'); }
|
||||
async pullSelected(): Promise<void> { 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<void> {
|
||||
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<string | TFile>): Promise<void> {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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') });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
54
tests/i18n/index.test.ts
Normal file
54
tests/i18n/index.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue