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:
ClaudiaFang 2026-07-13 14:28:18 +00:00
parent 4eebebc765
commit 144eb286d8
13 changed files with 655 additions and 153 deletions

42
src/i18n/index.ts Normal file
View 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
View 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
View 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;

View file

@ -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) }));
}
}

View file

@ -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;

View file

@ -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();

View file

@ -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();
}));

View file

@ -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);

View file

@ -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());
}

View file

@ -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));
}

View file

@ -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);

View file

@ -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
View 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');
});
});