feat: add Simplified Chinese locale, settings what's-new banner, and 1.3.0 changelog

- src/i18n: add zh-cn locale, explicit language override setting
  (LanguageSetting), and moment-locale -> zh-cn/zh-tw resolution.
- src/settings.ts: persistent banner at the top of the settings tab
  showing the current version's notable changelog highlights, so users
  who dismiss or miss the WhatsNewModal can still find them. Dismissal
  is tracked separately (bannerDismissedVersion) from the modal's
  once-per-upgrade lastSeenVersion gate.
- src/changelog.ts: curate the 1.3.0 release notes (multi-language UI,
  faster status refresh, symlink-pull fix, ignore-sync setting,
  connection status display, resizable conflict modal, folder picker,
  clearer connection errors, this what's-new tip).

Part of #39.
This commit is contained in:
ClaudiaFang 2026-07-14 13:12:14 +00:00
parent 33d41ac89b
commit 72ed2cde75
11 changed files with 355 additions and 5 deletions

View file

@ -20,6 +20,20 @@ export interface ChangelogRelease {
}
export const CHANGELOG: ChangelogRelease[] = [
{
version: '1.3.0',
entries: [
{ text: 'The plugin now speaks multiple languages — English, Traditional Chinese, and Simplified Chinese. It follows your Obsidian display language automatically, or you can pick one in Settings.', notable: true },
{ text: 'Checking sync status is now much faster, especially in vaults with lots of files.', notable: true },
{ text: 'Fixed a bug where a linked (symlinked) folder could be pulled incorrectly instead of being treated as a link.', notable: true },
{ text: 'Added a setting to keep specific files or folders out of sync, in addition to what your repo\'s .gitignore already excludes.' },
{ text: 'Settings now show your connection status at a glance, so you can tell right away if something needs attention.' },
{ text: 'The conflict resolution window can now be resized to see more content at once.' },
{ text: 'Picking your sync folders is easier now, with a folder browser instead of typing paths by hand.' },
{ text: 'Connection errors now explain what went wrong in plain language instead of a raw technical error.' },
{ text: 'You\'ll now see a short "what\'s new" summary right after updating, so you don\'t miss new features.' },
],
},
{
version: '1.2.1',
entries: [

View file

@ -1,13 +1,26 @@
import en, { TranslationKey } from './locales/en';
import zhTw from './locales/zh-tw';
import zhCn from './locales/zh-cn';
export type { TranslationKey };
const locales: Record<string, Partial<Record<TranslationKey, string>>> = {
en,
'zh-tw': zhTw,
'zh-cn': zhCn,
};
/** User-facing language choices exposed in the settings UI. 'system' follows Obsidian's display language. */
export type LanguageSetting = 'system' | 'en' | 'zh-tw' | 'zh-cn';
// Explicit language chosen in plugin settings, or 'system' (default) to follow
// Obsidian's display language. Set once at load via setLanguageOverride().
let languageOverride: LanguageSetting = 'system';
export function setLanguageOverride(language: LanguageSetting): void {
languageOverride = language;
}
// 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.
@ -24,11 +37,12 @@ function detectMomentLocale(): string {
// 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';
if (rawLocale.startsWith('zh')) return rawLocale.includes('cn') ? 'zh-cn' : 'zh-tw';
return 'en';
}
export function getActiveLocale(): string {
if (languageOverride !== 'system') return languageOverride;
return resolveLocale(detectMomentLocale());
}

View file

@ -6,6 +6,13 @@ const en = {
'settings.connectionStatus.disconnected': 'Not connected',
'settings.connectionStatus.withDetail': '{label} — {detail}',
'settings.language.name': 'Language',
'settings.language.desc': 'UI language for this plugin',
'settings.language.option.system': 'System default',
'settings.language.option.en': 'English',
'settings.language.option.zhTw': '繁體中文 (Traditional Chinese)',
'settings.language.option.zhCn': '简体中文 (Simplified Chinese)',
'settings.gitService.name': 'Git service',
'settings.gitService.desc': 'Choose your Git hosting service',
@ -102,6 +109,9 @@ const en = {
'whatsNew.viewChangelog': 'View full changelog',
'whatsNew.gotIt': 'Got it',
'settings.whatsNewBanner.title': "What's new in v{version}",
'settings.whatsNewBanner.dismiss': 'Dismiss',
'syncStatus.viewTitle': 'Sync status',
'syncStatus.emptyPrompt': 'Click "Refresh" to check sync status',
'syncStatus.progress.checkingWithCount': 'Checking files… {current}/{total} ({pct}%)',

200
src/i18n/locales/zh-cn.ts Normal file
View file

@ -0,0 +1,200 @@
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 zhCn: Partial<Record<TranslationKey, string>> = {
'settings.connectionStatus.checking': '检查中…',
'settings.connectionStatus.connected': '已连接',
'settings.connectionStatus.disconnected': '未连接',
'settings.connectionStatus.withDetail': '{label} — {detail}',
'settings.language.name': '语言',
'settings.language.desc': '此插件的界面语言',
'settings.language.option.system': '系统默认',
'settings.language.option.en': 'English',
'settings.language.option.zhTw': '繁體中文',
'settings.language.option.zhCn': '简体中文',
'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': '知道了',
'settings.whatsNewBanner.title': 'v{version} 更新重点',
'settings.whatsNewBanner.dismiss': '关闭提示',
'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.partialWithMessage': '已删除 {succeeded}/{total} 个,{failed} 个失败:{message}',
'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 zhCn;

View file

@ -8,6 +8,13 @@ const zhTw: Partial<Record<TranslationKey, string>> = {
'settings.connectionStatus.disconnected': '未連線',
'settings.connectionStatus.withDetail': '{label} — {detail}',
'settings.language.name': '語言',
'settings.language.desc': '此外掛的介面語言',
'settings.language.option.system': '系統預設',
'settings.language.option.en': 'English',
'settings.language.option.zhTw': '繁體中文',
'settings.language.option.zhCn': '简体中文',
'settings.gitService.name': 'Git 服務',
'settings.gitService.desc': '選擇您的 Git 代管服務',
@ -104,6 +111,9 @@ const zhTw: Partial<Record<TranslationKey, string>> = {
'whatsNew.viewChangelog': '查看完整更新日誌',
'whatsNew.gotIt': '知道了',
'settings.whatsNewBanner.title': 'v{version} 更新重點',
'settings.whatsNewBanner.dismiss': '關閉提示',
'syncStatus.viewTitle': '同步狀態',
'syncStatus.emptyPrompt': '點擊「重新整理」以檢查同步狀態',
'syncStatus.progress.checkingWithCount': '檢查檔案中… {current}/{total}{pct}%',

View file

@ -13,7 +13,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';
import { t, setLanguageOverride } from './i18n';
export type ConnectionStatusState = 'checking' | 'connected' | 'disconnected';
@ -422,6 +422,7 @@ export default class GitLabFilesPush extends Plugin {
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData() as Partial<GitLabFilesPushSettings>);
setLanguageOverride(this.settings.language);
}
async saveSettings() {

View file

@ -2,7 +2,8 @@ import {App, PluginSettingTab, Setting, Notice, TextComponent} from 'obsidian';
import GitLabFilesPush, { type ConnectionStatus } from "./main";
import {FolderSuggest} from "./ui/FolderSuggest";
import {RemoteFolderSuggest} from "./ui/RemoteFolderSuggest";
import { t } from "./i18n";
import { t, setLanguageOverride, type LanguageSetting } from "./i18n";
import { CHANGELOG } from "./changelog";
// Minimal shape of Obsidian >= 1.13's SettingDefinitionItem. Declared locally so
// the plugin still type-checks against older Obsidian typings (minAppVersion
@ -51,6 +52,10 @@ export interface GitLabFilesPushSettings {
ignorePatterns: string;
/** Plugin version last seen by this vault, used to show a "what's new" tip after an update. */
lastSeenVersion: string;
/** Version whose "what's new" banner in the settings tab has been dismissed, if any. */
bannerDismissedVersion: string;
/** UI language. 'system' follows Obsidian's display language, falling back to English if unsupported. */
language: LanguageSetting;
}
export function getServiceName(settings: GitLabFilesPushSettings): string {
@ -90,7 +95,9 @@ export const DEFAULT_SETTINGS: GitLabFilesPushSettings = {
vaultFolder: '',
symlinkHandling: 'real',
ignorePatterns: '',
lastSeenVersion: ''
lastSeenVersion: '',
bannerDismissedVersion: '',
language: 'system'
}
const CONNECTION_TEST_DEBOUNCE_MS = 800;
@ -151,6 +158,41 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
// badge element is instead created once per renderSettings pass and
// updated in place by setStatusBadge(), driven by the plugin's shared
// connection status (see main.ts) so it stays in sync with the status bar.
// Persistent (until dismissed) banner surfacing the current version's notable
// highlights right at the top of the settings tab, so users who dismissed or
// never saw the WhatsNewModal (see main.ts) can still find them. Separate
// from `lastSeenVersion` — that gate controls the once-per-upgrade modal,
// this one just tracks whether the banner itself was dismissed.
private renderWhatsNewBanner(containerEl: HTMLElement): void {
const currentVersion = this.plugin.manifest.version;
if (this.plugin.settings.bannerDismissedVersion === currentVersion) return;
const release = CHANGELOG.find(r => r.version === currentVersion);
const notableEntries = release?.entries.filter(entry => entry.notable) ?? [];
if (notableEntries.length === 0) return;
const banner = containerEl.createDiv({ cls: 'gfs-whats-new-banner' });
const textEl = banner.createDiv({ cls: 'gfs-whats-new-banner-text' });
textEl.createEl('strong', { text: t('settings.whatsNewBanner.title', { version: currentVersion }) });
const list = textEl.createEl('ul', { cls: 'gfs-whats-new-banner-list' });
for (const entry of notableEntries) {
list.createEl('li', { text: entry.text });
}
const dismissBtn = banner.createEl('button', {
cls: 'gfs-whats-new-banner-dismiss',
text: '×',
attr: { 'aria-label': t('settings.whatsNewBanner.dismiss') }
});
dismissBtn.addEventListener('click', () => {
void (async () => {
this.plugin.settings.bannerDismissedVersion = currentVersion;
await this.plugin.saveSettings();
this.refresh();
})();
});
}
private renderConnectionStatus(containerEl: HTMLElement): void {
this.statusBadgeEl = containerEl.createDiv({ cls: 'gfs-connection-status' });
this.unsubscribeConnectionStatus?.();
@ -188,8 +230,25 @@ export class GitLabSyncSettingTab extends PluginSettingTab {
private renderSettings(containerEl: HTMLElement): void {
containerEl.empty();
this.renderWhatsNewBanner(containerEl);
this.renderConnectionStatus(containerEl);
new Setting(containerEl)
.setName(t('settings.language.name'))
.setDesc(t('settings.language.desc'))
.addDropdown(dropdown => dropdown
.addOption('system', t('settings.language.option.system'))
.addOption('en', t('settings.language.option.en'))
.addOption('zh-tw', t('settings.language.option.zhTw'))
.addOption('zh-cn', t('settings.language.option.zhCn'))
.setValue(this.plugin.settings.language)
.onChange((value: string) => {
this.plugin.settings.language = value as LanguageSetting;
void this.plugin.saveSettings();
setLanguageOverride(this.plugin.settings.language);
this.refresh();
}));
new Setting(containerEl)
.setName(t('settings.gitService.name'))
.setDesc(t('settings.gitService.desc'))

View file

@ -762,3 +762,40 @@
.ssv-whats-new-notable {
font-weight: 600;
}
/* ── Settings "what's new" banner ──────────────────────────────── */
.gfs-whats-new-banner {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
margin-bottom: 12px;
border-radius: 8px;
background: var(--background-secondary);
border: 1px solid var(--background-modifier-border);
}
.gfs-whats-new-banner-text {
flex: 1;
}
.gfs-whats-new-banner-list {
margin: 4px 0 0;
padding-left: 20px;
}
.gfs-whats-new-banner-dismiss {
background: none;
border: none;
box-shadow: none;
cursor: pointer;
color: var(--text-muted);
font-size: 1.1em;
line-height: 1;
padding: 2px 4px;
}
.gfs-whats-new-banner-dismiss:hover {
color: var(--text-normal);
}

View file

@ -55,6 +55,8 @@ const mockSettings: GitLabFilesPushSettings = {
symlinkHandling: 'real',
ignorePatterns: '',
lastSeenVersion: '',
bannerDismissedVersion: '',
language: 'system',
};
describe('SyncManager Mapping', () => {

View file

@ -61,7 +61,9 @@ const mockSettings: GitLabFilesPushSettings = {
vaultFolder: '',
symlinkHandling: 'real',
ignorePatterns: '',
lastSeenVersion: ''
lastSeenVersion: '',
bannerDismissedVersion: '',
language: 'system'
};
describe('SyncManager', () => {

View file

@ -21,6 +21,7 @@ function createPluginStub(testConnection: () => Promise<ConnectionTestResult>):
return {
settings: { ...DEFAULT_SETTINGS },
manifest: { version: '0.0.0-test' },
saveSettings: vi.fn().mockResolvedValue(undefined),
initializeGitService: vi.fn(),
gitService: { testConnection },