♻️ Refactor synchronization service

This commit is contained in:
2026-03-30 00:03:23 +08:00
parent 34c8f2a185
commit 4c5fff5390
42 changed files with 4377 additions and 3199 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,79 +0,0 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
/**
* BackupService 提供基于Git的备份同步功能
* @module
*/
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import {Call as $Call, Create as $Create} from "@wailsio/runtime";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as application$0 from "../../../github.com/wailsapp/wails/v3/pkg/application/models.js";
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore: Unused imports
import * as models$0 from "../models/models.js";
/**
* HandleConfigChange 处理配置变更
*/
export function HandleConfigChange(config: models$0.GitBackupConfig | null): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(395287784, config) as any;
return $resultPromise;
}
/**
* Initialize 初始化备份服务
*/
export function Initialize(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(1052437974) as any;
return $resultPromise;
}
/**
* Reinitialize 重新初始化
*/
export function Reinitialize(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(301562543) as any;
return $resultPromise;
}
/**
* ServiceShutdown 服务关闭
*/
export function ServiceShutdown(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(422131801) as any;
return $resultPromise;
}
export function ServiceStartup(options: application$0.ServiceOptions): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2900331732, options) as any;
return $resultPromise;
}
/**
* StartAutoBackup 启动自动备份
*/
export function StartAutoBackup(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3035755449) as any;
return $resultPromise;
}
/**
* StopAutoBackup 停止自动备份
*/
export function StopAutoBackup(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(2641894021) as any;
return $resultPromise;
}
/**
* Sync 执行完整的同步流程:导出 -> commit -> pull -> 解决冲突 -> push -> 导入
*/
export function Sync(): Promise<void> & { cancel(): void } {
let $resultPromise = $Call.ByID(3420383211) as any;
return $resultPromise;
}

View File

@@ -1,41 +0,0 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import * as BackupService from "./backupservice.js";
import * as ConfigService from "./configservice.js";
import * as DatabaseService from "./databaseservice.js";
import * as DialogService from "./dialogservice.js";
import * as DocumentService from "./documentservice.js";
import * as ExtensionService from "./extensionservice.js";
import * as HotkeyService from "./hotkeyservice.js";
import * as HttpClientService from "./httpclientservice.js";
import * as KeyBindingService from "./keybindingservice.js";
import * as MigrationService from "./migrationservice.js";
import * as SelfUpdateService from "./selfupdateservice.js";
import * as StartupService from "./startupservice.js";
import * as SystemService from "./systemservice.js";
import * as TestService from "./testservice.js";
import * as ThemeService from "./themeservice.js";
import * as TranslationService from "./translationservice.js";
import * as WindowService from "./windowservice.js";
export {
BackupService,
ConfigService,
DatabaseService,
DialogService,
DocumentService,
ExtensionService,
HotkeyService,
HttpClientService,
KeyBindingService,
MigrationService,
SelfUpdateService,
StartupService,
SystemService,
TestService,
ThemeService,
TranslationService,
WindowService
};
export * from "./models.js";

View File

@@ -1,6 +1,7 @@
import { import {
AppConfig, AppConfig,
AuthMethod, AuthMethod,
SyncTarget,
KeyBindingType, KeyBindingType,
LanguageType, LanguageType,
SystemThemeType, SystemThemeType,
@@ -8,12 +9,8 @@ import {
} from '@/../bindings/voidraft/internal/models/models'; } from '@/../bindings/voidraft/internal/models/models';
import {FONT_OPTIONS} from './fonts'; import {FONT_OPTIONS} from './fonts';
export type NumberConfigKey = 'fontSize' | 'tabSize' | 'lineHeight';
export type ConfigSection = 'general' | 'editing' | 'appearance' | 'updates' | 'backup';
// 统一配置键映射(平级展开) // 统一配置键映射(平级展开)
export const CONFIG_KEY_MAP = { export const CONFIG_KEY_MAP = {
// general
alwaysOnTop: 'general.alwaysOnTop', alwaysOnTop: 'general.alwaysOnTop',
dataPath: 'general.dataPath', dataPath: 'general.dataPath',
enableSystemTray: 'general.enableSystemTray', enableSystemTray: 'general.enableSystemTray',
@@ -24,7 +21,7 @@ export const CONFIG_KEY_MAP = {
enableLoadingAnimation: 'general.enableLoadingAnimation', enableLoadingAnimation: 'general.enableLoadingAnimation',
enableTabs: 'general.enableTabs', enableTabs: 'general.enableTabs',
enableMemoryMonitor: 'general.enableMemoryMonitor', enableMemoryMonitor: 'general.enableMemoryMonitor',
// editing
fontSize: 'editing.fontSize', fontSize: 'editing.fontSize',
fontFamily: 'editing.fontFamily', fontFamily: 'editing.fontFamily',
fontWeight: 'editing.fontWeight', fontWeight: 'editing.fontWeight',
@@ -34,33 +31,35 @@ export const CONFIG_KEY_MAP = {
tabType: 'editing.tabType', tabType: 'editing.tabType',
keymapMode: 'editing.keymapMode', keymapMode: 'editing.keymapMode',
autoSaveDelay: 'editing.autoSaveDelay', autoSaveDelay: 'editing.autoSaveDelay',
// appearance
language: 'appearance.language', language: 'appearance.language',
systemTheme: 'appearance.systemTheme', systemTheme: 'appearance.systemTheme',
currentTheme: 'appearance.currentTheme', currentTheme: 'appearance.currentTheme',
// updates
version: 'updates.version',
autoUpdate: 'updates.autoUpdate', autoUpdate: 'updates.autoUpdate',
primarySource: 'updates.primarySource',
backupSource: 'updates.backupSource',
backupBeforeUpdate: 'updates.backupBeforeUpdate', backupBeforeUpdate: 'updates.backupBeforeUpdate',
updateTimeout: 'updates.updateTimeout', updateTimeout: 'updates.updateTimeout',
github: 'updates.github', github: 'updates.github',
gitea: 'updates.gitea',
// backup sync_target: 'sync.target',
enabled: 'backup.enabled', git_enabled: 'sync.git.enabled',
repo_url: 'backup.repo_url', git_auto_sync: 'sync.git.auto_sync',
auth_method: 'backup.auth_method', git_sync_interval: 'sync.git.sync_interval',
username: 'backup.username', git_repo_url: 'sync.git.repo_url',
password: 'backup.password', git_auth_method: 'sync.git.auth_method',
token: 'backup.token', git_username: 'sync.git.username',
ssh_key_path: 'backup.ssh_key_path', git_password: 'sync.git.password',
ssh_key_passphrase: 'backup.ssh_key_passphrase', git_token: 'sync.git.token',
backup_interval: 'backup.backup_interval', git_ssh_key_path: 'sync.git.ssh_key_path',
auto_backup: 'backup.auto_backup', git_ssh_key_passphrase: 'sync.git.ssh_key_passphrase',
localfs_enabled: 'sync.localfs.enabled',
localfs_auto_sync: 'sync.localfs.auto_sync',
localfs_sync_interval: 'sync.localfs.sync_interval',
localfs_root_path: 'sync.localfs.root_path',
} as const; } as const;
export type ConfigKey = keyof typeof CONFIG_KEY_MAP; export type ConfigKey = keyof typeof CONFIG_KEY_MAP;
export type NumberConfigKey = 'fontSize' | 'tabSize' | 'lineHeight';
// 配置限制 // 配置限制
export const CONFIG_LIMITS = { export const CONFIG_LIMITS = {
@@ -116,20 +115,29 @@ export const DEFAULT_CONFIG: AppConfig = {
repo: "voidraft", repo: "voidraft",
}, },
}, },
backup: { sync: {
enabled: false, target: SyncTarget.SyncTargetGit,
repo_url: "", git: {
auth_method: AuthMethod.UserPass, enabled: false,
username: "", auto_sync: false,
password: "", sync_interval: 60,
token: "", repo_url: '',
ssh_key_path: "", auth_method: AuthMethod.UserPass,
ssh_key_passphrase: "", username: '',
backup_interval: 60, password: '',
auto_backup: true, token: '',
ssh_key_path: '',
ssh_key_passphrase: '',
},
localfs: {
enabled: false,
auto_sync: false,
sync_interval: 60,
root_path: '',
},
}, },
metadata: { metadata: {
version: '1.0.0', version: '1.0.0',
lastUpdated: new Date().toString(), lastUpdated: new Date().toString(),
} }
}; };

View File

@@ -182,7 +182,7 @@ export default {
general: 'General', general: 'General',
editing: 'Editor', editing: 'Editor',
appearance: 'Appearance', appearance: 'Appearance',
backupPage: 'Backup', syncPage: 'Sync',
keyBindings: 'Key Bindings', keyBindings: 'Key Bindings',
updates: 'Updates', updates: 'Updates',
reset: 'Reset', reset: 'Reset',
@@ -257,11 +257,16 @@ export default {
restartNow: 'Restart Now', restartNow: 'Restart Now',
hotkeyPreview: 'Preview:', hotkeyPreview: 'Preview:',
none: 'None', none: 'None',
backup: { sync: {
basicSettings: 'Basic Settings', basicSettings: 'Basic Settings',
enableBackup: 'Enable Git Backup', enableSync: 'Enable Sync',
autoBackup: 'Auto Backup', targetType: 'Sync Type',
backupInterval: 'Backup Interval', targetTypes: {
git: 'Git',
localfs: 'Local File System'
},
autoSync: 'Auto Sync',
syncInterval: 'Sync Interval',
intervals: { intervals: {
'5min': '5 minutes', '5min': '5 minutes',
'10min': '10 minutes', '10min': '10 minutes',
@@ -270,8 +275,11 @@ export default {
'1hour': '1 hour' '1hour': '1 hour'
}, },
repositoryConfig: 'Repository Configuration', repositoryConfig: 'Repository Configuration',
repoUrl: 'Repository URL', storageConfig: 'Storage Configuration',
repoUrlPlaceholder: 'Enter Git repository URL', repoUrl: 'Repository URL',
repoUrlPlaceholder: 'Enter Git repository URL',
localfsRootPath: 'Local Storage Directory',
localfsRootPathPlaceholder: 'Select local sync directory',
authConfig: 'Authentication Configuration', authConfig: 'Authentication Configuration',
authMethod: 'Authentication Method', authMethod: 'Authentication Method',
authMethods: { authMethods: {
@@ -289,9 +297,11 @@ export default {
sshKeyPathPlaceholder: 'Select SSH key file', sshKeyPathPlaceholder: 'Select SSH key file',
sshKeyPassphrase: 'SSH Key Passphrase', sshKeyPassphrase: 'SSH Key Passphrase',
sshKeyPassphrasePlaceholder: 'Enter SSH key passphrase', sshKeyPassphrasePlaceholder: 'Enter SSH key passphrase',
backupOperations: 'Backup Operations', syncOperations: 'Sync Operations',
syncToRemote: 'Sync to Remote', syncToRemote: 'Sync to Remote',
syncToTarget: 'Sync to Target',
syncing: 'Syncing...', syncing: 'Syncing...',
syncSuccess: 'Sync completed',
actions: { actions: {
sync: 'Sync', sync: 'Sync',
} }

View File

@@ -182,7 +182,7 @@ export default {
general: '常规', general: '常规',
editing: '编辑器', editing: '编辑器',
appearance: '外观', appearance: '外观',
backupPage: '备份', syncPage: '同步',
extensions: '扩展', extensions: '扩展',
keyBindings: '快捷键', keyBindings: '快捷键',
updates: '更新', updates: '更新',
@@ -259,11 +259,16 @@ export default {
colorValue: '颜色值', colorValue: '颜色值',
hotkeyPreview: '预览:', hotkeyPreview: '预览:',
none: '无', none: '无',
backup: { sync: {
basicSettings: '基本设置', basicSettings: '基本设置',
enableBackup: '启用备份', enableSync: '启用同步',
autoBackup: '自动备份', targetType: '同步方式',
backupInterval: '备份间隔', targetTypes: {
git: 'Git',
localfs: '本地文件系统'
},
autoSync: '自动同步',
syncInterval: '同步间隔',
intervals: { intervals: {
'5min': '5分钟', '5min': '5分钟',
'10min': '10分钟', '10min': '10分钟',
@@ -272,8 +277,11 @@ export default {
'1hour': '1小时' '1hour': '1小时'
}, },
repositoryConfig: '仓库配置', repositoryConfig: '仓库配置',
storageConfig: '存储配置',
repoUrl: '仓库地址', repoUrl: '仓库地址',
repoUrlPlaceholder: '请输入Git仓库地址', repoUrlPlaceholder: '请输入Git仓库地址',
localfsRootPath: '本地存储目录',
localfsRootPathPlaceholder: '请选择本地同步目录',
authConfig: '认证配置', authConfig: '认证配置',
authMethod: '认证方式', authMethod: '认证方式',
authMethods: { authMethods: {
@@ -291,9 +299,11 @@ export default {
sshKeyPathPlaceholder: '请选择SSH密钥文件', sshKeyPathPlaceholder: '请选择SSH密钥文件',
sshKeyPassphrase: 'SSH密钥密码', sshKeyPassphrase: 'SSH密钥密码',
sshKeyPassphrasePlaceholder: '请输入SSH密钥密码', sshKeyPassphrasePlaceholder: '请输入SSH密钥密码',
backupOperations: '备份操作', syncOperations: '同步操作',
syncToRemote: '同步到远程', syncToRemote: '同步到远程',
syncToTarget: '同步到目标',
syncing: '同步中...', syncing: '同步中...',
syncSuccess: '同步成功',
actions: { actions: {
sync: '同步', sync: '同步',
} }

View File

@@ -7,7 +7,7 @@ import AppearancePage from '@/views/settings/pages/AppearancePage.vue';
import KeyBindingsPage from '@/views/settings/pages/KeyBindingsPage.vue'; import KeyBindingsPage from '@/views/settings/pages/KeyBindingsPage.vue';
import UpdatesPage from '@/views/settings/pages/UpdatesPage.vue'; import UpdatesPage from '@/views/settings/pages/UpdatesPage.vue';
import ExtensionsPage from '@/views/settings/pages/ExtensionsPage.vue'; import ExtensionsPage from '@/views/settings/pages/ExtensionsPage.vue';
import BackupPage from '@/views/settings/pages/BackupPage.vue'; import SyncPage from '@/views/settings/pages/SyncPage.vue';
// 测试页面 // 测试页面
import TestPage from '@/views/settings/pages/TestPage.vue'; import TestPage from '@/views/settings/pages/TestPage.vue';
@@ -44,9 +44,9 @@ const settingsChildren: RouteRecordRaw[] = [
component: UpdatesPage component: UpdatesPage
}, },
{ {
path: 'backup', path: 'sync',
name: 'SettingsBackup', name: 'SettingsSync',
component: BackupPage component: SyncPage
} }
]; ];
@@ -79,4 +79,4 @@ const router = createRouter({
routes: routes routes: routes
}); });
export default router; export default router;

View File

@@ -1,113 +1,124 @@
import {defineStore} from 'pinia'; import {defineStore} from 'pinia';
import {computed, reactive} from 'vue'; import {computed, reactive} from 'vue';
import {ConfigService, StartupService} from '@/../bindings/voidraft/internal/services'; import {ConfigService, StartupService} from '@/../bindings/voidraft/internal/services';
import {AppConfig, AuthMethod, LanguageType, SystemThemeType, TabType} from '@/../bindings/voidraft/internal/models/models'; import {
AppConfig,
AuthMethod,
SyncTarget,
LanguageType,
SystemThemeType,
} from '@/../bindings/voidraft/internal/models/models';
import {useI18n} from 'vue-i18n'; import {useI18n} from 'vue-i18n';
import {ConfigUtils} from '@/common/utils/configUtils'; import {ConfigUtils} from '@/common/utils/configUtils';
import {FONT_OPTIONS} from '@/common/constant/fonts'; import {FONT_OPTIONS} from '@/common/constant/fonts';
import { import {CONFIG_KEY_MAP, CONFIG_LIMITS, ConfigKey, DEFAULT_CONFIG, NumberConfigKey} from '@/common/constant/config';
CONFIG_KEY_MAP,
CONFIG_LIMITS,
ConfigKey,
ConfigSection,
DEFAULT_CONFIG,
NumberConfigKey
} from '@/common/constant/config';
import * as runtime from '@wailsio/runtime'; import * as runtime from '@wailsio/runtime';
export const useConfigStore = defineStore('config', () => { export const useConfigStore = defineStore('config', () => {
const {locale} = useI18n(); const {locale} = useI18n();
// 响应式状态
const state = reactive({ const state = reactive({
config: {...DEFAULT_CONFIG} as AppConfig, config: structuredClone(DEFAULT_CONFIG) as AppConfig,
isLoading: false, isLoading: false,
configLoaded: false configLoaded: false
}); });
// Font options (no longer localized)
const fontOptions = computed(() => FONT_OPTIONS); const fontOptions = computed(() => FONT_OPTIONS);
// 统一配置更新方法 const applyConfig = (appConfig?: AppConfig | null): void => {
const updateConfig = async <K extends ConfigKey>(key: K, value: any): Promise<void> => { const nextConfig = structuredClone(DEFAULT_CONFIG) as AppConfig;
if (appConfig?.general) Object.assign(nextConfig.general, appConfig.general);
if (appConfig?.editing) Object.assign(nextConfig.editing, appConfig.editing);
if (appConfig?.appearance) Object.assign(nextConfig.appearance, appConfig.appearance);
if (appConfig?.updates) Object.assign(nextConfig.updates, appConfig.updates);
if (appConfig?.sync) {
if (appConfig.sync.target) {
nextConfig.sync.target = appConfig.sync.target;
}
if (appConfig.sync.git) {
Object.assign(nextConfig.sync.git, appConfig.sync.git);
}
if (appConfig.sync.localfs) {
Object.assign(nextConfig.sync.localfs, appConfig.sync.localfs);
}
}
if (appConfig?.metadata) Object.assign(nextConfig.metadata, appConfig.metadata);
state.config = nextConfig;
};
const ensureConfigLoaded = async (): Promise<void> => {
if (!state.configLoaded && !state.isLoading) { if (!state.configLoaded && !state.isLoading) {
await initConfig(); await initConfig();
} }
};
const backendKey = CONFIG_KEY_MAP[key]; const setValueByPath = (target: Record<string, any>, path: string, value: unknown): void => {
if (!backendKey) { const segments = path.split('.');
throw new Error(`No backend key mapping found for ${String(key)}`); const lastIndex = segments.length - 1;
let current: Record<string, any> = target;
for (let index = 0; index < lastIndex; index++) {
current = current[segments[index]];
} }
current[segments[lastIndex]] = value;
// 从 backendKey 提取 section例如 'general.alwaysOnTop' -> 'general'
const section = backendKey.split('.')[0] as ConfigSection;
await ConfigService.Set(backendKey, value);
(state.config[section] as any)[key] = value;
}; };
// 只更新本地状态,不保存到后端 const getValueByPath = (target: Record<string, any>, path: string): unknown => {
const updateConfigLocal = <K extends ConfigKey>(key: K, value: any): void => { return path.split('.').reduce<unknown>((current, segment) => (current as Record<string, any>)[segment], target);
const backendKey = CONFIG_KEY_MAP[key]; };
const section = backendKey.split('.')[0] as ConfigSection;
(state.config[section] as any)[key] = value; const updateConfig = async <K extends ConfigKey>(key: K, value: unknown): Promise<void> => {
await ensureConfigLoaded();
const path = CONFIG_KEY_MAP[key];
await ConfigService.Set(path, value);
setValueByPath(state.config as Record<string, any>, path, value);
};
const updateConfigLocal = <K extends ConfigKey>(key: K, value: unknown): void => {
setValueByPath(state.config as Record<string, any>, CONFIG_KEY_MAP[key], value);
}; };
// 保存指定配置到后端
const saveConfig = async <K extends ConfigKey>(key: K): Promise<void> => { const saveConfig = async <K extends ConfigKey>(key: K): Promise<void> => {
const backendKey = CONFIG_KEY_MAP[key]; const path = CONFIG_KEY_MAP[key];
const section = backendKey.split('.')[0] as ConfigSection; await ConfigService.Set(path, getValueByPath(state.config as Record<string, any>, path));
await ConfigService.Set(backendKey, (state.config[section] as any)[key]);
}; };
// 加载配置 const activeSyncKey = <G extends ConfigKey, L extends ConfigKey>(gitKey: G, localFSKey: L): G | L => (
state.config.sync.target === SyncTarget.SyncTargetGit ? gitKey : localFSKey
);
const initConfig = async (): Promise<void> => { const initConfig = async (): Promise<void> => {
if (state.isLoading) return; if (state.isLoading) return;
state.isLoading = true; state.isLoading = true;
try { try {
const appConfig = await ConfigService.GetConfig(); applyConfig(await ConfigService.GetConfig());
if (appConfig) {
// 合并配置
if (appConfig.general) Object.assign(state.config.general, appConfig.general);
if (appConfig.editing) Object.assign(state.config.editing, appConfig.editing);
if (appConfig.appearance) Object.assign(state.config.appearance, appConfig.appearance);
if (appConfig.updates) Object.assign(state.config.updates, appConfig.updates);
if (appConfig.backup) Object.assign(state.config.backup, appConfig.backup);
if (appConfig.metadata) Object.assign(state.config.metadata, appConfig.metadata);
}
state.configLoaded = true; state.configLoaded = true;
} finally { } finally {
state.isLoading = false; state.isLoading = false;
} }
}; };
// 重置配置
const resetConfig = async (): Promise<void> => { const resetConfig = async (): Promise<void> => {
if (state.isLoading) return; if (state.isLoading) return;
state.isLoading = true; state.isLoading = true;
try { try {
await ConfigService.ResetConfig(); await ConfigService.ResetConfig();
const appConfig = await ConfigService.GetConfig(); applyConfig(await ConfigService.GetConfig());
if (appConfig) { state.configLoaded = true;
state.config = JSON.parse(JSON.stringify(appConfig)) as AppConfig;
}
} finally { } finally {
state.isLoading = false; state.isLoading = false;
} }
}; };
// 辅助函数:限制数值范围
const clampValue = (value: number, key: NumberConfigKey): number => { const clampValue = (value: number, key: NumberConfigKey): number => {
const limit = CONFIG_LIMITS[key]; const limit = CONFIG_LIMITS[key];
return ConfigUtils.clamp(value, limit.min, limit.max); return ConfigUtils.clamp(value, limit.min, limit.max);
}; };
// 计算属性
const fontConfig = computed(() => ({ const fontConfig = computed(() => ({
fontSize: state.config.editing.fontSize, fontSize: state.config.editing.fontSize,
fontFamily: state.config.editing.fontFamily, fontFamily: state.config.editing.fontFamily,
@@ -122,7 +133,6 @@ export const useConfigStore = defineStore('config', () => {
})); }));
return { return {
// 状态
config: computed(() => state.config), config: computed(() => state.config),
configLoaded: computed(() => state.configLoaded), configLoaded: computed(() => state.configLoaded),
isLoading: computed(() => state.isLoading), isLoading: computed(() => state.isLoading),
@@ -130,31 +140,25 @@ export const useConfigStore = defineStore('config', () => {
fontConfig, fontConfig,
tabConfig, tabConfig,
// 核心方法
initConfig, initConfig,
resetConfig, resetConfig,
// 语言相关方法 setLanguage: async (value: LanguageType) => {
setLanguage: (value: LanguageType) => { await updateConfig('language', value);
updateConfig('language', value);
locale.value = value as any; locale.value = value as any;
}, },
// 主题相关方法
setSystemTheme: (value: SystemThemeType) => updateConfig('systemTheme', value), setSystemTheme: (value: SystemThemeType) => updateConfig('systemTheme', value),
setCurrentTheme: (value: string) => updateConfig('currentTheme', value), setCurrentTheme: (value: string) => updateConfig('currentTheme', value),
// 字体大小操作
setFontSize: async (value: number) => { setFontSize: async (value: number) => {
await updateConfig('fontSize', clampValue(value, 'fontSize')); await updateConfig('fontSize', clampValue(value, 'fontSize'));
}, },
increaseFontSize: async () => { increaseFontSize: async () => {
const newValue = state.config.editing.fontSize + 1; await updateConfig('fontSize', clampValue(state.config.editing.fontSize + 1, 'fontSize'));
await updateConfig('fontSize', clampValue(newValue, 'fontSize'));
}, },
decreaseFontSize: async () => { decreaseFontSize: async () => {
const newValue = state.config.editing.fontSize - 1; await updateConfig('fontSize', clampValue(state.config.editing.fontSize - 1, 'fontSize'));
await updateConfig('fontSize', clampValue(newValue, 'fontSize'));
}, },
resetFontSize: async () => { resetFontSize: async () => {
await updateConfig('fontSize', CONFIG_LIMITS.fontSize.default); await updateConfig('fontSize', CONFIG_LIMITS.fontSize.default);
@@ -169,89 +173,63 @@ export const useConfigStore = defineStore('config', () => {
await saveConfig('fontSize'); await saveConfig('fontSize');
}, },
// 字体操作
setFontFamily: (value: string) => updateConfig('fontFamily', value), setFontFamily: (value: string) => updateConfig('fontFamily', value),
setFontWeight: (value: string) => updateConfig('fontWeight', value), setFontWeight: (value: string) => updateConfig('fontWeight', value),
// 行高操作
setLineHeight: async (value: number) => { setLineHeight: async (value: number) => {
await updateConfig('lineHeight', clampValue(value, 'lineHeight')); await updateConfig('lineHeight', clampValue(value, 'lineHeight'));
}, },
// Tab操作
setEnableTabIndent: (value: boolean) => updateConfig('enableTabIndent', value), setEnableTabIndent: (value: boolean) => updateConfig('enableTabIndent', value),
setTabSize: async (value: number) => { setTabSize: async (value: number) => {
await updateConfig('tabSize', clampValue(value, 'tabSize')); await updateConfig('tabSize', clampValue(value, 'tabSize'));
}, },
increaseTabSize: async () => { increaseTabSize: async () => {
const newValue = state.config.editing.tabSize + 1; await updateConfig('tabSize', clampValue(state.config.editing.tabSize + 1, 'tabSize'));
await updateConfig('tabSize', clampValue(newValue, 'tabSize'));
}, },
decreaseTabSize: async () => { decreaseTabSize: async () => {
const newValue = state.config.editing.tabSize - 1; await updateConfig('tabSize', clampValue(state.config.editing.tabSize - 1, 'tabSize'));
await updateConfig('tabSize', clampValue(newValue, 'tabSize'));
}, },
toggleTabType: async () => { toggleTabType: async () => {
const values = CONFIG_LIMITS.tabType.values; const values = CONFIG_LIMITS.tabType.values;
const currentIndex = values.indexOf(state.config.editing.tabType as typeof values[number]); const currentIndex = values.indexOf(state.config.editing.tabType as typeof values[number]);
const nextIndex = (currentIndex + 1) % values.length; await updateConfig('tabType', values[(currentIndex + 1) % values.length]);
await updateConfig('tabType', values[nextIndex]);
}, },
// 窗口操作
toggleAlwaysOnTop: async () => { toggleAlwaysOnTop: async () => {
await updateConfig('alwaysOnTop', !state.config.general.alwaysOnTop); await updateConfig('alwaysOnTop', !state.config.general.alwaysOnTop);
await runtime.Window.SetAlwaysOnTop(state.config.general.alwaysOnTop); await runtime.Window.SetAlwaysOnTop(state.config.general.alwaysOnTop);
}, },
setAlwaysOnTop: (value: boolean) => updateConfig('alwaysOnTop', value), setAlwaysOnTop: (value: boolean) => updateConfig('alwaysOnTop', value),
// 路径操作
setDataPath: (value: string) => updateConfigLocal('dataPath', value), setDataPath: (value: string) => updateConfigLocal('dataPath', value),
// 保存配置相关方法
setAutoSaveDelay: (value: number) => updateConfig('autoSaveDelay', value), setAutoSaveDelay: (value: number) => updateConfig('autoSaveDelay', value),
// 热键配置相关方法
setEnableGlobalHotkey: (value: boolean) => updateConfig('enableGlobalHotkey', value), setEnableGlobalHotkey: (value: boolean) => updateConfig('enableGlobalHotkey', value),
setGlobalHotkey: (hotkey: any) => updateConfig('globalHotkey', hotkey), setGlobalHotkey: (hotkey: any) => updateConfig('globalHotkey', hotkey),
// 系统托盘配置相关方法
setEnableSystemTray: (value: boolean) => updateConfig('enableSystemTray', value), setEnableSystemTray: (value: boolean) => updateConfig('enableSystemTray', value),
// 开机启动配置相关方法
setStartAtLogin: async (value: boolean) => { setStartAtLogin: async (value: boolean) => {
await updateConfig('startAtLogin', value); await updateConfig('startAtLogin', value);
await StartupService.SetEnabled(value); await StartupService.SetEnabled(value);
}, },
// 窗口吸附配置相关方法
setEnableWindowSnap: (value: boolean) => updateConfig('enableWindowSnap', value), setEnableWindowSnap: (value: boolean) => updateConfig('enableWindowSnap', value),
// 加载动画配置相关方法
setEnableLoadingAnimation: (value: boolean) => updateConfig('enableLoadingAnimation', value), setEnableLoadingAnimation: (value: boolean) => updateConfig('enableLoadingAnimation', value),
// 标签页配置相关方法
setEnableTabs: (value: boolean) => updateConfig('enableTabs', value), setEnableTabs: (value: boolean) => updateConfig('enableTabs', value),
// 内存监视器配置相关方法
setEnableMemoryMonitor: (value: boolean) => updateConfig('enableMemoryMonitor', value), setEnableMemoryMonitor: (value: boolean) => updateConfig('enableMemoryMonitor', value),
// 快捷键模式配置相关方法
setKeymapMode: (value: any) => updateConfig('keymapMode', value), setKeymapMode: (value: any) => updateConfig('keymapMode', value),
// 更新配置相关方法
setAutoUpdate: (value: boolean) => updateConfig('autoUpdate', value), setAutoUpdate: (value: boolean) => updateConfig('autoUpdate', value),
// 备份配置相关方法 setSyncTarget: (value: SyncTarget) => updateConfig('sync_target', value),
setEnableBackup: (value: boolean) => updateConfig('enabled', value), setEnableSync: (value: boolean) => updateConfig(activeSyncKey('git_enabled', 'localfs_enabled'), value),
setAutoBackup: (value: boolean) => updateConfig('auto_backup', value), setAutoSync: (value: boolean) => updateConfig(activeSyncKey('git_auto_sync', 'localfs_auto_sync'), value),
setRepoUrl: (value: string) => updateConfig('repo_url', value), setSyncInterval: (value: number) => updateConfig(
setAuthMethod: (value: AuthMethod) => updateConfig('auth_method', value), activeSyncKey('git_sync_interval', 'localfs_sync_interval'),
setUsername: (value: string) => updateConfig('username', value), Math.max(1, value)
setPassword: (value: string) => updateConfig('password', value), ),
setToken: (value: string) => updateConfig('token', value), setRepoUrl: (value: string) => updateConfig('git_repo_url', value),
setSshKeyPath: (value: string) => updateConfig('ssh_key_path', value), setAuthMethod: (value: AuthMethod) => updateConfig('git_auth_method', value),
setSshKeyPassphrase: (value: string) => updateConfig('ssh_key_passphrase', value), setUsername: (value: string) => updateConfig('git_username', value),
setBackupInterval: (value: number) => updateConfig('backup_interval', value), setPassword: (value: string) => updateConfig('git_password', value),
setToken: (value: string) => updateConfig('git_token', value),
setSshKeyPath: (value: string) => updateConfig('git_ssh_key_path', value),
setSshKeyPassphrase: (value: string) => updateConfig('git_ssh_key_passphrase', value),
setLocalFSRootPath: (value: string) => updateConfig('localfs_root_path', value),
}; };
}); });

View File

@@ -1,8 +1,8 @@
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { ref } from 'vue'; import { ref } from 'vue';
import { BackupService } from '@/../bindings/voidraft/internal/services'; import { SyncService } from '@/../bindings/voidraft/internal/services';
export const useBackupStore = defineStore('backup', () => { export const useSyncStore = defineStore('sync', () => {
const isSyncing = ref(false); const isSyncing = ref(false);
const sync = async (): Promise<void> => { const sync = async (): Promise<void> => {
@@ -11,11 +11,8 @@ export const useBackupStore = defineStore('backup', () => {
} }
isSyncing.value = true; isSyncing.value = true;
try { try {
await BackupService.Sync(); await SyncService.Sync();
} catch (e) {
throw e;
} finally { } finally {
isSyncing.value = false; isSyncing.value = false;
} }
@@ -25,4 +22,4 @@ export const useBackupStore = defineStore('backup', () => {
isSyncing, isSyncing,
sync sync
}; };
}); });

View File

@@ -18,7 +18,7 @@ const navItems = [
{ id: 'general', icon: '⚙️', route: '/settings/general' }, { id: 'general', icon: '⚙️', route: '/settings/general' },
{ id: 'editing', icon: '✏️', route: '/settings/editing' }, { id: 'editing', icon: '✏️', route: '/settings/editing' },
{ id: 'appearance', icon: '🎨', route: '/settings/appearance' }, { id: 'appearance', icon: '🎨', route: '/settings/appearance' },
{ id: 'backupPage', icon: '🔗', route: '/settings/backup' }, { id: 'syncPage', icon: '🔗', route: '/settings/sync' },
{ id: 'extensions', icon: '🧩', route: '/settings/extensions' }, { id: 'extensions', icon: '🧩', route: '/settings/extensions' },
{ id: 'keyBindings', icon: '⌨️', route: '/settings/key-bindings' }, { id: 'keyBindings', icon: '⌨️', route: '/settings/key-bindings' },
{ id: 'updates', icon: '🔄', route: '/settings/updates' } { id: 'updates', icon: '🔄', route: '/settings/updates' }
@@ -212,4 +212,4 @@ const goBackToEditor = async () => {
} }
</style> </style>

View File

@@ -1,321 +0,0 @@
<script setup lang="ts">
import {useConfigStore} from '@/stores/configStore';
import {useBackupStore} from '@/stores/backupStore';
import {useI18n} from 'vue-i18n';
import {computed} from 'vue';
import SettingSection from '../components/SettingSection.vue';
import SettingItem from '../components/SettingItem.vue';
import ToggleSwitch from '../components/ToggleSwitch.vue';
import {AuthMethod} from '@/../bindings/voidraft/internal/models/models';
import {DialogService} from '@/../bindings/voidraft/internal/services';
import toast from '@/components/toast';
const {t} = useI18n();
const configStore = useConfigStore();
const backupStore = useBackupStore();
const authMethodOptions = computed(() => [
{value: AuthMethod.Token, label: t('settings.backup.authMethods.token')},
{value: AuthMethod.SSHKey, label: t('settings.backup.authMethods.sshKey')},
{value: AuthMethod.UserPass, label: t('settings.backup.authMethods.userPass')}
]);
const backupIntervalOptions = computed(() => [
{value: 5, label: t('settings.backup.intervals.5min')},
{value: 10, label: t('settings.backup.intervals.10min')},
{value: 15, label: t('settings.backup.intervals.15min')},
{value: 30, label: t('settings.backup.intervals.30min')},
{value: 60, label: t('settings.backup.intervals.1hour')}
]);
const selectSshKeyFile = async () => {
const selectedPath = await DialogService.SelectFile();
if (selectedPath.trim()) {
configStore.setSshKeyPath(selectedPath.trim());
}
};
const handleSync = async () => {
try {
await backupStore.sync();
toast.success('Sync successful');
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
}
};
</script>
<template>
<div class="settings-page">
<!-- 基本设置 -->
<SettingSection :title="t('settings.backup.basicSettings')">
<SettingItem :title="t('settings.backup.enableBackup')">
<ToggleSwitch
:modelValue="configStore.config.backup.enabled"
@update:modelValue="configStore.setEnableBackup"
/>
</SettingItem>
<SettingItem
:title="t('settings.backup.autoBackup')"
:class="{ 'disabled-setting': !configStore.config.backup.enabled }"
>
<ToggleSwitch
:modelValue="configStore.config.backup.auto_backup"
@update:modelValue="configStore.setAutoBackup"
:disabled="!configStore.config.backup.enabled"
/>
</SettingItem>
<SettingItem
:title="t('settings.backup.backupInterval')"
:class="{ 'disabled-setting': !configStore.config.backup.enabled || !configStore.config.backup.auto_backup }"
>
<select
class="backup-interval-select"
:value="configStore.config.backup.backup_interval"
@change="(e) => configStore.setBackupInterval(Number((e.target as HTMLSelectElement).value))"
:disabled="!configStore.config.backup.enabled || !configStore.config.backup.auto_backup"
>
<option v-for="option in backupIntervalOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</SettingItem>
</SettingSection>
<!-- 仓库配置 -->
<SettingSection :title="t('settings.backup.repositoryConfig')">
<SettingItem :title="t('settings.backup.repoUrl')">
<input
type="text"
class="repo-url-input"
:value="configStore.config.backup.repo_url"
@input="(e) => configStore.setRepoUrl((e.target as HTMLInputElement).value)"
:placeholder="t('settings.backup.repoUrlPlaceholder')"
:disabled="!configStore.config.backup.enabled"
/>
</SettingItem>
</SettingSection>
<!-- 认证配置 -->
<SettingSection :title="t('settings.backup.authConfig')">
<SettingItem :title="t('settings.backup.authMethod')">
<select
class="auth-method-select"
:value="configStore.config.backup.auth_method"
@change="(e) => configStore.setAuthMethod((e.target as HTMLSelectElement).value as AuthMethod)"
:disabled="!configStore.config.backup.enabled"
>
<option v-for="option in authMethodOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</SettingItem>
<!-- 用户名密码认证 -->
<template v-if="configStore.config.backup.auth_method === AuthMethod.UserPass">
<SettingItem :title="t('settings.backup.username')">
<input
type="text"
class="username-input"
:value="configStore.config.backup.username"
@input="(e) => configStore.setUsername((e.target as HTMLInputElement).value)"
:placeholder="t('settings.backup.usernamePlaceholder')"
:disabled="!configStore.config.backup.enabled"
/>
</SettingItem>
<SettingItem :title="t('settings.backup.password')">
<input
type="password"
class="password-input"
:value="configStore.config.backup.password"
@input="(e) => configStore.setPassword((e.target as HTMLInputElement).value)"
:placeholder="t('settings.backup.passwordPlaceholder')"
:disabled="!configStore.config.backup.enabled"
/>
</SettingItem>
</template>
<!-- 访问令牌认证 -->
<template v-if="configStore.config.backup.auth_method === AuthMethod.Token">
<SettingItem :title="t('settings.backup.token')">
<input
type="password"
class="token-input"
:value="configStore.config.backup.token"
@input="(e) => configStore.setToken((e.target as HTMLInputElement).value)"
:placeholder="t('settings.backup.tokenPlaceholder')"
:disabled="!configStore.config.backup.enabled"
/>
</SettingItem>
</template>
<!-- SSH密钥认证 -->
<template v-if="configStore.config.backup.auth_method === AuthMethod.SSHKey">
<SettingItem :title="t('settings.backup.sshKeyPath')">
<input
type="text"
class="ssh-key-path-input"
:value="configStore.config.backup.ssh_key_path"
:placeholder="t('settings.backup.sshKeyPathPlaceholder')"
:disabled="!configStore.config.backup.enabled"
readonly
@click="configStore.config.backup.enabled && selectSshKeyFile()"
/>
</SettingItem>
<SettingItem :title="t('settings.backup.sshKeyPassphrase')">
<input
type="password"
class="ssh-passphrase-input"
:value="configStore.config.backup.ssh_key_passphrase"
@input="(e) => configStore.setSshKeyPassphrase((e.target as HTMLInputElement).value)"
:placeholder="t('settings.backup.sshKeyPassphrasePlaceholder')"
:disabled="!configStore.config.backup.enabled"
/>
</SettingItem>
</template>
</SettingSection>
<!-- 备份操作 -->
<SettingSection :title="t('settings.backup.backupOperations')">
<SettingItem :title="t('settings.backup.syncToRemote')">
<button
class="sync-button"
@click="handleSync"
:disabled="!configStore.config.backup.enabled || !configStore.config.backup.repo_url || backupStore.isSyncing"
:class="{ 'syncing': backupStore.isSyncing }"
>
<span v-if="backupStore.isSyncing" class="loading-spinner"></span>
{{ backupStore.isSyncing ? t('settings.backup.syncing') : t('settings.backup.actions.sync') }}
</button>
</SettingItem>
</SettingSection>
</div>
</template>
<style scoped lang="scss">
// 统一的输入控件样式
.repo-url-input,
.branch-input,
.username-input,
.password-input,
.token-input,
.ssh-key-path-input,
.ssh-passphrase-input,
.backup-interval-select,
.auth-method-select {
width: 50%;
min-width: 200px;
padding: 10px 12px;
background-color: var(--settings-input-bg);
border: 1px solid var(--settings-input-border);
border-radius: 4px;
color: var(--settings-text);
font-size: 12px;
transition: all 0.2s ease;
&:focus {
outline: none;
border-color: #4a9eff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: var(--settings-hover);
}
&::placeholder {
color: var(--settings-text-secondary);
}
&[readonly]:not(:disabled) {
cursor: pointer;
&:hover {
border-color: var(--settings-hover);
background-color: var(--settings-hover);
}
}
}
// 选择框特有样式
.backup-interval-select,
.auth-method-select {
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 16px;
padding-right: 30px;
option {
background-color: var(--settings-input-bg);
color: var(--settings-text);
}
}
// 按钮样式
.sync-button {
padding: 8px 16px;
background-color: var(--settings-input-bg);
border: 1px solid var(--settings-input-border);
border-radius: 4px;
color: var(--settings-text);
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 8px;
&:hover:not(:disabled) {
background-color: var(--settings-hover);
border-color: var(--settings-border);
}
&:active:not(:disabled) {
transform: translateY(1px);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading-spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: var(--settings-text);
animation: spin 1s linear infinite;
}
&.syncing {
background-color: #2196f3;
border-color: #2196f3;
color: white;
}
}
// 禁用状态
.disabled-setting {
opacity: 0.5;
pointer-events: none;
}
// 加载动画
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,368 @@
<script setup lang="ts">
import {useConfigStore} from '@/stores/configStore';
import {useSyncStore} from '@/stores/syncStore';
import {useI18n} from 'vue-i18n';
import {computed} from 'vue';
import SettingSection from '../components/SettingSection.vue';
import SettingItem from '../components/SettingItem.vue';
import ToggleSwitch from '../components/ToggleSwitch.vue';
import {AuthMethod, SyncTarget} from '@/../bindings/voidraft/internal/models/models';
import {DialogService} from '@/../bindings/voidraft/internal/services';
import toast from '@/components/toast';
const {t} = useI18n();
const configStore = useConfigStore();
const syncStore = useSyncStore();
const syncConfig = computed(() => configStore.config.sync);
const isGitTarget = computed(() => syncConfig.value.target === SyncTarget.SyncTargetGit);
const isSyncEnabled = computed(() => isGitTarget.value ? syncConfig.value.git.enabled : syncConfig.value.localfs.enabled);
const isAutoSyncEnabled = computed(() => isGitTarget.value ? syncConfig.value.git.auto_sync : syncConfig.value.localfs.auto_sync);
const currentInterval = computed(() => isGitTarget.value ? syncConfig.value.git.sync_interval : syncConfig.value.localfs.sync_interval);
const authMethodOptions = computed(() => [
{value: AuthMethod.Token, label: t('settings.sync.authMethods.token')},
{value: AuthMethod.SSHKey, label: t('settings.sync.authMethods.sshKey')},
{value: AuthMethod.UserPass, label: t('settings.sync.authMethods.userPass')}
]);
const syncTargetOptions = computed(() => [
{value: SyncTarget.SyncTargetGit, label: t('settings.sync.targetTypes.git')},
{value: SyncTarget.SyncTargetLocalFS, label: t('settings.sync.targetTypes.localfs')}
]);
const syncIntervalOptions = computed(() => [
{value: 5, label: t('settings.sync.intervals.5min')},
{value: 10, label: t('settings.sync.intervals.10min')},
{value: 15, label: t('settings.sync.intervals.15min')},
{value: 30, label: t('settings.sync.intervals.30min')},
{value: 60, label: t('settings.sync.intervals.1hour')}
]);
const canSync = computed(() => {
if (!isSyncEnabled.value) {
return false;
}
return isGitTarget.value
? Boolean(syncConfig.value.git.repo_url.trim())
: Boolean(syncConfig.value.localfs.root_path.trim());
});
const selectSshKeyFile = async () => {
const selectedPath = await DialogService.SelectFile();
if (selectedPath.trim()) {
await configStore.setSshKeyPath(selectedPath.trim());
}
};
const selectLocalFSDirectory = async () => {
const selectedPath = await DialogService.SelectDirectory();
if (selectedPath.trim()) {
await configStore.setLocalFSRootPath(selectedPath.trim());
}
};
const handleSync = async () => {
try {
await syncStore.sync();
toast.success(t('settings.sync.syncSuccess'));
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
}
};
</script>
<template>
<div class="settings-page">
<SettingSection :title="t('settings.sync.basicSettings')">
<SettingItem :title="t('settings.sync.enableSync')">
<ToggleSwitch
:modelValue="isSyncEnabled"
@update:modelValue="configStore.setEnableSync"
/>
</SettingItem>
<SettingItem :title="t('settings.sync.targetType')">
<select
class="target-type-select"
:value="syncConfig.target"
@change="(e) => configStore.setSyncTarget((e.target as HTMLSelectElement).value as SyncTarget)"
>
<option v-for="option in syncTargetOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</SettingItem>
<SettingItem
:title="t('settings.sync.autoSync')"
:class="{ 'disabled-setting': !isSyncEnabled }"
>
<ToggleSwitch
:modelValue="isAutoSyncEnabled"
@update:modelValue="configStore.setAutoSync"
:disabled="!isSyncEnabled"
/>
</SettingItem>
<SettingItem
:title="t('settings.sync.syncInterval')"
:class="{ 'disabled-setting': !isSyncEnabled || !isAutoSyncEnabled }"
>
<select
class="sync-interval-select"
:value="currentInterval"
@change="(e) => configStore.setSyncInterval(Number((e.target as HTMLSelectElement).value))"
:disabled="!isSyncEnabled || !isAutoSyncEnabled"
>
<option v-for="option in syncIntervalOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</SettingItem>
</SettingSection>
<SettingSection :title="isGitTarget ? t('settings.sync.repositoryConfig') : t('settings.sync.storageConfig')">
<template v-if="isGitTarget">
<SettingItem :title="t('settings.sync.repoUrl')">
<input
type="text"
class="repo-url-input"
:value="syncConfig.git.repo_url"
@input="(e) => configStore.setRepoUrl((e.target as HTMLInputElement).value)"
:placeholder="t('settings.sync.repoUrlPlaceholder')"
:disabled="!isSyncEnabled"
/>
</SettingItem>
</template>
<template v-else>
<SettingItem :title="t('settings.sync.localfsRootPath')">
<input
type="text"
class="localfs-root-path-input"
:value="syncConfig.localfs.root_path"
:placeholder="t('settings.sync.localfsRootPathPlaceholder')"
:disabled="!isSyncEnabled"
readonly
@click="isSyncEnabled && selectLocalFSDirectory()"
/>
</SettingItem>
</template>
</SettingSection>
<SettingSection v-if="isGitTarget" :title="t('settings.sync.authConfig')">
<SettingItem :title="t('settings.sync.authMethod')">
<select
class="auth-method-select"
:value="syncConfig.git.auth_method"
@change="(e) => configStore.setAuthMethod((e.target as HTMLSelectElement).value as AuthMethod)"
:disabled="!isSyncEnabled"
>
<option v-for="option in authMethodOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
</SettingItem>
<template v-if="syncConfig.git.auth_method === AuthMethod.UserPass">
<SettingItem :title="t('settings.sync.username')">
<input
type="text"
class="username-input"
:value="syncConfig.git.username ?? ''"
@input="(e) => configStore.setUsername((e.target as HTMLInputElement).value)"
:placeholder="t('settings.sync.usernamePlaceholder')"
:disabled="!isSyncEnabled"
/>
</SettingItem>
<SettingItem :title="t('settings.sync.password')">
<input
type="password"
class="password-input"
:value="syncConfig.git.password ?? ''"
@input="(e) => configStore.setPassword((e.target as HTMLInputElement).value)"
:placeholder="t('settings.sync.passwordPlaceholder')"
:disabled="!isSyncEnabled"
/>
</SettingItem>
</template>
<template v-if="syncConfig.git.auth_method === AuthMethod.Token">
<SettingItem :title="t('settings.sync.token')">
<input
type="password"
class="token-input"
:value="syncConfig.git.token ?? ''"
@input="(e) => configStore.setToken((e.target as HTMLInputElement).value)"
:placeholder="t('settings.sync.tokenPlaceholder')"
:disabled="!isSyncEnabled"
/>
</SettingItem>
</template>
<template v-if="syncConfig.git.auth_method === AuthMethod.SSHKey">
<SettingItem :title="t('settings.sync.sshKeyPath')">
<input
type="text"
class="ssh-key-path-input"
:value="syncConfig.git.ssh_key_path ?? ''"
:placeholder="t('settings.sync.sshKeyPathPlaceholder')"
:disabled="!isSyncEnabled"
readonly
@click="isSyncEnabled && selectSshKeyFile()"
/>
</SettingItem>
<SettingItem :title="t('settings.sync.sshKeyPassphrase')">
<input
type="password"
class="ssh-passphrase-input"
:value="syncConfig.git.ssh_key_passphrase ?? ''"
@input="(e) => configStore.setSshKeyPassphrase((e.target as HTMLInputElement).value)"
:placeholder="t('settings.sync.sshKeyPassphrasePlaceholder')"
:disabled="!isSyncEnabled"
/>
</SettingItem>
</template>
</SettingSection>
<SettingSection :title="t('settings.sync.syncOperations')">
<SettingItem :title="t('settings.sync.syncToTarget')">
<button
class="sync-button"
@click="handleSync"
:disabled="!canSync || syncStore.isSyncing"
:class="{ 'syncing': syncStore.isSyncing }"
>
<span v-if="syncStore.isSyncing" class="loading-spinner"></span>
{{ syncStore.isSyncing ? t('settings.sync.syncing') : t('settings.sync.actions.sync') }}
</button>
</SettingItem>
</SettingSection>
</div>
</template>
<style scoped lang="scss">
.repo-url-input,
.localfs-root-path-input,
.username-input,
.password-input,
.token-input,
.ssh-key-path-input,
.ssh-passphrase-input,
.sync-interval-select,
.auth-method-select,
.target-type-select {
width: 50%;
min-width: 200px;
padding: 10px 12px;
background-color: var(--settings-input-bg);
border: 1px solid var(--settings-input-border);
border-radius: 4px;
color: var(--settings-text);
font-size: 12px;
transition: all 0.2s ease;
&:focus {
outline: none;
border-color: #4a9eff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
background-color: var(--settings-hover);
}
&::placeholder {
color: var(--settings-text-secondary);
}
&[readonly]:not(:disabled) {
cursor: pointer;
&:hover {
border-color: var(--settings-hover);
background-color: var(--settings-hover);
}
}
}
.sync-interval-select,
.auth-method-select,
.target-type-select {
appearance: none;
background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e");
background-repeat: no-repeat;
background-position: right 8px center;
background-size: 16px;
padding-right: 30px;
option {
background-color: var(--settings-input-bg);
color: var(--settings-text);
}
}
.sync-button {
padding: 8px 16px;
background-color: var(--settings-input-bg);
border: 1px solid var(--settings-input-border);
border-radius: 4px;
color: var(--settings-text);
cursor: pointer;
font-size: 12px;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 8px;
&:hover:not(:disabled) {
background-color: var(--settings-hover);
border-color: var(--settings-border);
}
&:active:not(:disabled) {
transform: translateY(1px);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading-spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top-color: var(--settings-text);
animation: spin 1s linear infinite;
}
&.syncing {
background-color: #2196f3;
border-color: #2196f3;
color: white;
}
}
.disabled-setting {
opacity: 0.5;
pointer-events: none;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -110,7 +110,7 @@ type UpdatesConfig struct {
Github GithubConfig `json:"github"` // GitHub配置 Github GithubConfig `json:"github"` // GitHub配置
} }
// Git备份相关类型定义 // Git同步相关类型定义
type ( type (
// AuthMethod 定义Git认证方式 // AuthMethod 定义Git认证方式
AuthMethod string AuthMethod string
@@ -123,18 +123,43 @@ const (
UserPass AuthMethod = "user_pass" UserPass AuthMethod = "user_pass"
) )
// GitBackupConfig Git备份配置 // SyncTarget 定义当前可选择的同步目标。
type GitBackupConfig struct { type SyncTarget string
Enabled bool `json:"enabled"`
RepoURL string `json:"repo_url"` const (
AuthMethod AuthMethod `json:"auth_method"` // SyncTargetGit 表示 Git 同步。
Username string `json:"username,omitempty"` SyncTargetGit SyncTarget = "git"
Password string `json:"password,omitempty"` // SyncTargetLocalFS 表示本地文件系统同步。
Token string `json:"token,omitempty"` SyncTargetLocalFS SyncTarget = "localfs"
SSHKeyPath string `json:"ssh_key_path,omitempty"` )
SSHKeyPass string `json:"ssh_key_passphrase,omitempty"`
BackupInterval int `json:"backup_interval"` // 分钟 // GitSyncConfig 描述 Git 同步配置。
AutoBackup bool `json:"auto_backup"` type GitSyncConfig struct {
Enabled bool `json:"enabled"`
AutoSync bool `json:"auto_sync"`
SyncInterval int `json:"sync_interval"` // 分钟
RepoURL string `json:"repo_url"`
AuthMethod AuthMethod `json:"auth_method"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
Token string `json:"token,omitempty"`
SSHKeyPath string `json:"ssh_key_path,omitempty"`
SSHKeyPass string `json:"ssh_key_passphrase,omitempty"`
}
// LocalFSSyncConfig 描述本地文件系统同步配置。
type LocalFSSyncConfig struct {
Enabled bool `json:"enabled"`
AutoSync bool `json:"auto_sync"`
SyncInterval int `json:"sync_interval"` // 分钟
RootPath string `json:"root_path"`
}
// SyncConfig 描述同步模块配置。
type SyncConfig struct {
Target SyncTarget `json:"target"`
Git GitSyncConfig `json:"git"`
LocalFS LocalFSSyncConfig `json:"localfs"`
} }
// AppConfig 应用配置 - 按照前端设置页面分类组织 // AppConfig 应用配置 - 按照前端设置页面分类组织
@@ -143,7 +168,7 @@ type AppConfig struct {
Editing EditingConfig `json:"editing"` // 编辑设置 Editing EditingConfig `json:"editing"` // 编辑设置
Appearance AppearanceConfig `json:"appearance"` // 外观设置 Appearance AppearanceConfig `json:"appearance"` // 外观设置
Updates UpdatesConfig `json:"updates"` // 更新设置 Updates UpdatesConfig `json:"updates"` // 更新设置
Backup GitBackupConfig `json:"backup"` // Git备份设置 Sync SyncConfig `json:"sync"` // 同步设置
Metadata ConfigMetadata `json:"metadata"` // 配置元数据 Metadata ConfigMetadata `json:"metadata"` // 配置元数据
} }
@@ -208,16 +233,26 @@ func NewDefaultAppConfig() *AppConfig {
Repo: "voidraft", Repo: "voidraft",
}, },
}, },
Backup: GitBackupConfig{ Sync: SyncConfig{
Enabled: false, Target: SyncTargetGit,
RepoURL: "", Git: GitSyncConfig{
AuthMethod: UserPass, Enabled: false,
Username: "", AutoSync: false,
Password: "", SyncInterval: 60,
Token: "", RepoURL: "",
SSHKeyPath: "", AuthMethod: UserPass,
BackupInterval: 60, Username: "",
AutoBackup: false, Password: "",
Token: "",
SSHKeyPath: "",
SSHKeyPass: "",
},
LocalFS: LocalFSSyncConfig{
Enabled: false,
AutoSync: false,
SyncInterval: 60,
RootPath: "",
},
}, },
Metadata: ConfigMetadata{ Metadata: ConfigMetadata{
LastUpdated: time.Now().Format(time.RFC3339), LastUpdated: time.Now().Format(time.RFC3339),

File diff suppressed because it is too large Load Diff

View File

@@ -29,7 +29,7 @@ type ServiceManager struct {
badgeService *dock.DockService badgeService *dock.DockService
notificationService *notifications.NotificationService notificationService *notifications.NotificationService
testService *TestService // 测试服务(仅开发环境) testService *TestService // 测试服务(仅开发环境)
BackupService *BackupService SyncService *SyncService
httpClientService *HttpClientService // HTTP客户端服务 httpClientService *HttpClientService // HTTP客户端服务
logger *log.LogService logger *log.LogService
} }
@@ -95,8 +95,8 @@ func NewServiceManager() *ServiceManager {
// 初始化主题服务 // 初始化主题服务
themeService := NewThemeService(databaseService, logger) themeService := NewThemeService(databaseService, logger)
// 初始化备份服务 // 初始化同步服务
backupService := NewBackupService(configService, databaseService, logger) syncService := NewSyncService(configService, databaseService, logger)
// 初始化HTTP客户端服务 // 初始化HTTP客户端服务
httpClientService := NewHttpClientService(logger) httpClientService := NewHttpClientService(logger)
@@ -124,7 +124,7 @@ func NewServiceManager() *ServiceManager {
badgeService: badgeService, badgeService: badgeService,
notificationService: notificationService, notificationService: notificationService,
testService: testService, testService: testService,
BackupService: backupService, SyncService: syncService,
httpClientService: httpClientService, httpClientService: httpClientService,
logger: logger, logger: logger,
} }
@@ -150,7 +150,7 @@ func (sm *ServiceManager) GetServices() []application.Service {
application.NewService(sm.badgeService), application.NewService(sm.badgeService),
application.NewService(sm.notificationService), application.NewService(sm.notificationService),
application.NewService(sm.testService), application.NewService(sm.testService),
application.NewService(sm.BackupService), application.NewService(sm.SyncService),
application.NewService(sm.httpClientService), application.NewService(sm.httpClientService),
} }
return services return services

View File

@@ -0,0 +1,242 @@
package services
import (
"context"
"fmt"
"path/filepath"
"time"
"voidraft/internal/common/helper"
"voidraft/internal/models"
"voidraft/internal/syncer"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/log"
)
const (
syncDir = "sync"
localFSHeadKey = "head.json"
)
// SyncService 提供应用层同步服务入口。
type SyncService struct {
configService *ConfigService
dbService *DatabaseService
logger *log.LogService
app *syncer.App
cancelObservers []helper.CancelFunc
}
// NewSyncService 创建新的同步服务实例。
func NewSyncService(configService *ConfigService, dbService *DatabaseService, logger *log.LogService) *SyncService {
return &SyncService{
configService: configService,
dbService: dbService,
logger: logger,
}
}
// ServiceStartup 在服务启动时初始化同步系统。
func (s *SyncService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
_ = options
if err := s.ensureApp(); err != nil {
return err
}
s.cancelObservers = []helper.CancelFunc{
s.configService.Watch("sync", s.onSyncConfigChange),
s.configService.Watch("general.dataPath", s.onDataPathChange),
}
if err := s.Initialize(); err != nil {
s.logger.Error("initializing sync service: %v", err)
}
return nil
}
// Initialize 重新加载配置并启动自动同步。
func (s *SyncService) Initialize() error {
if err := s.ensureApp(); err != nil {
return err
}
config, err := s.buildConfig()
if err != nil {
return err
}
if err := s.app.Reconfigure(context.Background(), config); err != nil {
return fmt.Errorf("reconfigure sync app: %w", err)
}
if err := s.app.Start(context.Background()); err != nil {
return fmt.Errorf("start sync app: %w", err)
}
return nil
}
// Reinitialize 重新初始化同步服务。
func (s *SyncService) Reinitialize() error {
return s.Initialize()
}
// HandleConfigChange 在配置变化时重新应用配置。
func (s *SyncService) HandleConfigChange(config *models.SyncConfig) error {
_ = config
return s.Initialize()
}
// StartAutoSync 启动自动同步调度。
func (s *SyncService) StartAutoSync() error {
if err := s.ensureApp(); err != nil {
return err
}
return s.app.Start(context.Background())
}
// StopAutoSync 停止自动同步调度。
func (s *SyncService) StopAutoSync() {
if s.app == nil {
return
}
if err := s.app.Stop(context.Background()); err != nil {
s.logger.Warning("stop sync app: %v", err)
}
}
// Sync 执行一次手动同步。
func (s *SyncService) Sync() error {
if err := s.ensureApp(); err != nil {
return err
}
targetID, err := s.selectedTargetID()
if err != nil {
return err
}
if _, err := s.app.Sync(context.Background(), targetID); err != nil {
return err
}
return nil
}
// ServiceShutdown 停止同步服务并释放资源。
func (s *SyncService) ServiceShutdown() {
for _, cancel := range s.cancelObservers {
if cancel != nil {
cancel()
}
}
s.StopAutoSync()
}
// onSyncConfigChange 响应 sync 配置变化。
func (s *SyncService) onSyncConfigChange(oldValue interface{}, newValue interface{}) {
_, _ = oldValue, newValue
if err := s.Initialize(); err != nil {
s.logger.Error("reconfigure sync after sync config change: %v", err)
}
}
// onDataPathChange 响应数据目录变化。
func (s *SyncService) onDataPathChange(oldValue interface{}, newValue interface{}) {
_, _ = oldValue, newValue
if err := s.Reinitialize(); err != nil {
s.logger.Error("reconfigure sync after data path change: %v", err)
}
}
// ensureApp 保证同步应用已被创建。
func (s *SyncService) ensureApp() error {
if s.app != nil {
return nil
}
if s.dbService == nil || s.dbService.Client == nil {
return fmt.Errorf("sync database client is not ready")
}
s.app = syncer.NewApp(s.dbService.Client, syncer.Options{
Logger: s.logger,
MaxSyncAttempts: 3,
})
return nil
}
// buildConfig 将现有应用配置映射为同步核心配置。
func (s *SyncService) buildConfig() (syncer.Config, error) {
appConfig, err := s.configService.GetConfig()
if err != nil {
return syncer.Config{}, err
}
return syncer.Config{
Targets: []syncer.TargetConfig{
s.buildGitTargetConfig(appConfig.General.DataPath, appConfig.Sync.Git),
s.buildLocalFSTargetConfig(appConfig.Sync.LocalFS),
},
}, nil
}
// selectedTargetID 返回当前选中的同步目标标识。
func (s *SyncService) selectedTargetID() (string, error) {
appConfig, err := s.configService.GetConfig()
if err != nil {
return "", err
}
switch appConfig.Sync.Target {
case models.SyncTargetGit:
return string(models.SyncTargetGit), nil
case models.SyncTargetLocalFS:
return string(models.SyncTargetLocalFS), nil
default:
return "", fmt.Errorf("unsupported sync target: %s", appConfig.Sync.Target)
}
}
// buildGitTargetConfig 将 Git 配置转换为同步核心目标配置。
func (s *SyncService) buildGitTargetConfig(dataPath string, config models.GitSyncConfig) syncer.TargetConfig {
return syncer.TargetConfig{
Kind: syncer.TargetKindGit,
Enabled: config.Enabled,
Schedule: syncer.ScheduleConfig{
AutoSync: config.AutoSync,
Interval: time.Duration(config.SyncInterval) * time.Minute,
},
Git: &syncer.GitTargetConfig{
RepoPath: filepath.Join(dataPath, syncDir),
RepoURL: config.RepoURL,
Branch: syncer.DefaultBranch,
RemoteName: syncer.DefaultRemoteName,
AuthorName: "voidraft",
AuthorEmail: "sync@voidraft.app",
Auth: syncer.GitAuthConfig{
Method: string(config.AuthMethod),
Username: config.Username,
Password: config.Password,
Token: config.Token,
SSHKeyPath: config.SSHKeyPath,
SSHKeyPassword: config.SSHKeyPass,
},
},
}
}
// buildLocalFSTargetConfig 将 localfs 配置转换为同步核心目标配置。
func (s *SyncService) buildLocalFSTargetConfig(config models.LocalFSSyncConfig) syncer.TargetConfig {
return syncer.TargetConfig{
Kind: syncer.TargetKindLocalFS,
Enabled: config.Enabled,
Schedule: syncer.ScheduleConfig{
AutoSync: config.AutoSync,
Interval: time.Duration(config.SyncInterval) * time.Minute,
},
LocalFS: &syncer.LocalFSTargetConfig{
Namespace: string(models.SyncTargetLocalFS),
HeadKey: localFSHeadKey,
RootPath: config.RootPath,
},
}
}

283
internal/syncer/app.go Normal file
View File

@@ -0,0 +1,283 @@
package syncer
import (
"context"
"fmt"
"sync"
"time"
"voidraft/internal/models/ent"
"voidraft/internal/syncer/backend"
gitbackend "voidraft/internal/syncer/backend/git"
snapshotstorebackend "voidraft/internal/syncer/backend/snapshotstore"
localfsblob "voidraft/internal/syncer/backend/snapshotstore/blob/localfs"
"voidraft/internal/syncer/engine"
"voidraft/internal/syncer/merge"
"voidraft/internal/syncer/resource"
"voidraft/internal/syncer/scheduler"
"voidraft/internal/syncer/snapshot"
)
const (
defaultAuthorName = "voidraft"
defaultAuthorEmail = "sync@voidraft.app"
defaultSyncAttempts = 3
)
// Options 描述同步应用的构造选项。
type Options struct {
Logger Logger
MaxSyncAttempts int
}
// App 是同步系统的编排入口。
type App struct {
logger Logger
snapshotter snapshot.Snapshotter
store snapshot.Store
merger merge.Merger
maxSyncAttempts int
mu sync.RWMutex
syncMu sync.Mutex
config Config
schedulers map[string]*scheduler.Ticker
}
// NewApp 创建新的同步应用实例。
func NewApp(client *ent.Client, options Options) *App {
maxSyncAttempts := options.MaxSyncAttempts
if maxSyncAttempts <= 0 {
maxSyncAttempts = defaultSyncAttempts
}
return &App{
logger: options.Logger,
snapshotter: resource.NewRegistry(
resource.NewDocumentAdapter(client),
resource.NewExtensionAdapter(client),
resource.NewKeyBindingAdapter(client),
resource.NewThemeAdapter(client),
),
store: snapshot.NewFileStore(),
merger: merge.NewUpdatedAtWinsMerger(),
maxSyncAttempts: maxSyncAttempts,
schedulers: make(map[string]*scheduler.Ticker),
}
}
// Reconfigure 更新同步系统配置。
func (a *App) Reconfigure(ctx context.Context, cfg Config) error {
_ = ctx
normalized := cfg.Normalize()
for _, target := range normalized.Targets {
if err := target.Validate(); err != nil {
return fmt.Errorf("validate target %s: %w", target.Kind, err)
}
}
a.mu.Lock()
a.config = normalized
a.mu.Unlock()
return nil
}
// Start 按当前配置启动自动同步调度。
func (a *App) Start(ctx context.Context) error {
targets := a.targetsSnapshot()
if err := a.verifyTargets(ctx, targets); err != nil {
return err
}
a.mu.Lock()
defer a.mu.Unlock()
a.stopSchedulersLocked()
for _, target := range targets {
if !target.Ready() || !target.Schedule.AutoSync || target.Schedule.Interval <= 0 {
continue
}
currentTargetID := target.Kind
task := scheduler.NewTicker()
task.Start(target.Schedule.Interval, func(runCtx context.Context) error {
_, err := a.Sync(runCtx, currentTargetID)
if err != nil && a.logger != nil {
a.logger.Error("sync auto run failed for target %s: %v", currentTargetID, err)
}
return err
})
a.schedulers[currentTargetID] = task
}
return nil
}
// Stop 停止所有自动同步调度。
func (a *App) Stop(ctx context.Context) error {
_ = ctx
a.mu.Lock()
defer a.mu.Unlock()
a.stopSchedulersLocked()
return nil
}
// Sync 执行指定目标的一次完整同步。
func (a *App) Sync(ctx context.Context, targetID string) (*SyncResult, error) {
target, err := a.currentTarget(targetID)
if err != nil {
return nil, err
}
if !target.Enabled {
return nil, ErrTargetDisabled
}
if !target.Ready() {
return nil, ErrTargetNotReady
}
backendInstance, err := a.newBackend(target)
if err != nil {
return nil, err
}
defer func() {
_ = backendInstance.Close()
}()
syncEngine := engine.NewSyncEngine(
backendInstance,
a.store,
a.snapshotter,
a.merger,
engine.Options{
Logger: a.logger,
MaxAttempts: a.maxSyncAttempts,
},
)
a.syncMu.Lock()
defer a.syncMu.Unlock()
result, err := syncEngine.Sync(ctx, engine.SyncOptions{
CommitMessage: a.commitMessage(target),
})
if err != nil {
return nil, err
}
return &SyncResult{
TargetID: target.Kind,
LocalChanged: result.LocalChanged,
RemoteChanged: result.RemoteChanged,
AppliedToLocal: result.AppliedToLocal,
Published: result.Published,
ConflictCount: result.ConflictCount,
Revision: result.Revision,
}, nil
}
// commitMessage 生成提交信息。
func (a *App) commitMessage(target TargetConfig) string {
return fmt.Sprintf("Sync %s %s", target.Kind, time.Now().Format(time.RFC3339))
}
// currentTarget 返回当前内存中的目标配置。
func (a *App) currentTarget(targetID string) (TargetConfig, error) {
a.mu.RLock()
defer a.mu.RUnlock()
return a.config.Target(targetID)
}
// targetsSnapshot 返回当前所有目标的快照。
func (a *App) targetsSnapshot() []TargetConfig {
a.mu.RLock()
defer a.mu.RUnlock()
targets := make([]TargetConfig, len(a.config.Targets))
copy(targets, a.config.Targets)
return targets
}
// verifyTargets 预先校验所有已就绪目标。
func (a *App) verifyTargets(ctx context.Context, targets []TargetConfig) error {
for _, target := range targets {
if !target.Ready() {
continue
}
backendInstance, err := a.newBackend(target)
if err != nil {
return err
}
verifyErr := backendInstance.Verify(ctx)
closeErr := backendInstance.Close()
if verifyErr != nil {
return fmt.Errorf("verify target %s: %w", target.Kind, verifyErr)
}
if closeErr != nil {
return fmt.Errorf("close target %s backend: %w", target.Kind, closeErr)
}
}
return nil
}
// newBackend 根据目标配置构造后端实例。
func (a *App) newBackend(target TargetConfig) (backend.Backend, error) {
switch target.Kind {
case TargetKindGit:
if target.Git == nil {
return nil, fmt.Errorf("target %s: git config is nil", target.Kind)
}
return gitbackend.New(gitbackend.Config{
RepoPath: target.Git.RepoPath,
RepoURL: target.Git.RepoURL,
Branch: target.Git.Branch,
RemoteName: target.Git.RemoteName,
AuthorName: fallbackString(target.Git.AuthorName, defaultAuthorName),
AuthorEmail: fallbackString(target.Git.AuthorEmail, defaultAuthorEmail),
Auth: gitbackend.AuthConfig{
Method: target.Git.Auth.Method,
Username: target.Git.Auth.Username,
Password: target.Git.Auth.Password,
Token: target.Git.Auth.Token,
SSHKeyPath: target.Git.Auth.SSHKeyPath,
SSHKeyPassword: target.Git.Auth.SSHKeyPassword,
},
})
case TargetKindLocalFS:
if target.LocalFS == nil {
return nil, fmt.Errorf("target %s: localfs config is nil", target.Kind)
}
store, err := localfsblob.New(target.LocalFS.RootPath)
if err != nil {
return nil, err
}
return snapshotstorebackend.New(snapshotstorebackend.Config{
Store: store,
Namespace: target.LocalFS.Namespace,
HeadKey: target.LocalFS.HeadKey,
})
default:
return nil, fmt.Errorf("%w: %s", ErrUnsupportedBackend, target.Kind)
}
}
// stopSchedulersLocked 停止所有调度器。
func (a *App) stopSchedulersLocked() {
for targetID, task := range a.schedulers {
task.Stop()
delete(a.schedulers, targetID)
}
}
// fallbackString 返回第一个非空字符串。
func fallbackString(value string, fallback string) string {
if value == "" {
return fallback
}
return value
}

View File

@@ -0,0 +1,31 @@
package backend
import (
"context"
"errors"
)
var (
// ErrRevisionConflict 表示远端版本已变化,需要重新拉取合并。
ErrRevisionConflict = errors.New("sync revision conflict")
)
// RemoteState 描述远端最新状态。
type RemoteState struct {
Revision string
Exists bool
}
// PublishOptions 描述一次发布操作的参数。
type PublishOptions struct {
ExpectedRevision string
Message string
}
// Backend 描述统一同步后端接口。
type Backend interface {
Verify(ctx context.Context) error
DownloadLatest(ctx context.Context, dst string) (RemoteState, error)
Upload(ctx context.Context, src string, options PublishOptions) (RemoteState, error)
Close() error
}

View File

@@ -0,0 +1,60 @@
package git
import (
"errors"
"fmt"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/http"
"github.com/go-git/go-git/v5/plumbing/transport/ssh"
)
// AuthConfig 描述 Git 鉴权方式。
type AuthConfig struct {
Method string
Username string
Password string
Token string
SSHKeyPath string
SSHKeyPassword string
}
const (
// AuthMethodToken 使用 Token 鉴权。
AuthMethodToken = "token"
// AuthMethodSSHKey 使用 SSH Key 鉴权。
AuthMethodSSHKey = "ssh_key"
// AuthMethodUserPass 使用用户名密码鉴权。
AuthMethodUserPass = "user_pass"
)
// authMethod 根据配置构造 go-git 鉴权实例。
func authMethod(config AuthConfig) (transport.AuthMethod, error) {
switch config.Method {
case AuthMethodToken:
if config.Token == "" {
return nil, errors.New("git token is required")
}
return &http.BasicAuth{
Username: "git",
Password: config.Token,
}, nil
case AuthMethodUserPass:
if config.Username == "" || config.Password == "" {
return nil, errors.New("git username and password are required")
}
return &http.BasicAuth{
Username: config.Username,
Password: config.Password,
}, nil
case AuthMethodSSHKey:
if config.SSHKeyPath == "" {
return nil, errors.New("git ssh key path is required")
}
return ssh.NewPublicKeysFromFile("git", config.SSHKeyPath, config.SSHKeyPassword)
case "":
return nil, nil
default:
return nil, fmt.Errorf("unsupported git auth method: %s", config.Method)
}
}

View File

@@ -0,0 +1,518 @@
package git
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"voidraft/internal/syncer/backend"
"github.com/go-git/go-git/v5"
gitconfig "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
)
const defaultGitIgnore = "*.tmp\n*.log\n"
// Config 描述 Git 后端配置。
type Config struct {
RepoPath string
RepoURL string
Branch string
RemoteName string
AuthorName string
AuthorEmail string
Auth AuthConfig
}
// Backend 提供基于 Git 的后端实现。
type Backend struct {
config Config
repository *git.Repository
}
// New 创建新的 Git 后端实例。
func New(config Config) (*Backend, error) {
normalized, err := normalizeConfig(config)
if err != nil {
return nil, err
}
return &Backend{config: normalized}, nil
}
// Verify 校验本地仓库和远端连接是否可用。
func (b *Backend) Verify(ctx context.Context) error {
_ = ctx
if err := b.ensureRepository(); err != nil {
return err
}
auth, err := authMethod(b.config.Auth)
if err != nil {
return err
}
remote, err := b.repository.Remote(b.config.RemoteName)
if err != nil {
return err
}
_, err = remote.List(&git.ListOptions{Auth: auth})
if err == nil {
return nil
}
if isEmptyRemoteError(err) {
return nil
}
return err
}
// DownloadLatest 拉取远端最新快照并导出到目标目录。
func (b *Backend) DownloadLatest(ctx context.Context, dst string) (backend.RemoteState, error) {
_ = ctx
if err := b.ensureRepository(); err != nil {
return backend.RemoteState{}, err
}
if err := recreateDir(dst); err != nil {
return backend.RemoteState{}, err
}
remoteState, err := b.fetchRemoteState()
if err != nil {
return backend.RemoteState{}, err
}
if !remoteState.Exists {
return remoteState, nil
}
if err := b.exportRemoteTree(remoteState.Revision, dst); err != nil {
return backend.RemoteState{}, err
}
return remoteState, nil
}
// Upload 将本地快照目录发布到远端 Git 仓库。
func (b *Backend) Upload(ctx context.Context, src string, options backend.PublishOptions) (backend.RemoteState, error) {
_ = ctx
if err := b.ensureRepository(); err != nil {
return backend.RemoteState{}, err
}
remoteState, err := b.fetchRemoteState()
if err != nil {
return backend.RemoteState{}, err
}
if options.ExpectedRevision != "" && remoteState.Exists && remoteState.Revision != options.ExpectedRevision {
return backend.RemoteState{}, backend.ErrRevisionConflict
}
if err := b.prepareBranch(remoteState); err != nil {
return backend.RemoteState{}, err
}
if err := syncDir(src, b.config.RepoPath); err != nil {
return backend.RemoteState{}, err
}
worktree, err := b.repository.Worktree()
if err != nil {
return backend.RemoteState{}, err
}
changed, err := stageAll(worktree)
if err != nil {
return backend.RemoteState{}, err
}
if !changed {
return b.currentLocalState()
}
if _, err := worktree.Commit(options.Message, &git.CommitOptions{
Author: &object.Signature{
Name: b.config.AuthorName,
Email: b.config.AuthorEmail,
When: time.Now(),
},
}); err != nil {
return backend.RemoteState{}, err
}
auth, err := authMethod(b.config.Auth)
if err != nil {
return backend.RemoteState{}, err
}
branchRef := plumbing.NewBranchReferenceName(b.config.Branch)
remoteRef := plumbing.NewRemoteReferenceName(b.config.RemoteName, b.config.Branch)
err = b.repository.Push(&git.PushOptions{
RemoteName: b.config.RemoteName,
Auth: auth,
RefSpecs: []gitconfig.RefSpec{
gitconfig.RefSpec(fmt.Sprintf("%s:%s", branchRef, remoteRef)),
},
})
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
if errors.Is(err, git.ErrNonFastForwardUpdate) {
return backend.RemoteState{}, backend.ErrRevisionConflict
}
return backend.RemoteState{}, err
}
return b.currentLocalState()
}
// Close 关闭后端。
func (b *Backend) Close() error {
return nil
}
// normalizeConfig 填充 Git 后端配置默认值。
func normalizeConfig(config Config) (Config, error) {
normalized := config
if strings.TrimSpace(normalized.RepoPath) == "" {
return Config{}, errors.New("git repo path is required")
}
if strings.TrimSpace(normalized.Branch) == "" {
normalized.Branch = "master"
}
if strings.TrimSpace(normalized.RemoteName) == "" {
normalized.RemoteName = "origin"
}
if strings.TrimSpace(normalized.AuthorName) == "" {
normalized.AuthorName = "voidraft"
}
if strings.TrimSpace(normalized.AuthorEmail) == "" {
normalized.AuthorEmail = "sync@voidraft.app"
}
return normalized, nil
}
// ensureRepository 确保本地 Git 仓库存在且远端配置正确。
func (b *Backend) ensureRepository() error {
if b.repository != nil {
return b.ensureRemote()
}
if err := os.MkdirAll(b.config.RepoPath, 0755); err != nil {
return fmt.Errorf("create git repo dir: %w", err)
}
gitPath := filepath.Join(b.config.RepoPath, ".git")
if _, err := os.Stat(gitPath); os.IsNotExist(err) {
repository, initErr := git.PlainInit(b.config.RepoPath, false)
if initErr != nil {
return fmt.Errorf("init git repo: %w", initErr)
}
b.repository = repository
if err := ensureGitIgnore(b.config.RepoPath); err != nil {
return err
}
return b.ensureRemote()
} else if err != nil {
return fmt.Errorf("stat git repo: %w", err)
}
repository, err := git.PlainOpen(b.config.RepoPath)
if err != nil {
return fmt.Errorf("open git repo: %w", err)
}
b.repository = repository
if err := ensureGitIgnore(b.config.RepoPath); err != nil {
return err
}
return b.ensureRemote()
}
// ensureRemote 确保远端配置与当前目标一致。
func (b *Backend) ensureRemote() error {
if strings.TrimSpace(b.config.RepoURL) == "" {
return nil
}
remote, err := b.repository.Remote(b.config.RemoteName)
if errors.Is(err, git.ErrRemoteNotFound) {
_, err = b.repository.CreateRemote(&gitconfig.RemoteConfig{
Name: b.config.RemoteName,
URLs: []string{b.config.RepoURL},
})
return err
}
if err != nil {
return err
}
if len(remote.Config().URLs) > 0 && remote.Config().URLs[0] == b.config.RepoURL {
return nil
}
if err := b.repository.DeleteRemote(b.config.RemoteName); err != nil {
return err
}
_, err = b.repository.CreateRemote(&gitconfig.RemoteConfig{
Name: b.config.RemoteName,
URLs: []string{b.config.RepoURL},
})
return err
}
// fetchRemoteState 拉取远端分支并返回最新状态。
func (b *Backend) fetchRemoteState() (backend.RemoteState, error) {
auth, err := authMethod(b.config.Auth)
if err != nil {
return backend.RemoteState{}, err
}
err = b.repository.Fetch(&git.FetchOptions{
RemoteName: b.config.RemoteName,
Auth: auth,
Force: true,
})
if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) {
if isEmptyRemoteError(err) || isMissingRemoteRefError(err) {
return backend.RemoteState{}, nil
}
return backend.RemoteState{}, err
}
ref, err := b.repository.Reference(plumbing.NewRemoteReferenceName(b.config.RemoteName, b.config.Branch), true)
if err != nil {
if errors.Is(err, plumbing.ErrReferenceNotFound) {
return backend.RemoteState{}, nil
}
return backend.RemoteState{}, err
}
return backend.RemoteState{
Exists: true,
Revision: ref.Hash().String(),
}, nil
}
// exportRemoteTree 将指定提交的树内容导出为普通文件。
func (b *Backend) exportRemoteTree(revision string, dst string) error {
commit, err := b.repository.CommitObject(plumbing.NewHash(revision))
if err != nil {
return err
}
tree, err := commit.Tree()
if err != nil {
return err
}
return tree.Files().ForEach(func(file *object.File) error {
targetPath := filepath.Join(dst, filepath.FromSlash(file.Name))
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
reader, err := file.Reader()
if err != nil {
return err
}
defer reader.Close()
writer, err := os.Create(targetPath)
if err != nil {
return err
}
defer writer.Close()
_, err = io.Copy(writer, reader)
return err
})
}
// prepareBranch 将本地分支重置到远端最新版本。
func (b *Backend) prepareBranch(remoteState backend.RemoteState) error {
branchRef := plumbing.NewBranchReferenceName(b.config.Branch)
if remoteState.Exists {
if err := b.repository.Storer.SetReference(plumbing.NewHashReference(branchRef, plumbing.NewHash(remoteState.Revision))); err != nil {
return err
}
}
if err := b.repository.Storer.SetReference(plumbing.NewSymbolicReference(plumbing.HEAD, branchRef)); err != nil {
return err
}
if !remoteState.Exists {
return nil
}
worktree, err := b.repository.Worktree()
if err != nil {
return err
}
return worktree.Checkout(&git.CheckoutOptions{
Branch: branchRef,
Force: true,
})
}
// currentLocalState 返回当前本地 HEAD 状态。
func (b *Backend) currentLocalState() (backend.RemoteState, error) {
head, err := b.repository.Head()
if err != nil {
if errors.Is(err, plumbing.ErrReferenceNotFound) {
return backend.RemoteState{}, nil
}
return backend.RemoteState{}, err
}
return backend.RemoteState{
Exists: true,
Revision: head.Hash().String(),
}, nil
}
// ensureGitIgnore 保证仓库目录中存在默认 .gitignore。
func ensureGitIgnore(repoPath string) error {
gitIgnorePath := filepath.Join(repoPath, ".gitignore")
if _, err := os.Stat(gitIgnorePath); err == nil {
return nil
} else if !os.IsNotExist(err) {
return err
}
return os.WriteFile(gitIgnorePath, []byte(defaultGitIgnore), 0644)
}
// recreateDir 清空并重建目录。
func recreateDir(dir string) error {
if err := os.RemoveAll(dir); err != nil {
return err
}
return os.MkdirAll(dir, 0755)
}
// syncDir 将源目录内容同步到目标目录。
func syncDir(src string, dst string) error {
sourceEntries, err := os.ReadDir(src)
if err != nil {
return err
}
if err := os.MkdirAll(dst, 0755); err != nil {
return err
}
sourceIndex := make(map[string]os.DirEntry, len(sourceEntries))
for _, entry := range sourceEntries {
sourceIndex[entry.Name()] = entry
srcPath := filepath.Join(src, entry.Name())
dstPath := filepath.Join(dst, entry.Name())
if entry.IsDir() {
if err := syncDir(srcPath, dstPath); err != nil {
return err
}
continue
}
if err := copyFile(srcPath, dstPath); err != nil {
return err
}
}
targetEntries, err := os.ReadDir(dst)
if err != nil {
return err
}
for _, entry := range targetEntries {
if entry.Name() == ".git" || entry.Name() == ".gitignore" {
continue
}
if _, exists := sourceIndex[entry.Name()]; exists {
continue
}
if err := os.RemoveAll(filepath.Join(dst, entry.Name())); err != nil {
return err
}
}
return nil
}
// copyFile 复制单个文件并保留权限位。
func copyFile(src string, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
info, err := sourceFile.Stat()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
targetFile, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, info.Mode().Perm())
if err != nil {
return err
}
defer targetFile.Close()
_, err = io.Copy(targetFile, sourceFile)
return err
}
// stageAll 将工作区所有变化加入索引。
func stageAll(worktree *git.Worktree) (bool, error) {
status, err := worktree.Status()
if err != nil {
return false, err
}
for path, fileStatus := range status {
switch fileStatus.Worktree {
case git.Untracked, git.Modified, git.Added, git.Copied, git.Renamed:
if _, err := worktree.Add(path); err != nil {
return false, err
}
case git.Deleted:
if _, err := worktree.Remove(path); err != nil && !os.IsNotExist(err) {
return false, err
}
}
if fileStatus.Staging == git.Deleted && fileStatus.Worktree == git.Unmodified {
if _, err := worktree.Remove(path); err != nil && !os.IsNotExist(err) {
return false, err
}
}
}
status, err = worktree.Status()
if err != nil {
return false, err
}
return !status.IsClean(), nil
}
// isEmptyRemoteError 判断错误是否表示远端仓库为空。
func isEmptyRemoteError(err error) bool {
if err == nil {
return false
}
message := err.Error()
return strings.Contains(message, "empty") || strings.Contains(message, "no reference")
}
// isMissingRemoteRefError 判断错误是否表示远端分支不存在。
func isMissingRemoteRefError(err error) bool {
if err == nil {
return false
}
message := err.Error()
return strings.Contains(message, "reference not found") || strings.Contains(message, "couldn't find remote ref")
}

View File

@@ -0,0 +1,413 @@
package snapshotstore
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
"voidraft/internal/syncer/backend"
"voidraft/internal/syncer/backend/snapshotstore/blob"
)
const (
defaultNamespace = "sync"
defaultHeadKey = "head.json"
bundleDirName = "bundles"
)
var stableBundleTime = time.Unix(0, 0).UTC()
// Config 描述 snapshot_store 后端配置。
type Config struct {
Store blob.Store
Namespace string
HeadKey string
}
type headDocument struct {
Revision string `json:"revision"`
BundleKey string `json:"bundle_key"`
UpdatedAt string `json:"updated_at"`
}
type headState struct {
Document headDocument
Info blob.ObjectInfo
}
// Backend 提供基于对象/文件存储的快照后端实现。
type Backend struct {
config Config
}
// New 创建新的 snapshot_store 后端。
func New(config Config) (*Backend, error) {
if config.Store == nil {
return nil, errors.New("snapshot store blob backend is required")
}
if strings.TrimSpace(config.Namespace) == "" {
config.Namespace = defaultNamespace
}
if strings.TrimSpace(config.HeadKey) == "" {
config.HeadKey = defaultHeadKey
}
return &Backend{config: config}, nil
}
// Verify 校验后端是否可读。
func (b *Backend) Verify(ctx context.Context) error {
_, _, err := b.readHead(ctx)
return err
}
// DownloadLatest 下载远端最新快照包并解压到目标目录。
func (b *Backend) DownloadLatest(ctx context.Context, dst string) (backend.RemoteState, error) {
head, exists, err := b.readHead(ctx)
if err != nil {
return backend.RemoteState{}, err
}
if !exists {
return backend.RemoteState{}, nil
}
reader, _, err := b.config.Store.Get(ctx, head.Document.BundleKey)
if err != nil {
if errors.Is(err, blob.ErrObjectNotFound) {
return backend.RemoteState{}, nil
}
return backend.RemoteState{}, err
}
defer reader.Close()
if err := recreateDir(dst); err != nil {
return backend.RemoteState{}, err
}
if err := extractBundle(reader, dst); err != nil {
return backend.RemoteState{}, err
}
return backend.RemoteState{
Exists: true,
Revision: head.Document.Revision,
}, nil
}
// Upload 打包并发布本地快照目录。
func (b *Backend) Upload(ctx context.Context, src string, options backend.PublishOptions) (backend.RemoteState, error) {
currentHead, exists, err := b.readHead(ctx)
if err != nil {
return backend.RemoteState{}, err
}
switch {
case options.ExpectedRevision != "" && !exists:
return backend.RemoteState{}, backend.ErrRevisionConflict
case options.ExpectedRevision != "" && currentHead.Document.Revision != options.ExpectedRevision:
return backend.RemoteState{}, backend.ErrRevisionConflict
}
bundlePath, revision, err := createBundle(src)
if err != nil {
return backend.RemoteState{}, err
}
defer os.Remove(bundlePath)
if exists && currentHead.Document.Revision == revision {
return backend.RemoteState{
Exists: true,
Revision: revision,
}, nil
}
bundleKey := b.bundleKey(revision)
file, err := os.Open(bundlePath)
if err != nil {
return backend.RemoteState{}, err
}
defer file.Close()
if _, err := b.config.Store.Put(ctx, bundleKey, file, blob.PutOptions{}); err != nil {
return backend.RemoteState{}, err
}
nextHead := headDocument{
Revision: revision,
BundleKey: bundleKey,
UpdatedAt: time.Now().Format(time.RFC3339),
}
headPayload, err := json.MarshalIndent(nextHead, "", " ")
if err != nil {
return backend.RemoteState{}, err
}
headPayload = append(headPayload, '\n')
putOptions := blob.PutOptions{}
if exists {
putOptions.IfMatch = currentHead.Info.Revision
}
if _, err := b.config.Store.Put(ctx, b.headKey(), bytes.NewReader(headPayload), putOptions); err != nil {
if errors.Is(err, blob.ErrConditionNotMet) {
return backend.RemoteState{}, backend.ErrRevisionConflict
}
return backend.RemoteState{}, err
}
return backend.RemoteState{
Exists: true,
Revision: revision,
}, nil
}
// Close 关闭后端。
func (b *Backend) Close() error {
return nil
}
// readHead 读取远端 head 指针。
func (b *Backend) readHead(ctx context.Context) (headState, bool, error) {
reader, info, err := b.config.Store.Get(ctx, b.headKey())
if err != nil {
if errors.Is(err, blob.ErrObjectNotFound) {
return headState{}, false, nil
}
return headState{}, false, err
}
defer reader.Close()
data, err := io.ReadAll(reader)
if err != nil {
return headState{}, false, err
}
var document headDocument
if err := json.Unmarshal(data, &document); err != nil {
return headState{}, false, err
}
if document.Revision == "" || document.BundleKey == "" {
return headState{}, false, errors.New("snapshot store head is invalid")
}
return headState{
Document: document,
Info: info,
}, true, nil
}
// headKey 返回完整的 head 对象键。
func (b *Backend) headKey() string {
return path.Join(b.config.Namespace, b.config.HeadKey)
}
// bundleKey 返回 revision 对应的 bundle 键。
func (b *Backend) bundleKey(revision string) string {
return path.Join(b.config.Namespace, bundleDirName, revision+".tar.gz")
}
// createBundle 将目录稳定打包成 tar.gz并返回文件路径与摘要。
func createBundle(root string) (string, string, error) {
tempFile, err := os.CreateTemp("", "voidraft-snapshot-*.tar.gz")
if err != nil {
return "", "", err
}
tempName := tempFile.Name()
hasher := sha256.New()
multiWriter := io.MultiWriter(tempFile, hasher)
gzipWriter := gzip.NewWriter(multiWriter)
gzipWriter.ModTime = stableBundleTime
gzipWriter.Name = ""
gzipWriter.Comment = ""
tarWriter := tar.NewWriter(gzipWriter)
writeErr := writeBundle(root, tarWriter)
closeErr := tarWriter.Close()
gzipCloseErr := gzipWriter.Close()
fileCloseErr := tempFile.Close()
if writeErr != nil {
_ = os.Remove(tempName)
return "", "", writeErr
}
if closeErr != nil {
_ = os.Remove(tempName)
return "", "", closeErr
}
if gzipCloseErr != nil {
_ = os.Remove(tempName)
return "", "", gzipCloseErr
}
if fileCloseErr != nil {
_ = os.Remove(tempName)
return "", "", fileCloseErr
}
revision := hex.EncodeToString(hasher.Sum(nil))
return tempName, revision, nil
}
// writeBundle 将目录内容按稳定顺序写入 tar。
func writeBundle(root string, writer *tar.Writer) error {
paths, err := collectPaths(root)
if err != nil {
return err
}
for _, entryPath := range paths {
info, err := os.Lstat(entryPath)
if err != nil {
return err
}
relativePath, err := filepath.Rel(root, entryPath)
if err != nil {
return err
}
header, err := tar.FileInfoHeader(info, "")
if err != nil {
return err
}
header.Name = filepath.ToSlash(relativePath)
header.ModTime = stableBundleTime
header.AccessTime = stableBundleTime
header.ChangeTime = stableBundleTime
header.Uid = 0
header.Gid = 0
header.Uname = ""
header.Gname = ""
if info.IsDir() && !strings.HasSuffix(header.Name, "/") {
header.Name += "/"
}
if err := writer.WriteHeader(header); err != nil {
return err
}
if info.IsDir() {
continue
}
file, err := os.Open(entryPath)
if err != nil {
return err
}
if _, err := io.Copy(writer, file); err != nil {
file.Close()
return err
}
if err := file.Close(); err != nil {
return err
}
}
return nil
}
// collectPaths 返回稳定排序后的目录项列表。
func collectPaths(root string) ([]string, error) {
entries := make([]string, 0)
if err := filepath.WalkDir(root, func(entryPath string, entry os.DirEntry, err error) error {
if err != nil {
return err
}
if entryPath == root {
return nil
}
entries = append(entries, entryPath)
return nil
}); err != nil {
return nil, err
}
sort.Strings(entries)
return entries, nil
}
// extractBundle 将 tar.gz 包解压到目标目录。
func extractBundle(reader io.Reader, dst string) error {
gzipReader, err := gzip.NewReader(reader)
if err != nil {
return err
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
targetPath, err := resolveExtractPath(dst, header.Name)
if err != nil {
return err
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(targetPath, 0755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return err
}
file, err := os.OpenFile(targetPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(header.Mode))
if err != nil {
return err
}
if _, err := io.Copy(file, tarReader); err != nil {
file.Close()
return err
}
if err := file.Close(); err != nil {
return err
}
default:
return fmt.Errorf("unsupported tar entry type: %d", header.Typeflag)
}
}
}
// recreateDir 清空并重建目录。
func recreateDir(dir string) error {
if err := os.RemoveAll(dir); err != nil {
return err
}
return os.MkdirAll(dir, 0755)
}
// resolveExtractPath 将归档路径安全映射到目标目录。
func resolveExtractPath(root string, name string) (string, error) {
clean := filepath.Clean(filepath.FromSlash(name))
if clean == "." {
return "", errors.New("invalid archive entry")
}
targetPath := filepath.Join(root, clean)
relativePath, err := filepath.Rel(root, targetPath)
if err != nil {
return "", err
}
if strings.HasPrefix(relativePath, "..") {
return "", errors.New("archive entry escapes target directory")
}
return targetPath, nil
}

View File

@@ -0,0 +1,109 @@
package snapshotstore
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"voidraft/internal/syncer/backend"
localfsblob "voidraft/internal/syncer/backend/snapshotstore/blob/localfs"
)
// TestBackendUploadDownload 验证 snapshot_store 后端可以发布并回放快照包。
func TestBackendUploadDownload(t *testing.T) {
store, err := localfsblob.New(t.TempDir())
if err != nil {
t.Fatalf("create blob store: %v", err)
}
backendInstance, err := New(Config{
Store: store,
Namespace: "tests",
})
if err != nil {
t.Fatalf("create backend: %v", err)
}
sourceDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(sourceDir, "documents"), 0755); err != nil {
t.Fatalf("mkdir source dir: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "documents", "doc-1.json"), []byte("{\"title\":\"v1\"}\n"), 0644); err != nil {
t.Fatalf("write source file: %v", err)
}
firstState, err := backendInstance.Upload(context.Background(), sourceDir, backend.PublishOptions{})
if err != nil {
t.Fatalf("upload snapshot: %v", err)
}
if !firstState.Exists || firstState.Revision == "" {
t.Fatalf("expected remote state after first upload")
}
downloadDir := t.TempDir()
downloadState, err := backendInstance.DownloadLatest(context.Background(), downloadDir)
if err != nil {
t.Fatalf("download latest snapshot: %v", err)
}
if downloadState.Revision != firstState.Revision {
t.Fatalf("expected revision %s, got %s", firstState.Revision, downloadState.Revision)
}
data, err := os.ReadFile(filepath.Join(downloadDir, "documents", "doc-1.json"))
if err != nil {
t.Fatalf("read downloaded file: %v", err)
}
if string(data) != "{\"title\":\"v1\"}\n" {
t.Fatalf("unexpected downloaded content: %s", string(data))
}
}
// TestBackendRevisionConflict 验证 snapshot_store 后端会在版本过期时返回冲突。
func TestBackendRevisionConflict(t *testing.T) {
store, err := localfsblob.New(t.TempDir())
if err != nil {
t.Fatalf("create blob store: %v", err)
}
backendInstance, err := New(Config{
Store: store,
Namespace: "tests",
})
if err != nil {
t.Fatalf("create backend: %v", err)
}
sourceDir := t.TempDir()
if err := os.WriteFile(filepath.Join(sourceDir, "state.json"), []byte("{\"value\":1}\n"), 0644); err != nil {
t.Fatalf("write source file: %v", err)
}
firstState, err := backendInstance.Upload(context.Background(), sourceDir, backend.PublishOptions{})
if err != nil {
t.Fatalf("upload first snapshot: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "state.json"), []byte("{\"value\":2}\n"), 0644); err != nil {
t.Fatalf("rewrite source file: %v", err)
}
secondState, err := backendInstance.Upload(context.Background(), sourceDir, backend.PublishOptions{
ExpectedRevision: firstState.Revision,
})
if err != nil {
t.Fatalf("upload second snapshot: %v", err)
}
if err := os.WriteFile(filepath.Join(sourceDir, "state.json"), []byte("{\"value\":3}\n"), 0644); err != nil {
t.Fatalf("rewrite source file again: %v", err)
}
_, err = backendInstance.Upload(context.Background(), sourceDir, backend.PublishOptions{
ExpectedRevision: firstState.Revision,
})
if !errors.Is(err, backend.ErrRevisionConflict) {
t.Fatalf("expected ErrRevisionConflict, got %v", err)
}
if secondState.Revision == firstState.Revision {
t.Fatalf("expected revision to change after second upload")
}
}

View File

@@ -0,0 +1,34 @@
package blob
import (
"context"
"errors"
"io"
)
var (
// ErrObjectNotFound 表示对象不存在。
ErrObjectNotFound = errors.New("blob object not found")
// ErrConditionNotMet 表示条件写入失败。
ErrConditionNotMet = errors.New("blob condition not met")
)
// ObjectInfo 描述一个对象的元信息。
type ObjectInfo struct {
Key string
Revision string
Size int64
}
// PutOptions 描述对象写入条件。
type PutOptions struct {
IfMatch string
}
// Store 描述 blob 存储的最小能力集。
type Store interface {
Get(ctx context.Context, key string) (io.ReadCloser, ObjectInfo, error)
Put(ctx context.Context, key string, body io.Reader, options PutOptions) (ObjectInfo, error)
Stat(ctx context.Context, key string) (ObjectInfo, error)
Delete(ctx context.Context, key string) error
}

View File

@@ -0,0 +1,182 @@
package localfs
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"voidraft/internal/syncer/backend/snapshotstore/blob"
)
// Store 提供基于本地目录的 blob 存储实现。
type Store struct {
rootPath string
}
// New 创建新的 localfs blob 存储。
func New(rootPath string) (*Store, error) {
if strings.TrimSpace(rootPath) == "" {
return nil, errors.New("localfs root path is required")
}
if err := os.MkdirAll(rootPath, 0755); err != nil {
return nil, fmt.Errorf("create localfs root path: %w", err)
}
return &Store{rootPath: rootPath}, nil
}
// Get 读取对象内容。
func (s *Store) Get(ctx context.Context, key string) (io.ReadCloser, blob.ObjectInfo, error) {
_ = ctx
info, err := s.Stat(ctx, key)
if err != nil {
return nil, blob.ObjectInfo{}, err
}
path, err := s.resolvePath(key)
if err != nil {
return nil, blob.ObjectInfo{}, err
}
reader, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return nil, blob.ObjectInfo{}, blob.ErrObjectNotFound
}
return nil, blob.ObjectInfo{}, err
}
return reader, info, nil
}
// Put 写入对象内容。
func (s *Store) Put(ctx context.Context, key string, body io.Reader, options blob.PutOptions) (blob.ObjectInfo, error) {
_ = ctx
path, err := s.resolvePath(key)
if err != nil {
return blob.ObjectInfo{}, err
}
if options.IfMatch != "" {
currentInfo, err := s.Stat(ctx, key)
if err != nil {
if errors.Is(err, blob.ErrObjectNotFound) {
return blob.ObjectInfo{}, blob.ErrConditionNotMet
}
return blob.ObjectInfo{}, err
}
if currentInfo.Revision != options.IfMatch {
return blob.ObjectInfo{}, blob.ErrConditionNotMet
}
}
data, err := io.ReadAll(body)
if err != nil {
return blob.ObjectInfo{}, err
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return blob.ObjectInfo{}, err
}
tempFile, err := os.CreateTemp(filepath.Dir(path), "blob-put-*")
if err != nil {
return blob.ObjectInfo{}, err
}
tempName := tempFile.Name()
if _, err := tempFile.Write(data); err != nil {
tempFile.Close()
_ = os.Remove(tempName)
return blob.ObjectInfo{}, err
}
if err := tempFile.Close(); err != nil {
_ = os.Remove(tempName)
return blob.ObjectInfo{}, err
}
if err := os.Rename(tempName, path); err != nil {
_ = os.Remove(tempName)
return blob.ObjectInfo{}, err
}
return blob.ObjectInfo{
Key: key,
Revision: digest(data),
Size: int64(len(data)),
}, nil
}
// Stat 返回对象元信息。
func (s *Store) Stat(ctx context.Context, key string) (blob.ObjectInfo, error) {
_ = ctx
path, err := s.resolvePath(key)
if err != nil {
return blob.ObjectInfo{}, err
}
file, err := os.Open(path)
if err != nil {
if os.IsNotExist(err) {
return blob.ObjectInfo{}, blob.ErrObjectNotFound
}
return blob.ObjectInfo{}, err
}
defer file.Close()
hash := sha256.New()
size, err := io.Copy(hash, file)
if err != nil {
return blob.ObjectInfo{}, err
}
return blob.ObjectInfo{
Key: key,
Revision: hex.EncodeToString(hash.Sum(nil)),
Size: size,
}, nil
}
// Delete 删除指定对象。
func (s *Store) Delete(ctx context.Context, key string) error {
_ = ctx
path, err := s.resolvePath(key)
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// resolvePath 将对象键转换为安全路径。
func (s *Store) resolvePath(key string) (string, error) {
normalized := filepath.Clean(filepath.FromSlash(key))
if normalized == "." || normalized == string(filepath.Separator) {
return "", errors.New("invalid blob key")
}
path := filepath.Join(s.rootPath, normalized)
rel, err := filepath.Rel(s.rootPath, path)
if err != nil {
return "", err
}
if strings.HasPrefix(rel, "..") {
return "", errors.New("blob key escapes root path")
}
return path, nil
}
// digest 计算内容摘要。
func digest(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,73 @@
package localfs
import (
"bytes"
"context"
"errors"
"io"
"testing"
"voidraft/internal/syncer/backend/snapshotstore/blob"
)
// TestStorePutGetStat 验证 localfs blob 存储的基本读写流程。
func TestStorePutGetStat(t *testing.T) {
store, err := New(t.TempDir())
if err != nil {
t.Fatalf("create store: %v", err)
}
info, err := store.Put(context.Background(), "nested/file.txt", bytes.NewReader([]byte("hello")), blob.PutOptions{})
if err != nil {
t.Fatalf("put object: %v", err)
}
if info.Revision == "" {
t.Fatalf("expected revision to be generated")
}
stat, err := store.Stat(context.Background(), "nested/file.txt")
if err != nil {
t.Fatalf("stat object: %v", err)
}
if stat.Revision != info.Revision {
t.Fatalf("expected stat revision %s, got %s", info.Revision, stat.Revision)
}
reader, _, err := store.Get(context.Background(), "nested/file.txt")
if err != nil {
t.Fatalf("get object: %v", err)
}
defer reader.Close()
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("read object: %v", err)
}
if string(data) != "hello" {
t.Fatalf("expected object content hello, got %s", string(data))
}
}
// TestStorePutIfMatch 验证 localfs blob 存储的条件写入。
func TestStorePutIfMatch(t *testing.T) {
store, err := New(t.TempDir())
if err != nil {
t.Fatalf("create store: %v", err)
}
info, err := store.Put(context.Background(), "file.txt", bytes.NewReader([]byte("v1")), blob.PutOptions{})
if err != nil {
t.Fatalf("put initial object: %v", err)
}
if _, err := store.Put(context.Background(), "file.txt", bytes.NewReader([]byte("v2")), blob.PutOptions{IfMatch: "stale"}); !errors.Is(err, blob.ErrConditionNotMet) {
t.Fatalf("expected ErrConditionNotMet, got %v", err)
}
nextInfo, err := store.Put(context.Background(), "file.txt", bytes.NewReader([]byte("v2")), blob.PutOptions{IfMatch: info.Revision})
if err != nil {
t.Fatalf("put with correct if-match: %v", err)
}
if nextInfo.Revision == info.Revision {
t.Fatalf("expected revision to change after overwrite")
}
}

173
internal/syncer/config.go Normal file
View File

@@ -0,0 +1,173 @@
package syncer
import (
"errors"
"fmt"
"strings"
"time"
)
const (
// DefaultBranch 是默认 Git 分支名。
DefaultBranch = "master"
// DefaultRemoteName 是默认 Git 远端名。
DefaultRemoteName = "origin"
// DefaultHeadKey 是默认同步头文件名。
DefaultHeadKey = "head.json"
)
const (
// TargetKindGit 表示 Git 同步目标。
TargetKindGit = "git"
// TargetKindLocalFS 表示本地文件系统同步目标。
TargetKindLocalFS = "localfs"
)
// Config 描述整个同步系统的运行配置。
type Config struct {
Targets []TargetConfig
}
// TargetConfig 描述单个同步目标的配置。
type TargetConfig struct {
Kind string
Enabled bool
Schedule ScheduleConfig
Git *GitTargetConfig
LocalFS *LocalFSTargetConfig
}
// ScheduleConfig 描述自动同步调度配置。
type ScheduleConfig struct {
AutoSync bool
Interval time.Duration
}
// GitTargetConfig 描述 Git 同步目标配置。
type GitTargetConfig struct {
RepoPath string
RepoURL string
Branch string
RemoteName string
AuthorName string
AuthorEmail string
Auth GitAuthConfig
}
// GitAuthConfig 描述 Git 鉴权配置。
type GitAuthConfig struct {
Method string
Username string
Password string
Token string
SSHKeyPath string
SSHKeyPassword string
}
// LocalFSTargetConfig 描述本地文件系统同步目标配置。
type LocalFSTargetConfig struct {
Namespace string
HeadKey string
RootPath string
}
// Normalize 返回带默认值的配置副本。
func (c Config) Normalize() Config {
if len(c.Targets) == 0 {
return Config{}
}
targets := make([]TargetConfig, 0, len(c.Targets))
for _, target := range c.Targets {
targets = append(targets, target.Normalize())
}
return Config{Targets: targets}
}
// Target 返回指定 kind 的目标配置。
func (c Config) Target(targetKind string) (TargetConfig, error) {
for _, target := range c.Targets {
if target.Kind == targetKind {
return target, nil
}
}
return TargetConfig{}, fmt.Errorf("%w: %s", ErrTargetNotFound, targetKind)
}
// Normalize 返回带默认值的目标配置副本。
func (t TargetConfig) Normalize() TargetConfig {
target := t
if target.Kind == "" {
target.Kind = TargetKindGit
}
if target.Schedule.Interval < 0 {
target.Schedule.Interval = 0
}
if target.Kind == TargetKindGit && target.Git != nil {
gitConfig := *target.Git
if strings.TrimSpace(gitConfig.Branch) == "" {
gitConfig.Branch = DefaultBranch
}
if strings.TrimSpace(gitConfig.RemoteName) == "" {
gitConfig.RemoteName = DefaultRemoteName
}
target.Git = &gitConfig
}
if target.Kind == TargetKindLocalFS && target.LocalFS != nil {
storeConfig := *target.LocalFS
if strings.TrimSpace(storeConfig.Namespace) == "" {
storeConfig.Namespace = target.Kind
}
if strings.TrimSpace(storeConfig.HeadKey) == "" {
storeConfig.HeadKey = DefaultHeadKey
}
target.LocalFS = &storeConfig
}
return target
}
// Validate 校验目标配置。
func (t TargetConfig) Validate() error {
switch t.Kind {
case TargetKindGit:
if t.Git == nil {
return errors.New("git target config is required")
}
if strings.TrimSpace(t.Git.RepoPath) == "" {
return errors.New("git repo path is required")
}
case TargetKindLocalFS:
if t.LocalFS == nil {
return errors.New("localfs target config is required")
}
if strings.TrimSpace(t.LocalFS.RootPath) == "" {
return errors.New("localfs root path is required")
}
default:
return fmt.Errorf("%w: %s", ErrUnsupportedBackend, t.Kind)
}
return nil
}
// Ready 判断目标是否具备执行同步的必要信息。
func (t TargetConfig) Ready() bool {
if !t.Enabled {
return false
}
switch t.Kind {
case TargetKindGit:
if t.Git == nil {
return false
}
return strings.TrimSpace(t.Git.RepoPath) != "" && strings.TrimSpace(t.Git.RepoURL) != ""
case TargetKindLocalFS:
if t.LocalFS == nil {
return false
}
return strings.TrimSpace(t.LocalFS.RootPath) != ""
default:
return false
}
}

View File

@@ -0,0 +1,184 @@
package engine
import (
"context"
"errors"
"fmt"
"os"
"voidraft/internal/syncer/backend"
"voidraft/internal/syncer/merge"
"voidraft/internal/syncer/snapshot"
)
const defaultMaxAttempts = 3
// Logger 描述同步引擎依赖的最小日志接口。
type Logger interface {
Debug(message string, args ...interface{})
Info(message string, args ...interface{})
Warning(message string, args ...interface{})
Error(message string, args ...interface{})
}
// Options 描述同步引擎构造选项。
type Options struct {
Logger Logger
MaxAttempts int
}
// SyncOptions 描述一次同步执行参数。
type SyncOptions struct {
CommitMessage string
}
// Result 描述同步引擎执行结果。
type Result struct {
LocalChanged bool
RemoteChanged bool
AppliedToLocal bool
Published bool
ConflictCount int
Revision string
}
// SyncEngine 负责执行一次完整的同步闭环。
type SyncEngine struct {
backend backend.Backend
store snapshot.Store
snapshotter snapshot.Snapshotter
merger merge.Merger
logger Logger
maxAttempts int
}
// NewSyncEngine 创建新的同步引擎实例。
func NewSyncEngine(
backendInstance backend.Backend,
store snapshot.Store,
snapshotter snapshot.Snapshotter,
merger merge.Merger,
options Options,
) *SyncEngine {
maxAttempts := options.MaxAttempts
if maxAttempts <= 0 {
maxAttempts = defaultMaxAttempts
}
return &SyncEngine{
backend: backendInstance,
store: store,
snapshotter: snapshotter,
merger: merger,
logger: options.Logger,
maxAttempts: maxAttempts,
}
}
// Sync 执行同步,并在远端版本竞争时自动重试。
func (e *SyncEngine) Sync(ctx context.Context, options SyncOptions) (*Result, error) {
var lastErr error
for attempt := 1; attempt <= e.maxAttempts; attempt++ {
result, retry, err := e.syncOnce(ctx, options)
if err == nil {
return result, nil
}
if retry && errors.Is(err, backend.ErrRevisionConflict) {
lastErr = err
if e.logger != nil {
e.logger.Warning("sync retry after revision conflict, attempt %d/%d", attempt, e.maxAttempts)
}
continue
}
return nil, err
}
if lastErr == nil {
lastErr = backend.ErrRevisionConflict
}
return nil, lastErr
}
// syncOnce 执行一次同步尝试。
func (e *SyncEngine) syncOnce(ctx context.Context, options SyncOptions) (*Result, bool, error) {
localSnapshot, err := e.snapshotter.Export(ctx)
if err != nil {
return nil, false, fmt.Errorf("export local snapshot: %w", err)
}
localDigest, err := snapshot.Digest(localSnapshot)
if err != nil {
return nil, false, fmt.Errorf("digest local snapshot: %w", err)
}
remoteDir, err := os.MkdirTemp("", "voidraft-sync-remote-*")
if err != nil {
return nil, false, err
}
defer os.RemoveAll(remoteDir)
remoteState, err := e.backend.DownloadLatest(ctx, remoteDir)
if err != nil {
return nil, false, fmt.Errorf("download remote snapshot: %w", err)
}
remoteSnapshot := snapshot.New()
if remoteState.Exists {
remoteSnapshot, err = e.store.Read(ctx, remoteDir)
if err != nil {
return nil, false, fmt.Errorf("read remote snapshot: %w", err)
}
}
remoteDigest, err := snapshot.Digest(remoteSnapshot)
if err != nil {
return nil, false, fmt.Errorf("digest remote snapshot: %w", err)
}
mergedSnapshot, report, err := e.merger.Merge(ctx, localSnapshot, remoteSnapshot)
if err != nil {
return nil, false, fmt.Errorf("merge snapshot: %w", err)
}
mergedDigest, err := snapshot.Digest(mergedSnapshot)
if err != nil {
return nil, false, fmt.Errorf("digest merged snapshot: %w", err)
}
appliedToLocal := localDigest != mergedDigest
if appliedToLocal {
if err := e.snapshotter.Apply(ctx, mergedSnapshot); err != nil {
return nil, false, fmt.Errorf("apply merged snapshot: %w", err)
}
}
stageDir, err := os.MkdirTemp("", "voidraft-sync-stage-*")
if err != nil {
return nil, false, err
}
defer os.RemoveAll(stageDir)
if err := e.store.Write(ctx, stageDir, mergedSnapshot); err != nil {
return nil, false, fmt.Errorf("write merged snapshot: %w", err)
}
publishedState, err := e.backend.Upload(ctx, stageDir, backend.PublishOptions{
ExpectedRevision: remoteState.Revision,
Message: options.CommitMessage,
})
if err != nil {
if errors.Is(err, backend.ErrRevisionConflict) {
return nil, true, err
}
return nil, false, fmt.Errorf("upload merged snapshot: %w", err)
}
return &Result{
LocalChanged: appliedToLocal,
RemoteChanged: remoteDigest != mergedDigest,
AppliedToLocal: appliedToLocal,
Published: remoteState != publishedState,
ConflictCount: report.Conflicts,
Revision: publishedState.Revision,
}, false, nil
}

16
internal/syncer/errors.go Normal file
View File

@@ -0,0 +1,16 @@
package syncer
import "errors"
var (
// ErrTargetNotFound 表示目标不存在。
ErrTargetNotFound = errors.New("sync target not found")
// ErrTargetDisabled 表示目标未启用。
ErrTargetDisabled = errors.New("sync target is disabled")
// ErrTargetNotReady 表示目标缺少必要配置。
ErrTargetNotReady = errors.New("sync target is not ready")
// ErrUnsupportedBackend 表示后端类型未实现。
ErrUnsupportedBackend = errors.New("sync backend is not supported")
// ErrUnsupportedDriver 表示后端驱动未实现。
ErrUnsupportedDriver = errors.New("sync driver is not supported")
)

View File

@@ -0,0 +1,19 @@
package merge
import (
"context"
"voidraft/internal/syncer/snapshot"
)
// Report 描述一次合并中的统计信息。
type Report struct {
Added int
Updated int
Deleted int
Conflicts int
}
// Merger 描述快照合并策略。
type Merger interface {
Merge(ctx context.Context, local *snapshot.Snapshot, remote *snapshot.Snapshot) (*snapshot.Snapshot, Report, error)
}

View File

@@ -0,0 +1,98 @@
package merge
import (
"context"
"sort"
"time"
"voidraft/internal/syncer/snapshot"
)
// UpdatedAtWinsMerger 使用 updated_at 作为默认冲突解决依据。
type UpdatedAtWinsMerger struct{}
// NewUpdatedAtWinsMerger 创建新的默认合并器。
func NewUpdatedAtWinsMerger() *UpdatedAtWinsMerger {
return &UpdatedAtWinsMerger{}
}
// Merge 合并本地与远端快照。
func (m *UpdatedAtWinsMerger) Merge(ctx context.Context, local *snapshot.Snapshot, remote *snapshot.Snapshot) (*snapshot.Snapshot, Report, error) {
_ = ctx
localSnapshot := snapshot.Clone(local)
remoteSnapshot := snapshot.Clone(remote)
index := make(map[string]snapshot.Record)
report := Report{}
for _, kind := range sortedKinds(localSnapshot, remoteSnapshot) {
for _, record := range localSnapshot.Resources[kind] {
index[recordKey(kind, record.ID)] = snapshot.CloneRecord(record)
}
for _, remoteRecord := range remoteSnapshot.Resources[kind] {
key := recordKey(kind, remoteRecord.ID)
localRecord, exists := index[key]
if !exists {
index[key] = snapshot.CloneRecord(remoteRecord)
report.Added++
continue
}
switch {
case remoteRecord.UpdatedAt.After(localRecord.UpdatedAt):
index[key] = snapshot.CloneRecord(remoteRecord)
report.Updated++
case remoteRecord.UpdatedAt.Equal(localRecord.UpdatedAt):
if snapshot.RecordDigest(localRecord) != snapshot.RecordDigest(remoteRecord) {
report.Conflicts++
}
default:
if remoteRecord.DeletedAt != nil && localRecord.DeletedAt == nil {
report.Deleted++
}
}
}
}
merged := snapshot.New()
for _, key := range sortedKeys(index) {
record := index[key]
merged.Resources[record.Kind] = append(merged.Resources[record.Kind], snapshot.CloneRecord(record))
}
merged.CreatedAt = time.Now()
return merged, report, nil
}
// sortedKinds 返回两个快照内的全部资源类型集合。
func sortedKinds(local *snapshot.Snapshot, remote *snapshot.Snapshot) []string {
index := make(map[string]struct{})
for kind := range local.Resources {
index[kind] = struct{}{}
}
for kind := range remote.Resources {
index[kind] = struct{}{}
}
kinds := make([]string, 0, len(index))
for kind := range index {
kinds = append(kinds, kind)
}
sort.Strings(kinds)
return kinds
}
// sortedKeys 返回稳定排序后的索引键集合。
func sortedKeys(index map[string]snapshot.Record) []string {
keys := make([]string, 0, len(index))
for key := range index {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
// recordKey 生成 record 的稳定索引键。
func recordKey(kind string, id string) string {
return kind + ":" + id
}

View File

@@ -0,0 +1,50 @@
package merge
import (
"context"
"testing"
"time"
"voidraft/internal/syncer/snapshot"
)
// TestUpdatedAtWinsMergerMerge 验证较新的记录会覆盖较旧记录。
func TestUpdatedAtWinsMergerMerge(t *testing.T) {
localRecord, err := snapshot.NewRecord("documents", "doc-1", map[string]interface{}{
"uuid": "doc-1",
"updated_at": time.Date(2026, 3, 29, 9, 0, 0, 0, time.UTC).Format(time.RFC3339),
"title": "local",
}, nil)
if err != nil {
t.Fatalf("build local record: %v", err)
}
remoteRecord, err := snapshot.NewRecord("documents", "doc-1", map[string]interface{}{
"uuid": "doc-1",
"updated_at": time.Date(2026, 3, 29, 10, 0, 0, 0, time.UTC).Format(time.RFC3339),
"title": "remote",
}, nil)
if err != nil {
t.Fatalf("build remote record: %v", err)
}
localSnapshot := snapshot.New()
localSnapshot.Resources["documents"] = []snapshot.Record{localRecord}
remoteSnapshot := snapshot.New()
remoteSnapshot.Resources["documents"] = []snapshot.Record{remoteRecord}
merger := NewUpdatedAtWinsMerger()
merged, report, err := merger.Merge(context.Background(), localSnapshot, remoteSnapshot)
if err != nil {
t.Fatalf("merge snapshot: %v", err)
}
if report.Updated != 1 {
t.Fatalf("expected updated report to be 1, got %d", report.Updated)
}
record := merged.Resources["documents"][0]
if got := record.Values["title"]; got != "remote" {
t.Fatalf("expected remote title, got %v", got)
}
}

View File

@@ -0,0 +1,61 @@
package resource
import (
"context"
"sort"
"voidraft/internal/syncer/snapshot"
)
// Adapter 描述单类资源的导出与应用能力。
type Adapter interface {
Kind() string
Export(ctx context.Context) ([]snapshot.Record, error)
Apply(ctx context.Context, records []snapshot.Record) error
}
// Registry 聚合所有资源适配器,并实现快照导入导出接口。
type Registry struct {
adapters []Adapter
}
// NewRegistry 创建新的资源注册表。
func NewRegistry(adapters ...Adapter) *Registry {
return &Registry{adapters: adapters}
}
// Export 导出所有已注册资源的快照。
func (r *Registry) Export(ctx context.Context) (*snapshot.Snapshot, error) {
snap := snapshot.New()
for _, adapter := range r.adapters {
records, err := adapter.Export(ctx)
if err != nil {
return nil, err
}
if len(records) == 0 {
continue
}
sort.Slice(records, func(i int, j int) bool {
return records[i].ID < records[j].ID
})
snap.Resources[adapter.Kind()] = records
}
return snap, nil
}
// Apply 将快照内容应用到本地资源。
func (r *Registry) Apply(ctx context.Context, snap *snapshot.Snapshot) error {
if snap == nil {
return nil
}
for _, adapter := range r.adapters {
records := snap.Resources[adapter.Kind()]
if err := adapter.Apply(ctx, records); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,117 @@
package resource
import (
"context"
"fmt"
"voidraft/internal/models/ent"
"voidraft/internal/models/ent/document"
"voidraft/internal/syncer/snapshot"
)
const documentContentBlob = "content.md"
// DocumentAdapter 负责文档资源的快照导入导出。
type DocumentAdapter struct {
client *ent.Client
}
// NewDocumentAdapter 创建文档适配器。
func NewDocumentAdapter(client *ent.Client) *DocumentAdapter {
return &DocumentAdapter{client: client}
}
// Kind 返回适配器负责的资源类型。
func (a *DocumentAdapter) Kind() string {
return "documents"
}
// Export 导出文档快照记录。
func (a *DocumentAdapter) Export(ctx context.Context) ([]snapshot.Record, error) {
documents, err := a.client.Document.Query().Order(document.ByUUID()).All(exportContext(ctx))
if err != nil {
return nil, err
}
records := make([]snapshot.Record, 0, len(documents))
for _, item := range documents {
values := map[string]interface{}{
document.FieldUUID: item.UUID,
document.FieldCreatedAt: item.CreatedAt,
document.FieldUpdatedAt: item.UpdatedAt,
document.FieldTitle: item.Title,
document.FieldLocked: item.Locked,
}
if item.DeletedAt != nil {
values[document.FieldDeletedAt] = *item.DeletedAt
}
record, err := snapshot.NewRecord(a.Kind(), item.UUID, values, map[string][]byte{
documentContentBlob: []byte(item.Content),
})
if err != nil {
return nil, fmt.Errorf("build document record %s: %w", item.UUID, err)
}
records = append(records, record)
}
return records, nil
}
// Apply 将快照记录应用到本地文档表。
func (a *DocumentAdapter) Apply(ctx context.Context, records []snapshot.Record) error {
applyCtx := importContext(ctx)
for _, record := range records {
found, err := a.client.Document.Query().Where(document.UUIDEQ(record.ID)).First(applyCtx)
switch {
case ent.IsNotFound(err):
if err := a.create(applyCtx, record); err != nil {
return err
}
case err != nil:
return err
default:
if shouldApplyRecord(found.UpdatedAt, record) {
if err := a.update(applyCtx, found.ID, record); err != nil {
return err
}
}
}
}
return nil
}
// create 创建新的文档记录。
func (a *DocumentAdapter) create(ctx context.Context, record snapshot.Record) error {
builder := a.client.Document.Create().
SetUUID(record.ID).
SetTitle(stringValue(record, document.FieldTitle)).
SetContent(blobString(record, documentContentBlob)).
SetLocked(boolValue(record, document.FieldLocked)).
SetCreatedAt(stringValue(record, document.FieldCreatedAt)).
SetUpdatedAt(stringValue(record, document.FieldUpdatedAt))
if deletedAt := recordDeletedAtString(record); deletedAt != nil {
builder.SetDeletedAt(*deletedAt)
}
return builder.Exec(ctx)
}
// update 更新已有文档记录。
func (a *DocumentAdapter) update(ctx context.Context, id int, record snapshot.Record) error {
builder := a.client.Document.UpdateOneID(id).
SetTitle(stringValue(record, document.FieldTitle)).
SetContent(blobString(record, documentContentBlob)).
SetLocked(boolValue(record, document.FieldLocked)).
SetUpdatedAt(stringValue(record, document.FieldUpdatedAt))
if deletedAt := recordDeletedAtString(record); deletedAt != nil {
builder.SetDeletedAt(*deletedAt)
} else {
builder.ClearDeletedAt()
}
return builder.Exec(ctx)
}

View File

@@ -0,0 +1,114 @@
package resource
import (
"context"
"fmt"
"voidraft/internal/models/ent"
"voidraft/internal/models/ent/extension"
"voidraft/internal/syncer/snapshot"
)
// ExtensionAdapter 负责扩展资源的快照导入导出。
type ExtensionAdapter struct {
client *ent.Client
}
// NewExtensionAdapter 创建扩展适配器。
func NewExtensionAdapter(client *ent.Client) *ExtensionAdapter {
return &ExtensionAdapter{client: client}
}
// Kind 返回适配器负责的资源类型。
func (a *ExtensionAdapter) Kind() string {
return "extensions"
}
// Export 导出扩展快照记录。
func (a *ExtensionAdapter) Export(ctx context.Context) ([]snapshot.Record, error) {
extensions, err := a.client.Extension.Query().Order(extension.ByUUID()).All(exportContext(ctx))
if err != nil {
return nil, err
}
records := make([]snapshot.Record, 0, len(extensions))
for _, item := range extensions {
values := map[string]interface{}{
extension.FieldUUID: item.UUID,
extension.FieldCreatedAt: item.CreatedAt,
extension.FieldUpdatedAt: item.UpdatedAt,
extension.FieldName: item.Name,
extension.FieldEnabled: item.Enabled,
extension.FieldConfig: cloneMap(item.Config),
}
if item.DeletedAt != nil {
values[extension.FieldDeletedAt] = *item.DeletedAt
}
record, err := snapshot.NewRecord(a.Kind(), item.UUID, values, nil)
if err != nil {
return nil, fmt.Errorf("build extension record %s: %w", item.UUID, err)
}
records = append(records, record)
}
return records, nil
}
// Apply 将快照记录应用到本地扩展表。
func (a *ExtensionAdapter) Apply(ctx context.Context, records []snapshot.Record) error {
applyCtx := importContext(ctx)
for _, record := range records {
found, err := a.client.Extension.Query().Where(extension.UUIDEQ(record.ID)).First(applyCtx)
switch {
case ent.IsNotFound(err):
if err := a.create(applyCtx, record); err != nil {
return err
}
case err != nil:
return err
default:
if shouldApplyRecord(found.UpdatedAt, record) {
if err := a.update(applyCtx, found.ID, record); err != nil {
return err
}
}
}
}
return nil
}
// create 创建新的扩展记录。
func (a *ExtensionAdapter) create(ctx context.Context, record snapshot.Record) error {
builder := a.client.Extension.Create().
SetUUID(record.ID).
SetName(stringValue(record, extension.FieldName)).
SetEnabled(boolValue(record, extension.FieldEnabled)).
SetConfig(mapValue(record, extension.FieldConfig)).
SetCreatedAt(stringValue(record, extension.FieldCreatedAt)).
SetUpdatedAt(stringValue(record, extension.FieldUpdatedAt))
if deletedAt := recordDeletedAtString(record); deletedAt != nil {
builder.SetDeletedAt(*deletedAt)
}
return builder.Exec(ctx)
}
// update 更新已有扩展记录。
func (a *ExtensionAdapter) update(ctx context.Context, id int, record snapshot.Record) error {
builder := a.client.Extension.UpdateOneID(id).
SetName(stringValue(record, extension.FieldName)).
SetEnabled(boolValue(record, extension.FieldEnabled)).
SetConfig(mapValue(record, extension.FieldConfig)).
SetUpdatedAt(stringValue(record, extension.FieldUpdatedAt))
if deletedAt := recordDeletedAtString(record); deletedAt != nil {
builder.SetDeletedAt(*deletedAt)
} else {
builder.ClearDeletedAt()
}
return builder.Exec(ctx)
}

View File

@@ -0,0 +1,75 @@
package resource
import (
"context"
"maps"
"time"
"voidraft/internal/models/schema/mixin"
"voidraft/internal/syncer/snapshot"
)
// importContext 构造同步导入所需的上下文。
func importContext(ctx context.Context) context.Context {
return mixin.SkipAutoUpdate(mixin.SkipSoftDelete(ctx))
}
// exportContext 构造同步导出所需的上下文。
func exportContext(ctx context.Context) context.Context {
return mixin.SkipSoftDelete(ctx)
}
// cloneMap 返回 map 的安全副本。
func cloneMap(value map[string]interface{}) map[string]interface{} {
if value == nil {
return nil
}
return maps.Clone(value)
}
// recordDeletedAtString 返回记录中的删除时间字符串。
func recordDeletedAtString(record snapshot.Record) *string {
if record.DeletedAt == nil {
return nil
}
value := record.DeletedAt.Format(time.RFC3339)
return &value
}
// shouldApplyRecord 判断记录是否应该覆盖本地数据。
func shouldApplyRecord(localUpdatedAt string, record snapshot.Record) bool {
if localUpdatedAt == "" {
return true
}
localTime, err := time.Parse(time.RFC3339, localUpdatedAt)
if err != nil {
return true
}
return record.UpdatedAt.After(localTime)
}
// stringValue 从记录字段中读取字符串。
func stringValue(record snapshot.Record, key string) string {
value, _ := record.Values[key].(string)
return value
}
// boolValue 从记录字段中读取布尔值。
func boolValue(record snapshot.Record, key string) bool {
value, _ := record.Values[key].(bool)
return value
}
// mapValue 从记录字段中读取 map 值。
func mapValue(record snapshot.Record, key string) map[string]interface{} {
value, _ := record.Values[key].(map[string]interface{})
return cloneMap(value)
}
// blobString 读取记录中的文本 blob。
func blobString(record snapshot.Record, name string) string {
value, ok := record.Blobs[name]
if !ok {
return ""
}
return string(value)
}

View File

@@ -0,0 +1,135 @@
package resource
import (
"context"
"fmt"
"voidraft/internal/models/ent"
"voidraft/internal/models/ent/keybinding"
"voidraft/internal/syncer/snapshot"
)
// KeyBindingAdapter 负责快捷键资源的快照导入导出。
type KeyBindingAdapter struct {
client *ent.Client
}
// NewKeyBindingAdapter 创建快捷键适配器。
func NewKeyBindingAdapter(client *ent.Client) *KeyBindingAdapter {
return &KeyBindingAdapter{client: client}
}
// Kind 返回适配器负责的资源类型。
func (a *KeyBindingAdapter) Kind() string {
return "keybindings"
}
// Export 导出快捷键快照记录。
func (a *KeyBindingAdapter) Export(ctx context.Context) ([]snapshot.Record, error) {
keyBindings, err := a.client.KeyBinding.Query().Order(keybinding.ByUUID()).All(exportContext(ctx))
if err != nil {
return nil, err
}
records := make([]snapshot.Record, 0, len(keyBindings))
for _, item := range keyBindings {
values := map[string]interface{}{
keybinding.FieldUUID: item.UUID,
keybinding.FieldCreatedAt: item.CreatedAt,
keybinding.FieldUpdatedAt: item.UpdatedAt,
keybinding.FieldName: item.Name,
keybinding.FieldType: item.Type,
keybinding.FieldKey: item.Key,
keybinding.FieldMacos: item.Macos,
keybinding.FieldWindows: item.Windows,
keybinding.FieldLinux: item.Linux,
keybinding.FieldExtension: item.Extension,
keybinding.FieldEnabled: item.Enabled,
keybinding.FieldPreventDefault: item.PreventDefault,
keybinding.FieldScope: item.Scope,
}
if item.DeletedAt != nil {
values[keybinding.FieldDeletedAt] = *item.DeletedAt
}
record, err := snapshot.NewRecord(a.Kind(), item.UUID, values, nil)
if err != nil {
return nil, fmt.Errorf("build keybinding record %s: %w", item.UUID, err)
}
records = append(records, record)
}
return records, nil
}
// Apply 将快照记录应用到本地快捷键表。
func (a *KeyBindingAdapter) Apply(ctx context.Context, records []snapshot.Record) error {
applyCtx := importContext(ctx)
for _, record := range records {
found, err := a.client.KeyBinding.Query().Where(keybinding.UUIDEQ(record.ID)).First(applyCtx)
switch {
case ent.IsNotFound(err):
if err := a.create(applyCtx, record); err != nil {
return err
}
case err != nil:
return err
default:
if shouldApplyRecord(found.UpdatedAt, record) {
if err := a.update(applyCtx, found.ID, record); err != nil {
return err
}
}
}
}
return nil
}
// create 创建新的快捷键记录。
func (a *KeyBindingAdapter) create(ctx context.Context, record snapshot.Record) error {
builder := a.client.KeyBinding.Create().
SetUUID(record.ID).
SetName(stringValue(record, keybinding.FieldName)).
SetType(stringValue(record, keybinding.FieldType)).
SetKey(stringValue(record, keybinding.FieldKey)).
SetMacos(stringValue(record, keybinding.FieldMacos)).
SetWindows(stringValue(record, keybinding.FieldWindows)).
SetLinux(stringValue(record, keybinding.FieldLinux)).
SetExtension(stringValue(record, keybinding.FieldExtension)).
SetEnabled(boolValue(record, keybinding.FieldEnabled)).
SetPreventDefault(boolValue(record, keybinding.FieldPreventDefault)).
SetScope(stringValue(record, keybinding.FieldScope)).
SetCreatedAt(stringValue(record, keybinding.FieldCreatedAt)).
SetUpdatedAt(stringValue(record, keybinding.FieldUpdatedAt))
if deletedAt := recordDeletedAtString(record); deletedAt != nil {
builder.SetDeletedAt(*deletedAt)
}
return builder.Exec(ctx)
}
// update 更新已有快捷键记录。
func (a *KeyBindingAdapter) update(ctx context.Context, id int, record snapshot.Record) error {
builder := a.client.KeyBinding.UpdateOneID(id).
SetName(stringValue(record, keybinding.FieldName)).
SetType(stringValue(record, keybinding.FieldType)).
SetKey(stringValue(record, keybinding.FieldKey)).
SetMacos(stringValue(record, keybinding.FieldMacos)).
SetWindows(stringValue(record, keybinding.FieldWindows)).
SetLinux(stringValue(record, keybinding.FieldLinux)).
SetExtension(stringValue(record, keybinding.FieldExtension)).
SetEnabled(boolValue(record, keybinding.FieldEnabled)).
SetPreventDefault(boolValue(record, keybinding.FieldPreventDefault)).
SetScope(stringValue(record, keybinding.FieldScope)).
SetUpdatedAt(stringValue(record, keybinding.FieldUpdatedAt))
if deletedAt := recordDeletedAtString(record); deletedAt != nil {
builder.SetDeletedAt(*deletedAt)
} else {
builder.ClearDeletedAt()
}
return builder.Exec(ctx)
}

View File

@@ -0,0 +1,114 @@
package resource
import (
"context"
"fmt"
"voidraft/internal/models/ent"
"voidraft/internal/models/ent/theme"
"voidraft/internal/syncer/snapshot"
)
// ThemeAdapter 负责主题资源的快照导入导出。
type ThemeAdapter struct {
client *ent.Client
}
// NewThemeAdapter 创建主题适配器。
func NewThemeAdapter(client *ent.Client) *ThemeAdapter {
return &ThemeAdapter{client: client}
}
// Kind 返回适配器负责的资源类型。
func (a *ThemeAdapter) Kind() string {
return "themes"
}
// Export 导出主题快照记录。
func (a *ThemeAdapter) Export(ctx context.Context) ([]snapshot.Record, error) {
themes, err := a.client.Theme.Query().Order(theme.ByUUID()).All(exportContext(ctx))
if err != nil {
return nil, err
}
records := make([]snapshot.Record, 0, len(themes))
for _, item := range themes {
values := map[string]interface{}{
theme.FieldUUID: item.UUID,
theme.FieldCreatedAt: item.CreatedAt,
theme.FieldUpdatedAt: item.UpdatedAt,
theme.FieldName: item.Name,
theme.FieldType: item.Type.String(),
theme.FieldColors: cloneMap(item.Colors),
}
if item.DeletedAt != nil {
values[theme.FieldDeletedAt] = *item.DeletedAt
}
record, err := snapshot.NewRecord(a.Kind(), item.UUID, values, nil)
if err != nil {
return nil, fmt.Errorf("build theme record %s: %w", item.UUID, err)
}
records = append(records, record)
}
return records, nil
}
// Apply 将快照记录应用到本地主题表。
func (a *ThemeAdapter) Apply(ctx context.Context, records []snapshot.Record) error {
applyCtx := importContext(ctx)
for _, record := range records {
found, err := a.client.Theme.Query().Where(theme.UUIDEQ(record.ID)).First(applyCtx)
switch {
case ent.IsNotFound(err):
if err := a.create(applyCtx, record); err != nil {
return err
}
case err != nil:
return err
default:
if shouldApplyRecord(found.UpdatedAt, record) {
if err := a.update(applyCtx, found.ID, record); err != nil {
return err
}
}
}
}
return nil
}
// create 创建新的主题记录。
func (a *ThemeAdapter) create(ctx context.Context, record snapshot.Record) error {
builder := a.client.Theme.Create().
SetUUID(record.ID).
SetName(stringValue(record, theme.FieldName)).
SetType(theme.Type(stringValue(record, theme.FieldType))).
SetColors(mapValue(record, theme.FieldColors)).
SetCreatedAt(stringValue(record, theme.FieldCreatedAt)).
SetUpdatedAt(stringValue(record, theme.FieldUpdatedAt))
if deletedAt := recordDeletedAtString(record); deletedAt != nil {
builder.SetDeletedAt(*deletedAt)
}
return builder.Exec(ctx)
}
// update 更新已有主题记录。
func (a *ThemeAdapter) update(ctx context.Context, id int, record snapshot.Record) error {
builder := a.client.Theme.UpdateOneID(id).
SetName(stringValue(record, theme.FieldName)).
SetType(theme.Type(stringValue(record, theme.FieldType))).
SetColors(mapValue(record, theme.FieldColors)).
SetUpdatedAt(stringValue(record, theme.FieldUpdatedAt))
if deletedAt := recordDeletedAtString(record); deletedAt != nil {
builder.SetDeletedAt(*deletedAt)
} else {
builder.ClearDeletedAt()
}
return builder.Exec(ctx)
}

View File

@@ -0,0 +1,75 @@
package scheduler
import (
"context"
"sync"
"time"
)
// Ticker 提供可重启的周期任务调度器。
type Ticker struct {
mu sync.Mutex
cancel context.CancelFunc
done chan struct{}
}
// NewTicker 创建新的调度器实例。
func NewTicker() *Ticker {
return &Ticker{}
}
// Start 启动周期任务。
func (t *Ticker) Start(interval time.Duration, job func(context.Context) error) {
if interval <= 0 || job == nil {
return
}
t.Stop()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
ticker := time.NewTicker(interval)
t.mu.Lock()
t.cancel = cancel
t.done = done
t.mu.Unlock()
go func() {
defer close(done)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_ = job(ctx)
}
}
}()
}
// Stop 停止当前任务。
func (t *Ticker) Stop() {
t.mu.Lock()
cancel := t.cancel
done := t.done
t.cancel = nil
t.done = nil
t.mu.Unlock()
if cancel != nil {
cancel()
}
if done != nil {
<-done
}
}
// Running 返回调度器是否正在运行。
func (t *Ticker) Running() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.cancel != nil
}

View File

@@ -0,0 +1,248 @@
package snapshot
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"maps"
"sort"
"strings"
"time"
)
const (
// CurrentVersion 是当前快照格式版本。
CurrentVersion = 1
)
// Snapshot 描述一次完整的数据快照。
type Snapshot struct {
Version int
CreatedAt time.Time
Resources map[string][]Record
}
// Record 描述单条资源记录。
type Record struct {
Kind string
ID string
UpdatedAt time.Time
DeletedAt *time.Time
Values map[string]interface{}
Blobs map[string][]byte
}
// Snapshotter 描述快照导出与应用接口。
type Snapshotter interface {
Export(ctx context.Context) (*Snapshot, error)
Apply(ctx context.Context, snap *Snapshot) error
}
// New 创建新的空快照。
func New() *Snapshot {
return &Snapshot{
Version: CurrentVersion,
CreatedAt: time.Now(),
Resources: make(map[string][]Record),
}
}
// NewRecord 根据业务字段构造规范化记录。
func NewRecord(kind string, id string, values map[string]interface{}, blobs map[string][]byte) (Record, error) {
if strings.TrimSpace(kind) == "" {
return Record{}, errors.New("record kind is required")
}
normalizedValues := cloneValues(values)
if id == "" {
uuid, _ := normalizedValues["uuid"].(string)
id = uuid
}
if id == "" {
return Record{}, errors.New("record id is required")
}
normalizedValues["uuid"] = id
updatedAt, err := parseRequiredTime(normalizedValues["updated_at"])
if err != nil {
return Record{}, fmt.Errorf("record %s updated_at: %w", id, err)
}
deletedAt, err := parseOptionalTime(normalizedValues["deleted_at"])
if err != nil {
return Record{}, fmt.Errorf("record %s deleted_at: %w", id, err)
}
return Record{
Kind: kind,
ID: id,
UpdatedAt: updatedAt,
DeletedAt: deletedAt,
Values: normalizedValues,
Blobs: cloneBlobs(blobs),
}, nil
}
// Clone 返回快照的深拷贝。
func Clone(snap *Snapshot) *Snapshot {
if snap == nil {
return New()
}
cloned := &Snapshot{
Version: snap.Version,
CreatedAt: snap.CreatedAt,
Resources: make(map[string][]Record, len(snap.Resources)),
}
for kind, records := range snap.Resources {
copied := make([]Record, 0, len(records))
for _, record := range records {
copied = append(copied, CloneRecord(record))
}
cloned.Resources[kind] = copied
}
return cloned
}
// CloneRecord 返回记录的深拷贝。
func CloneRecord(record Record) Record {
return Record{
Kind: record.Kind,
ID: record.ID,
UpdatedAt: record.UpdatedAt,
DeletedAt: cloneTime(record.DeletedAt),
Values: cloneValues(record.Values),
Blobs: cloneBlobs(record.Blobs),
}
}
// Digest 计算快照的稳定摘要。
func Digest(snap *Snapshot) (string, error) {
normalized := Clone(snap)
type digestRecord struct {
ID string `json:"id"`
UpdatedAt string `json:"updated_at"`
DeletedAt *string `json:"deleted_at,omitempty"`
Values map[string]interface{} `json:"values"`
Blobs map[string][]byte `json:"blobs,omitempty"`
}
payload := struct {
Version int `json:"version"`
Resources map[string][]digestRecord `json:"resources"`
}{
Version: normalized.Version,
Resources: make(map[string][]digestRecord, len(normalized.Resources)),
}
for _, kind := range sortedKinds(normalized.Resources) {
records := normalized.Resources[kind]
sort.Slice(records, func(i int, j int) bool {
return records[i].ID < records[j].ID
})
items := make([]digestRecord, 0, len(records))
for _, record := range records {
var deletedAt *string
if record.DeletedAt != nil {
value := record.DeletedAt.Format(time.RFC3339)
deletedAt = &value
}
items = append(items, digestRecord{
ID: record.ID,
UpdatedAt: record.UpdatedAt.Format(time.RFC3339),
DeletedAt: deletedAt,
Values: record.Values,
Blobs: record.Blobs,
})
}
payload.Resources[kind] = items
}
data, err := json.Marshal(payload)
if err != nil {
return "", err
}
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:]), nil
}
// RecordDigest 计算单条记录的稳定摘要。
func RecordDigest(record Record) string {
sum, err := Digest(&Snapshot{
Version: CurrentVersion,
Resources: map[string][]Record{
record.Kind: {CloneRecord(record)},
},
})
if err != nil {
return ""
}
return sum
}
// cloneValues 复制字段 map。
func cloneValues(values map[string]interface{}) map[string]interface{} {
if values == nil {
return map[string]interface{}{}
}
return maps.Clone(values)
}
// cloneBlobs 复制二进制 blob 集合。
func cloneBlobs(blobs map[string][]byte) map[string][]byte {
if len(blobs) == 0 {
return nil
}
copied := make(map[string][]byte, len(blobs))
for name, blob := range blobs {
copied[name] = append([]byte(nil), blob...)
}
return copied
}
// cloneTime 复制时间指针。
func cloneTime(value *time.Time) *time.Time {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
// parseRequiredTime 解析必填时间字段。
func parseRequiredTime(value interface{}) (time.Time, error) {
text, _ := value.(string)
if text == "" {
return time.Time{}, errors.New("time value is required")
}
return time.Parse(time.RFC3339, text)
}
// parseOptionalTime 解析可选时间字段。
func parseOptionalTime(value interface{}) (*time.Time, error) {
text, _ := value.(string)
if text == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, text)
if err != nil {
return nil, err
}
return &parsed, nil
}
// sortedKinds 返回稳定排序后的资源类型列表。
func sortedKinds(resources map[string][]Record) []string {
kinds := make([]string, 0, len(resources))
for kind := range resources {
kinds = append(kinds, kind)
}
sort.Strings(kinds)
return kinds
}

View File

@@ -0,0 +1,266 @@
package snapshot
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
const manifestFileName = "manifest.json"
// Store 描述快照落盘与读取能力。
type Store interface {
Read(ctx context.Context, root string) (*Snapshot, error)
Write(ctx context.Context, root string, snap *Snapshot) error
}
// FileStore 提供基于目录树的快照读写实现。
type FileStore struct{}
type manifest struct {
Version int `json:"version"`
CreatedAt string `json:"created_at"`
}
// NewFileStore 创建新的文件快照存储。
func NewFileStore() *FileStore {
return &FileStore{}
}
// Read 从目录树读取快照。
func (s *FileStore) Read(ctx context.Context, root string) (*Snapshot, error) {
_ = ctx
info, err := os.Stat(root)
if os.IsNotExist(err) {
return New(), nil
}
if err != nil {
return nil, err
}
if !info.IsDir() {
return New(), nil
}
snap := New()
if err := s.readManifest(root, snap); err != nil {
return nil, err
}
entries, err := os.ReadDir(root)
if err != nil {
return nil, err
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
kind := entry.Name()
records, err := s.readKind(filepath.Join(root, kind), kind)
if err != nil {
return nil, err
}
if len(records) == 0 {
continue
}
sort.Slice(records, func(i int, j int) bool {
return records[i].ID < records[j].ID
})
snap.Resources[kind] = records
}
return snap, nil
}
// Write 将快照写入目录树。
func (s *FileStore) Write(ctx context.Context, root string, snap *Snapshot) error {
_ = ctx
if err := os.RemoveAll(root); err != nil {
return err
}
if err := os.MkdirAll(root, 0755); err != nil {
return err
}
if err := s.writeManifest(root, snap); err != nil {
return err
}
for _, kind := range sortedKinds(snap.Resources) {
kindDir := filepath.Join(root, kind)
if err := os.MkdirAll(kindDir, 0755); err != nil {
return err
}
records := append([]Record(nil), snap.Resources[kind]...)
sort.Slice(records, func(i int, j int) bool {
return records[i].ID < records[j].ID
})
for _, record := range records {
if len(record.Blobs) == 0 {
if err := writeJSON(filepath.Join(kindDir, record.ID+".json"), record.Values); err != nil {
return err
}
continue
}
recordDir := filepath.Join(kindDir, record.ID)
if err := os.MkdirAll(recordDir, 0755); err != nil {
return err
}
if err := writeJSON(filepath.Join(recordDir, "record.json"), record.Values); err != nil {
return err
}
blobNames := make([]string, 0, len(record.Blobs))
for name := range record.Blobs {
blobNames = append(blobNames, name)
}
sort.Strings(blobNames)
for _, blobName := range blobNames {
if err := os.WriteFile(filepath.Join(recordDir, blobName), record.Blobs[blobName], 0644); err != nil {
return err
}
}
}
}
return nil
}
// readManifest 读取快照 manifest。
func (s *FileStore) readManifest(root string, snap *Snapshot) error {
data, err := os.ReadFile(filepath.Join(root, manifestFileName))
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
var current manifest
if err := json.Unmarshal(data, &current); err != nil {
return err
}
snap.Version = current.Version
if current.CreatedAt != "" {
createdAt, err := time.Parse(time.RFC3339, current.CreatedAt)
if err != nil {
return err
}
snap.CreatedAt = createdAt
}
return nil
}
// writeManifest 写入快照 manifest。
func (s *FileStore) writeManifest(root string, snap *Snapshot) error {
payload := manifest{
Version: snap.Version,
CreatedAt: snap.CreatedAt.Format(time.RFC3339),
}
return writeJSON(filepath.Join(root, manifestFileName), payload)
}
// readKind 读取单类资源目录。
func (s *FileStore) readKind(root string, kind string) ([]Record, error) {
entries, err := os.ReadDir(root)
if err != nil {
return nil, err
}
records := make([]Record, 0, len(entries))
for _, entry := range entries {
switch {
case entry.IsDir():
record, err := s.readBlobRecord(filepath.Join(root, entry.Name()), kind)
if err != nil {
return nil, err
}
records = append(records, record)
case strings.HasSuffix(entry.Name(), ".json"):
record, err := s.readFlatRecord(filepath.Join(root, entry.Name()), kind)
if err != nil {
return nil, err
}
records = append(records, record)
}
}
return records, nil
}
// readFlatRecord 读取单文件记录。
func (s *FileStore) readFlatRecord(path string, kind string) (Record, error) {
values, err := readValues(path)
if err != nil {
return Record{}, err
}
id := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
return NewRecord(kind, id, values, nil)
}
// readBlobRecord 读取目录型记录。
func (s *FileStore) readBlobRecord(root string, kind string) (Record, error) {
values, err := readValues(filepath.Join(root, "record.json"))
if err != nil {
return Record{}, err
}
entries, err := os.ReadDir(root)
if err != nil {
return Record{}, err
}
blobs := make(map[string][]byte)
for _, entry := range entries {
if entry.IsDir() || entry.Name() == "record.json" {
continue
}
content, err := os.ReadFile(filepath.Join(root, entry.Name()))
if err != nil {
return Record{}, err
}
blobs[entry.Name()] = content
}
return NewRecord(kind, filepath.Base(root), values, blobs)
}
// readValues 读取 JSON 字段集合。
func readValues(path string) (map[string]interface{}, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
values := make(map[string]interface{})
if err := json.Unmarshal(data, &values); err != nil {
return nil, err
}
return values, nil
}
// writeJSON 将结构体格式化写入 JSON 文件。
func writeJSON(path string, value interface{}) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
data = append(data, '\n')
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
return os.WriteFile(path, data, 0644)
}

View File

@@ -0,0 +1,59 @@
package snapshot
import (
"context"
"testing"
"time"
)
// TestFileStoreReadWrite 验证目录树快照可以稳定往返。
func TestFileStoreReadWrite(t *testing.T) {
root := t.TempDir()
documentRecord, err := NewRecord("documents", "doc-1", map[string]interface{}{
"uuid": "doc-1",
"updated_at": time.Date(2026, 3, 29, 10, 0, 0, 0, time.UTC).Format(time.RFC3339),
"title": "hello",
}, map[string][]byte{
"content.md": []byte("world"),
})
if err != nil {
t.Fatalf("build document record: %v", err)
}
themeRecord, err := NewRecord("themes", "theme-1", map[string]interface{}{
"uuid": "theme-1",
"updated_at": time.Date(2026, 3, 29, 10, 1, 0, 0, time.UTC).Format(time.RFC3339),
"name": "dark",
}, nil)
if err != nil {
t.Fatalf("build theme record: %v", err)
}
snap := New()
snap.Resources["documents"] = []Record{documentRecord}
snap.Resources["themes"] = []Record{themeRecord}
store := NewFileStore()
if err := store.Write(context.Background(), root, snap); err != nil {
t.Fatalf("write snapshot: %v", err)
}
loaded, err := store.Read(context.Background(), root)
if err != nil {
t.Fatalf("read snapshot: %v", err)
}
originalDigest, err := Digest(snap)
if err != nil {
t.Fatalf("digest original snapshot: %v", err)
}
loadedDigest, err := Digest(loaded)
if err != nil {
t.Fatalf("digest loaded snapshot: %v", err)
}
if originalDigest != loadedDigest {
t.Fatalf("expected digests to match, got %s != %s", originalDigest, loadedDigest)
}
}

20
internal/syncer/types.go Normal file
View File

@@ -0,0 +1,20 @@
package syncer
// Logger 描述同步模块需要的最小日志接口。
type Logger interface {
Debug(message string, args ...interface{})
Info(message string, args ...interface{})
Warning(message string, args ...interface{})
Error(message string, args ...interface{})
}
// SyncResult 描述一次同步的结果。
type SyncResult struct {
TargetID string
LocalChanged bool
RemoteChanged bool
AppliedToLocal bool
Published bool
ConflictCount int
Revision string
}