diff --git a/frontend/bindings/voidraft/internal/models/models.ts b/frontend/bindings/voidraft/internal/models/models.ts deleted file mode 100644 index 38354e3..0000000 --- a/frontend/bindings/voidraft/internal/models/models.ts +++ /dev/null @@ -1,1310 +0,0 @@ -// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL -// This file is automatically generated. DO NOT EDIT - -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore: Unused imports -import {Create as $Create} from "@wailsio/runtime"; - -/** - * AppConfig 应用配置 - 按照前端设置页面分类组织 - */ -export class AppConfig { - /** - * 通用设置 - */ - "general": GeneralConfig; - - /** - * 编辑设置 - */ - "editing": EditingConfig; - - /** - * 外观设置 - */ - "appearance": AppearanceConfig; - - /** - * 更新设置 - */ - "updates": UpdatesConfig; - - /** - * Git备份设置 - */ - "backup": GitBackupConfig; - - /** - * 配置元数据 - */ - "metadata": ConfigMetadata; - - /** Creates a new AppConfig instance. */ - constructor($$source: Partial = {}) { - if (!("general" in $$source)) { - this["general"] = (new GeneralConfig()); - } - if (!("editing" in $$source)) { - this["editing"] = (new EditingConfig()); - } - if (!("appearance" in $$source)) { - this["appearance"] = (new AppearanceConfig()); - } - if (!("updates" in $$source)) { - this["updates"] = (new UpdatesConfig()); - } - if (!("backup" in $$source)) { - this["backup"] = (new GitBackupConfig()); - } - if (!("metadata" in $$source)) { - this["metadata"] = (new ConfigMetadata()); - } - - Object.assign(this, $$source); - } - - /** - * Creates a new AppConfig instance from a string or object. - */ - static createFrom($$source: any = {}): AppConfig { - const $$createField0_0 = $$createType0; - const $$createField1_0 = $$createType1; - const $$createField2_0 = $$createType2; - const $$createField3_0 = $$createType3; - const $$createField4_0 = $$createType4; - const $$createField5_0 = $$createType5; - let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; - if ("general" in $$parsedSource) { - $$parsedSource["general"] = $$createField0_0($$parsedSource["general"]); - } - if ("editing" in $$parsedSource) { - $$parsedSource["editing"] = $$createField1_0($$parsedSource["editing"]); - } - if ("appearance" in $$parsedSource) { - $$parsedSource["appearance"] = $$createField2_0($$parsedSource["appearance"]); - } - if ("updates" in $$parsedSource) { - $$parsedSource["updates"] = $$createField3_0($$parsedSource["updates"]); - } - if ("backup" in $$parsedSource) { - $$parsedSource["backup"] = $$createField4_0($$parsedSource["backup"]); - } - if ("metadata" in $$parsedSource) { - $$parsedSource["metadata"] = $$createField5_0($$parsedSource["metadata"]); - } - return new AppConfig($$parsedSource as Partial); - } -} - -/** - * AppearanceConfig 外观设置配置 - */ -export class AppearanceConfig { - /** - * 界面语言 - */ - "language": LanguageType; - - /** - * 系统界面主题 - */ - "systemTheme": SystemThemeType; - - /** - * 当前选择的预设主题名称 - */ - "currentTheme": string; - - /** Creates a new AppearanceConfig instance. */ - constructor($$source: Partial = {}) { - if (!("language" in $$source)) { - this["language"] = ("" as LanguageType); - } - if (!("systemTheme" in $$source)) { - this["systemTheme"] = ("" as SystemThemeType); - } - if (!("currentTheme" in $$source)) { - this["currentTheme"] = ""; - } - - Object.assign(this, $$source); - } - - /** - * Creates a new AppearanceConfig instance from a string or object. - */ - static createFrom($$source: any = {}): AppearanceConfig { - let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; - return new AppearanceConfig($$parsedSource as Partial); - } -} - -/** - * Git备份相关类型定义 - * - * AuthMethod 定义Git认证方式 - */ -export enum AuthMethod { - /** - * The Go zero value for the underlying type of the enum. - */ - $zero = "", - - /** - * 认证方式 - */ - Token = "token", - SSHKey = "ssh_key", - UserPass = "user_pass", -}; - -/** - * ConfigMetadata 配置元数据 - */ -export class ConfigMetadata { - /** - * 最后更新时间 - */ - "lastUpdated": string; - - /** - * 配置版本号 - */ - "version": string; - - /** Creates a new ConfigMetadata instance. */ - constructor($$source: Partial = {}) { - if (!("lastUpdated" in $$source)) { - this["lastUpdated"] = ""; - } - if (!("version" in $$source)) { - this["version"] = ""; - } - - Object.assign(this, $$source); - } - - /** - * Creates a new ConfigMetadata instance from a string or object. - */ - static createFrom($$source: any = {}): ConfigMetadata { - let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; - return new ConfigMetadata($$parsedSource as Partial); - } -} - -/** - * EditingConfig 编辑设置配置 - */ -export class EditingConfig { - /** - * 字体设置 - * 字体大小 - */ - "fontSize": number; - - /** - * 字体族 - */ - "fontFamily": string; - - /** - * 字体粗细 - */ - "fontWeight": string; - - /** - * 行高 - */ - "lineHeight": number; - - /** - * Tab设置 - * 是否启用Tab缩进 - */ - "enableTabIndent": boolean; - - /** - * Tab大小 - */ - "tabSize": number; - - /** - * Tab类型(空格或Tab) - */ - "tabType": TabType; - - /** - * 快捷键模式 - * 快捷键模式(standard 或 emacs) - */ - "keymapMode": KeyBindingType; - - /** - * 保存选项 - * 自动保存延迟(毫秒) - */ - "autoSaveDelay": number; - - /** Creates a new EditingConfig instance. */ - constructor($$source: Partial = {}) { - if (!("fontSize" in $$source)) { - this["fontSize"] = 0; - } - if (!("fontFamily" in $$source)) { - this["fontFamily"] = ""; - } - if (!("fontWeight" in $$source)) { - this["fontWeight"] = ""; - } - if (!("lineHeight" in $$source)) { - this["lineHeight"] = 0; - } - if (!("enableTabIndent" in $$source)) { - this["enableTabIndent"] = false; - } - if (!("tabSize" in $$source)) { - this["tabSize"] = 0; - } - if (!("tabType" in $$source)) { - this["tabType"] = ("" as TabType); - } - if (!("keymapMode" in $$source)) { - this["keymapMode"] = ("" as KeyBindingType); - } - if (!("autoSaveDelay" in $$source)) { - this["autoSaveDelay"] = 0; - } - - Object.assign(this, $$source); - } - - /** - * Creates a new EditingConfig instance from a string or object. - */ - static createFrom($$source: any = {}): EditingConfig { - let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; - return new EditingConfig($$parsedSource as Partial); - } -} - -/** - * Extension 扩展配置 - */ -export class Extension { - "key": ExtensionName; - "enabled": boolean; - "config": ExtensionConfig; - - /** Creates a new Extension instance. */ - constructor($$source: Partial = {}) { - if (!("key" in $$source)) { - this["key"] = ("" as ExtensionName); - } - if (!("enabled" in $$source)) { - this["enabled"] = false; - } - if (!("config" in $$source)) { - this["config"] = ({} as ExtensionConfig); - } - - Object.assign(this, $$source); - } - - /** - * Creates a new Extension instance from a string or object. - */ - static createFrom($$source: any = {}): Extension { - const $$createField2_0 = $$createType6; - let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; - if ("config" in $$parsedSource) { - $$parsedSource["config"] = $$createField2_0($$parsedSource["config"]); - } - return new Extension($$parsedSource as Partial); - } -} - -/** - * ExtensionConfig 扩展配置项 - */ -export type ExtensionConfig = { [_: string]: any }; - -/** - * ExtensionName 扩展标识符 - */ -export enum ExtensionName { - /** - * The Go zero value for the underlying type of the enum. - */ - $zero = "", - - /** - * 彩虹括号 - */ - RainbowBrackets = "rainbowBrackets", - - /** - * 超链接 - */ - Hyperlink = "hyperlink", - - /** - * 颜色选择器 - */ - ColorSelector = "colorSelector", - - /** - * 代码折叠 - */ - Fold = "fold", - - /** - * 划词翻译 - */ - Translator = "translator", - - /** - * Markdown渲染 - */ - Markdown = "markdown", - - /** - * 显示空白字符 - */ - HighlightWhitespace = "highlightWhitespace", - - /** - * 高亮行尾空白 - */ - HighlightTrailingWhitespace = "highlightTrailingWhitespace", - - /** - * 小地图 - */ - Minimap = "minimap", - - /** - * 行号显示 - */ - LineNumbers = "lineNumbers", - - /** - * 上下文菜单 - */ - ContextMenu = "contextMenu", - - /** - * 搜索功能 - */ - Search = "search", - - /** - * HTTP 客户端 - */ - HttpClient = "httpClient", - - /** - * 代码块导出图片 - */ - BlockImage = "blockImage", -}; - -/** - * GeneralConfig 通用设置配置 - */ -export class GeneralConfig { - /** - * 窗口是否置顶 - */ - "alwaysOnTop": boolean; - - /** - * 数据存储路径 - */ - "dataPath": string; - - /** - * 是否启用系统托盘 - */ - "enableSystemTray": boolean; - - /** - * 开机启动设置 - */ - "startAtLogin": boolean; - - /** - * 窗口吸附设置 - * 是否启用窗口吸附功能(阈值现在是自适应的) - */ - "enableWindowSnap": boolean; - - /** - * 全局热键设置 - * 是否启用全局热键 - */ - "enableGlobalHotkey": boolean; - - /** - * 全局热键组合 - */ - "globalHotkey": HotkeyCombo; - - /** - * 界面设置 - * 是否启用加载动画 - */ - "enableLoadingAnimation": boolean; - - /** - * 是否启用标签页模式 - */ - "enableTabs": boolean; - - /** - * 是否启用内存监视器 - */ - "enableMemoryMonitor": boolean; - - /** Creates a new GeneralConfig instance. */ - constructor($$source: Partial = {}) { - if (!("alwaysOnTop" in $$source)) { - this["alwaysOnTop"] = false; - } - if (!("dataPath" in $$source)) { - this["dataPath"] = ""; - } - if (!("enableSystemTray" in $$source)) { - this["enableSystemTray"] = false; - } - if (!("startAtLogin" in $$source)) { - this["startAtLogin"] = false; - } - if (!("enableWindowSnap" in $$source)) { - this["enableWindowSnap"] = false; - } - if (!("enableGlobalHotkey" in $$source)) { - this["enableGlobalHotkey"] = false; - } - if (!("globalHotkey" in $$source)) { - this["globalHotkey"] = (new HotkeyCombo()); - } - if (!("enableLoadingAnimation" in $$source)) { - this["enableLoadingAnimation"] = false; - } - if (!("enableTabs" in $$source)) { - this["enableTabs"] = false; - } - if (!("enableMemoryMonitor" in $$source)) { - this["enableMemoryMonitor"] = false; - } - - Object.assign(this, $$source); - } - - /** - * Creates a new GeneralConfig instance from a string or object. - */ - static createFrom($$source: any = {}): GeneralConfig { - const $$createField6_0 = $$createType8; - let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; - if ("globalHotkey" in $$parsedSource) { - $$parsedSource["globalHotkey"] = $$createField6_0($$parsedSource["globalHotkey"]); - } - return new GeneralConfig($$parsedSource as Partial); - } -} - -/** - * GitBackupConfig Git备份配置 - */ -export class GitBackupConfig { - "enabled": boolean; - "repo_url": string; - "auth_method": AuthMethod; - "username"?: string; - "password"?: string; - "token"?: string; - "ssh_key_path"?: string; - "ssh_key_passphrase"?: string; - - /** - * 分钟 - */ - "backup_interval": number; - "auto_backup": boolean; - - /** Creates a new GitBackupConfig instance. */ - constructor($$source: Partial = {}) { - if (!("enabled" in $$source)) { - this["enabled"] = false; - } - if (!("repo_url" in $$source)) { - this["repo_url"] = ""; - } - if (!("auth_method" in $$source)) { - this["auth_method"] = ("" as AuthMethod); - } - if (!("backup_interval" in $$source)) { - this["backup_interval"] = 0; - } - if (!("auto_backup" in $$source)) { - this["auto_backup"] = false; - } - - Object.assign(this, $$source); - } - - /** - * Creates a new GitBackupConfig instance from a string or object. - */ - static createFrom($$source: any = {}): GitBackupConfig { - let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; - return new GitBackupConfig($$parsedSource as Partial); - } -} - -/** - * GithubConfig GitHub配置 - */ -export class GithubConfig { - /** - * 仓库所有者 - */ - "owner": string; - - /** - * 仓库名称 - */ - "repo": string; - - /** Creates a new GithubConfig instance. */ - constructor($$source: Partial = {}) { - if (!("owner" in $$source)) { - this["owner"] = ""; - } - if (!("repo" in $$source)) { - this["repo"] = ""; - } - - Object.assign(this, $$source); - } - - /** - * Creates a new GithubConfig instance from a string or object. - */ - static createFrom($$source: any = {}): GithubConfig { - let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; - return new GithubConfig($$parsedSource as Partial); - } -} - -/** - * HotkeyCombo 热键组合定义 - */ -export class HotkeyCombo { - /** - * Ctrl键 - */ - "ctrl": boolean; - - /** - * Shift键 - */ - "shift": boolean; - - /** - * Alt键 - */ - "alt": boolean; - - /** - * Win键 - */ - "win": boolean; - - /** - * 主键(如 'X', 'F1' 等) - */ - "key": string; - - /** Creates a new HotkeyCombo instance. */ - constructor($$source: Partial = {}) { - if (!("ctrl" in $$source)) { - this["ctrl"] = false; - } - if (!("shift" in $$source)) { - this["shift"] = false; - } - if (!("alt" in $$source)) { - this["alt"] = false; - } - if (!("win" in $$source)) { - this["win"] = false; - } - if (!("key" in $$source)) { - this["key"] = ""; - } - - Object.assign(this, $$source); - } - - /** - * Creates a new HotkeyCombo instance from a string or object. - */ - static createFrom($$source: any = {}): HotkeyCombo { - let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; - return new HotkeyCombo($$parsedSource as Partial); - } -} - -/** - * KeyBinding 单个快捷键绑定 - */ -export class KeyBinding { - /** - * 命令唯一标识符 - */ - "name": KeyBindingName; - - /** - * 快捷键类型(standard 或 "emacs") - */ - "type": KeyBindingType; - - /** - * 通用快捷键(跨平台) - */ - "key"?: string; - - /** - * macOS 专用快捷键 - */ - "macos"?: string; - - /** - * windows 专用快捷键 - */ - "win"?: string; - - /** - * Linux 专用快捷键 - */ - "linux"?: string; - - /** - * 所属扩展 - */ - "extension": ExtensionName; - - /** - * 是否启用 - */ - "enabled": boolean; - - /** - * 阻止浏览器默认行为 - */ - "preventDefault": boolean; - - /** - * 作用域(默认 "editor") - */ - "scope"?: string; - - /** Creates a new KeyBinding instance. */ - constructor($$source: Partial = {}) { - if (!("name" in $$source)) { - this["name"] = ("" as KeyBindingName); - } - if (!("type" in $$source)) { - this["type"] = ("" as KeyBindingType); - } - if (!("extension" in $$source)) { - this["extension"] = ("" as ExtensionName); - } - if (!("enabled" in $$source)) { - this["enabled"] = false; - } - if (!("preventDefault" in $$source)) { - this["preventDefault"] = false; - } - - Object.assign(this, $$source); - } - - /** - * Creates a new KeyBinding instance from a string or object. - */ - static createFrom($$source: any = {}): KeyBinding { - let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; - return new KeyBinding($$parsedSource as Partial); - } -} - -/** - * KeyBindingName 快捷键命令标识符 - */ -export enum KeyBindingName { - /** - * The Go zero value for the underlying type of the enum. - */ - $zero = "", - - /** - * 显示搜索 - */ - ShowSearch = "showSearch", - - /** - * 隐藏搜索 - */ - HideSearch = "hideSearch", - - /** - * 块内选择全部 - */ - BlockSelectAll = "blockSelectAll", - - /** - * 在当前块后添加新块 - */ - BlockAddAfterCurrent = "blockAddAfterCurrent", - - /** - * 在最后添加新块 - */ - BlockAddAfterLast = "blockAddAfterLast", - - /** - * 在当前块前添加新块 - */ - BlockAddBeforeCurrent = "blockAddBeforeCurrent", - - /** - * 跳转到上一个块 - */ - BlockGotoPrevious = "blockGotoPrevious", - - /** - * 跳转到下一个块 - */ - BlockGotoNext = "blockGotoNext", - - /** - * 选择上一个块 - */ - BlockSelectPrevious = "blockSelectPrevious", - - /** - * 选择下一个块 - */ - BlockSelectNext = "blockSelectNext", - - /** - * 删除当前块 - */ - BlockDelete = "blockDelete", - - /** - * 向上移动当前块 - */ - BlockMoveUp = "blockMoveUp", - - /** - * 向下移动当前块 - */ - BlockMoveDown = "blockMoveDown", - - /** - * 删除行 - */ - BlockDeleteLine = "blockDeleteLine", - - /** - * 向上移动行 - */ - BlockMoveLineUp = "blockMoveLineUp", - - /** - * 向下移动行 - */ - BlockMoveLineDown = "blockMoveLineDown", - - /** - * 字符转置 - */ - BlockTransposeChars = "blockTransposeChars", - - /** - * 格式化代码块 - */ - BlockFormat = "blockFormat", - - /** - * 复制 - */ - BlockCopy = "blockCopy", - - /** - * 剪切 - */ - BlockCut = "blockCut", - - /** - * 粘贴 - */ - BlockPaste = "blockPaste", - - /** - * 折叠代码 - */ - FoldCode = "foldCode", - - /** - * 展开代码 - */ - UnfoldCode = "unfoldCode", - - /** - * 折叠全部 - */ - FoldAll = "foldAll", - - /** - * 展开全部 - */ - UnfoldAll = "unfoldAll", - - /** - * 光标按语法左移 - */ - CursorSyntaxLeft = "cursorSyntaxLeft", - - /** - * 光标按语法右移 - */ - CursorSyntaxRight = "cursorSyntaxRight", - - /** - * 按语法选择左侧 - */ - SelectSyntaxLeft = "selectSyntaxLeft", - - /** - * 按语法选择右侧 - */ - SelectSyntaxRight = "selectSyntaxRight", - - /** - * 向上复制行 - */ - CopyLineUp = "copyLineUp", - - /** - * 向下复制行 - */ - CopyLineDown = "copyLineDown", - - /** - * 插入空行 - */ - InsertBlankLine = "insertBlankLine", - - /** - * 选择行 - */ - SelectLine = "selectLine", - - /** - * 选择父级语法 - */ - SelectParentSyntax = "selectParentSyntax", - - /** - * 简化选择 - */ - SimplifySelection = "simplifySelection", - - /** - * 在上方添加光标 - */ - AddCursorAbove = "addCursorAbove", - - /** - * 在下方添加光标 - */ - AddCursorBelow = "addCursorBelow", - - /** - * 光标按单词左移 - */ - CursorGroupLeft = "cursorGroupLeft", - - /** - * 光标按单词右移 - */ - CursorGroupRight = "cursorGroupRight", - - /** - * 按单词选择左侧 - */ - SelectGroupLeft = "selectGroupLeft", - - /** - * 按单词选择右侧 - */ - SelectGroupRight = "selectGroupRight", - - /** - * 删除到行尾 - */ - DeleteToLineEnd = "deleteToLineEnd", - - /** - * 删除到行首 - */ - DeleteToLineStart = "deleteToLineStart", - - /** - * 移动到行首 - */ - CursorLineStart = "cursorLineStart", - - /** - * 移动到行尾 - */ - CursorLineEnd = "cursorLineEnd", - - /** - * 选择到行首 - */ - SelectLineStart = "selectLineStart", - - /** - * 选择到行尾 - */ - SelectLineEnd = "selectLineEnd", - - /** - * 跳转到文档开头 - */ - CursorDocStart = "cursorDocStart", - - /** - * 跳转到文档结尾 - */ - CursorDocEnd = "cursorDocEnd", - - /** - * 选择到文档开头 - */ - SelectDocStart = "selectDocStart", - - /** - * 选择到文档结尾 - */ - SelectDocEnd = "selectDocEnd", - - /** - * 选择到匹配括号 - */ - SelectMatchingBracket = "selectMatchingBracket", - - /** - * 分割行 - */ - SplitLine = "splitLine", - - /** - * 光标左移一个字符 - */ - CursorCharLeft = "cursorCharLeft", - - /** - * 光标右移一个字符 - */ - CursorCharRight = "cursorCharRight", - - /** - * 光标上移一行 - */ - CursorLineUp = "cursorLineUp", - - /** - * 光标下移一行 - */ - CursorLineDown = "cursorLineDown", - - /** - * 向上翻页 - */ - CursorPageUp = "cursorPageUp", - - /** - * 向下翻页 - */ - CursorPageDown = "cursorPageDown", - - /** - * 选择左移一个字符 - */ - SelectCharLeft = "selectCharLeft", - - /** - * 选择右移一个字符 - */ - SelectCharRight = "selectCharRight", - - /** - * 选择上移一行 - */ - SelectLineUp = "selectLineUp", - - /** - * 选择下移一行 - */ - SelectLineDown = "selectLineDown", - - /** - * 减少缩进 - */ - IndentLess = "indentLess", - - /** - * 增加缩进 - */ - IndentMore = "indentMore", - - /** - * 缩进选择 - */ - IndentSelection = "indentSelection", - - /** - * 光标到匹配括号 - */ - CursorMatchingBracket = "cursorMatchingBracket", - - /** - * 切换注释 - */ - ToggleComment = "toggleComment", - - /** - * 切换块注释 - */ - ToggleBlockComment = "toggleBlockComment", - - /** - * 插入新行并缩进 - */ - InsertNewlineAndIndent = "insertNewlineAndIndent", - - /** - * 向后删除字符 - */ - DeleteCharBackward = "deleteCharBackward", - - /** - * 向前删除字符 - */ - DeleteCharForward = "deleteCharForward", - - /** - * 向后删除组 - */ - DeleteGroupBackward = "deleteGroupBackward", - - /** - * 向前删除组 - */ - DeleteGroupForward = "deleteGroupForward", - - /** - * 撤销 - */ - HistoryUndo = "historyUndo", - - /** - * 重做 - */ - HistoryRedo = "historyRedo", - - /** - * 撤销选择 - */ - HistoryUndoSelection = "historyUndoSelection", - - /** - * 重做选择 - */ - HistoryRedoSelection = "historyRedoSelection", - - /** - * 复制块为图片 - */ - CopyBlockImage = "copyBlockImage", -}; - -export enum KeyBindingType { - /** - * The Go zero value for the underlying type of the enum. - */ - $zero = "", - - /** - * standard 标准快捷键 - */ - Standard = "standard", - - /** - * emacs 快捷键 - */ - Emacs = "emacs", -}; - -/** - * LanguageType 语言类型定义 - */ -export enum LanguageType { - /** - * The Go zero value for the underlying type of the enum. - */ - $zero = "", - - /** - * LangZhCN 中文简体 - */ - LangZhCN = "zh-CN", - - /** - * LangEnUS 英文-美国 - */ - LangEnUS = "en-US", -}; - -/** - * SystemThemeType 系统主题类型定义 - */ -export enum SystemThemeType { - /** - * The Go zero value for the underlying type of the enum. - */ - $zero = "", - - /** - * SystemThemeDark 深色系统主题 - */ - SystemThemeDark = "dark", - - /** - * SystemThemeLight 浅色系统主题 - */ - SystemThemeLight = "light", - - /** - * SystemThemeAuto 跟随系统主题 - */ - SystemThemeAuto = "auto", -}; - -/** - * TabType 定义了制表符类型 - */ -export enum TabType { - /** - * The Go zero value for the underlying type of the enum. - */ - $zero = "", - - /** - * TabTypeSpaces 使用空格作为制表符 - */ - TabTypeSpaces = "spaces", - - /** - * TabTypeTab 使用Tab作为制表符 - */ - TabTypeTab = "tab", -}; - -/** - * UpdatesConfig 更新设置配置 - */ -export class UpdatesConfig { - /** - * 当前版本号 - */ - "version": string; - - /** - * 是否自动更新 - */ - "autoUpdate": boolean; - - /** - * 更新前是否备份 - */ - "backupBeforeUpdate": boolean; - - /** - * 更新超时时间(秒) - */ - "updateTimeout": number; - - /** - * GitHub配置 - */ - "github": GithubConfig; - - /** Creates a new UpdatesConfig instance. */ - constructor($$source: Partial = {}) { - if (!("version" in $$source)) { - this["version"] = ""; - } - if (!("autoUpdate" in $$source)) { - this["autoUpdate"] = false; - } - if (!("backupBeforeUpdate" in $$source)) { - this["backupBeforeUpdate"] = false; - } - if (!("updateTimeout" in $$source)) { - this["updateTimeout"] = 0; - } - if (!("github" in $$source)) { - this["github"] = (new GithubConfig()); - } - - Object.assign(this, $$source); - } - - /** - * Creates a new UpdatesConfig instance from a string or object. - */ - static createFrom($$source: any = {}): UpdatesConfig { - const $$createField4_0 = $$createType9; - let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; - if ("github" in $$parsedSource) { - $$parsedSource["github"] = $$createField4_0($$parsedSource["github"]); - } - return new UpdatesConfig($$parsedSource as Partial); - } -} - -// Private type creation functions -const $$createType0 = GeneralConfig.createFrom; -const $$createType1 = EditingConfig.createFrom; -const $$createType2 = AppearanceConfig.createFrom; -const $$createType3 = UpdatesConfig.createFrom; -const $$createType4 = GitBackupConfig.createFrom; -const $$createType5 = ConfigMetadata.createFrom; -var $$createType6 = (function $$initCreateType6(...args): any { - if ($$createType6 === $$initCreateType6) { - $$createType6 = $$createType7; - } - return $$createType6(...args); -}); -const $$createType7 = $Create.Map($Create.Any, $Create.Any); -const $$createType8 = HotkeyCombo.createFrom; -const $$createType9 = GithubConfig.createFrom; diff --git a/frontend/bindings/voidraft/internal/services/backupservice.ts b/frontend/bindings/voidraft/internal/services/backupservice.ts deleted file mode 100644 index 5ff03b8..0000000 --- a/frontend/bindings/voidraft/internal/services/backupservice.ts +++ /dev/null @@ -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 & { cancel(): void } { - let $resultPromise = $Call.ByID(395287784, config) as any; - return $resultPromise; -} - -/** - * Initialize 初始化备份服务 - */ -export function Initialize(): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(1052437974) as any; - return $resultPromise; -} - -/** - * Reinitialize 重新初始化 - */ -export function Reinitialize(): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(301562543) as any; - return $resultPromise; -} - -/** - * ServiceShutdown 服务关闭 - */ -export function ServiceShutdown(): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(422131801) as any; - return $resultPromise; -} - -export function ServiceStartup(options: application$0.ServiceOptions): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(2900331732, options) as any; - return $resultPromise; -} - -/** - * StartAutoBackup 启动自动备份 - */ -export function StartAutoBackup(): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(3035755449) as any; - return $resultPromise; -} - -/** - * StopAutoBackup 停止自动备份 - */ -export function StopAutoBackup(): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(2641894021) as any; - return $resultPromise; -} - -/** - * Sync 执行完整的同步流程:导出 -> commit -> pull -> 解决冲突 -> push -> 导入 - */ -export function Sync(): Promise & { cancel(): void } { - let $resultPromise = $Call.ByID(3420383211) as any; - return $resultPromise; -} diff --git a/frontend/bindings/voidraft/internal/services/index.ts b/frontend/bindings/voidraft/internal/services/index.ts deleted file mode 100644 index 4bc0bbe..0000000 --- a/frontend/bindings/voidraft/internal/services/index.ts +++ /dev/null @@ -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"; diff --git a/frontend/src/common/constant/config.ts b/frontend/src/common/constant/config.ts index c3ad2ba..5b75af8 100644 --- a/frontend/src/common/constant/config.ts +++ b/frontend/src/common/constant/config.ts @@ -1,6 +1,7 @@ import { AppConfig, AuthMethod, + SyncTarget, KeyBindingType, LanguageType, SystemThemeType, @@ -8,12 +9,8 @@ import { } from '@/../bindings/voidraft/internal/models/models'; import {FONT_OPTIONS} from './fonts'; -export type NumberConfigKey = 'fontSize' | 'tabSize' | 'lineHeight'; -export type ConfigSection = 'general' | 'editing' | 'appearance' | 'updates' | 'backup'; - // 统一配置键映射(平级展开) export const CONFIG_KEY_MAP = { - // general alwaysOnTop: 'general.alwaysOnTop', dataPath: 'general.dataPath', enableSystemTray: 'general.enableSystemTray', @@ -24,7 +21,7 @@ export const CONFIG_KEY_MAP = { enableLoadingAnimation: 'general.enableLoadingAnimation', enableTabs: 'general.enableTabs', enableMemoryMonitor: 'general.enableMemoryMonitor', - // editing + fontSize: 'editing.fontSize', fontFamily: 'editing.fontFamily', fontWeight: 'editing.fontWeight', @@ -34,33 +31,35 @@ export const CONFIG_KEY_MAP = { tabType: 'editing.tabType', keymapMode: 'editing.keymapMode', autoSaveDelay: 'editing.autoSaveDelay', - // appearance + language: 'appearance.language', systemTheme: 'appearance.systemTheme', currentTheme: 'appearance.currentTheme', - // updates - version: 'updates.version', + autoUpdate: 'updates.autoUpdate', - primarySource: 'updates.primarySource', - backupSource: 'updates.backupSource', backupBeforeUpdate: 'updates.backupBeforeUpdate', updateTimeout: 'updates.updateTimeout', github: 'updates.github', - gitea: 'updates.gitea', - // backup - enabled: 'backup.enabled', - repo_url: 'backup.repo_url', - auth_method: 'backup.auth_method', - username: 'backup.username', - password: 'backup.password', - token: 'backup.token', - ssh_key_path: 'backup.ssh_key_path', - ssh_key_passphrase: 'backup.ssh_key_passphrase', - backup_interval: 'backup.backup_interval', - auto_backup: 'backup.auto_backup', + + sync_target: 'sync.target', + git_enabled: 'sync.git.enabled', + git_auto_sync: 'sync.git.auto_sync', + git_sync_interval: 'sync.git.sync_interval', + git_repo_url: 'sync.git.repo_url', + git_auth_method: 'sync.git.auth_method', + git_username: 'sync.git.username', + git_password: 'sync.git.password', + git_token: 'sync.git.token', + git_ssh_key_path: 'sync.git.ssh_key_path', + 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; export type ConfigKey = keyof typeof CONFIG_KEY_MAP; +export type NumberConfigKey = 'fontSize' | 'tabSize' | 'lineHeight'; // 配置限制 export const CONFIG_LIMITS = { @@ -116,20 +115,29 @@ export const DEFAULT_CONFIG: AppConfig = { repo: "voidraft", }, }, - backup: { - enabled: false, - repo_url: "", - auth_method: AuthMethod.UserPass, - username: "", - password: "", - token: "", - ssh_key_path: "", - ssh_key_passphrase: "", - backup_interval: 60, - auto_backup: true, + sync: { + target: SyncTarget.SyncTargetGit, + git: { + enabled: false, + auto_sync: false, + sync_interval: 60, + repo_url: '', + auth_method: AuthMethod.UserPass, + username: '', + password: '', + token: '', + ssh_key_path: '', + ssh_key_passphrase: '', + }, + localfs: { + enabled: false, + auto_sync: false, + sync_interval: 60, + root_path: '', + }, }, metadata: { version: '1.0.0', lastUpdated: new Date().toString(), } -}; \ No newline at end of file +}; diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 5607cc1..77f60ed 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -182,7 +182,7 @@ export default { general: 'General', editing: 'Editor', appearance: 'Appearance', - backupPage: 'Backup', + syncPage: 'Sync', keyBindings: 'Key Bindings', updates: 'Updates', reset: 'Reset', @@ -257,11 +257,16 @@ export default { restartNow: 'Restart Now', hotkeyPreview: 'Preview:', none: 'None', - backup: { - basicSettings: 'Basic Settings', - enableBackup: 'Enable Git Backup', - autoBackup: 'Auto Backup', - backupInterval: 'Backup Interval', + sync: { + basicSettings: 'Basic Settings', + enableSync: 'Enable Sync', + targetType: 'Sync Type', + targetTypes: { + git: 'Git', + localfs: 'Local File System' + }, + autoSync: 'Auto Sync', + syncInterval: 'Sync Interval', intervals: { '5min': '5 minutes', '10min': '10 minutes', @@ -270,8 +275,11 @@ export default { '1hour': '1 hour' }, repositoryConfig: 'Repository Configuration', - repoUrl: 'Repository URL', - repoUrlPlaceholder: 'Enter Git repository URL', + storageConfig: 'Storage Configuration', + repoUrl: 'Repository URL', + repoUrlPlaceholder: 'Enter Git repository URL', + localfsRootPath: 'Local Storage Directory', + localfsRootPathPlaceholder: 'Select local sync directory', authConfig: 'Authentication Configuration', authMethod: 'Authentication Method', authMethods: { @@ -289,9 +297,11 @@ export default { sshKeyPathPlaceholder: 'Select SSH key file', sshKeyPassphrase: 'SSH Key Passphrase', sshKeyPassphrasePlaceholder: 'Enter SSH key passphrase', - backupOperations: 'Backup Operations', - syncToRemote: 'Sync to Remote', + syncOperations: 'Sync Operations', + syncToRemote: 'Sync to Remote', + syncToTarget: 'Sync to Target', syncing: 'Syncing...', + syncSuccess: 'Sync completed', actions: { sync: 'Sync', } diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index b8fe43f..6b60d81 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -182,7 +182,7 @@ export default { general: '常规', editing: '编辑器', appearance: '外观', - backupPage: '备份', + syncPage: '同步', extensions: '扩展', keyBindings: '快捷键', updates: '更新', @@ -259,11 +259,16 @@ export default { colorValue: '颜色值', hotkeyPreview: '预览:', none: '无', - backup: { + sync: { basicSettings: '基本设置', - enableBackup: '启用备份', - autoBackup: '自动备份', - backupInterval: '备份间隔', + enableSync: '启用同步', + targetType: '同步方式', + targetTypes: { + git: 'Git', + localfs: '本地文件系统' + }, + autoSync: '自动同步', + syncInterval: '同步间隔', intervals: { '5min': '5分钟', '10min': '10分钟', @@ -272,8 +277,11 @@ export default { '1hour': '1小时' }, repositoryConfig: '仓库配置', + storageConfig: '存储配置', repoUrl: '仓库地址', repoUrlPlaceholder: '请输入Git仓库地址', + localfsRootPath: '本地存储目录', + localfsRootPathPlaceholder: '请选择本地同步目录', authConfig: '认证配置', authMethod: '认证方式', authMethods: { @@ -291,9 +299,11 @@ export default { sshKeyPathPlaceholder: '请选择SSH密钥文件', sshKeyPassphrase: 'SSH密钥密码', sshKeyPassphrasePlaceholder: '请输入SSH密钥密码', - backupOperations: '备份操作', + syncOperations: '同步操作', syncToRemote: '同步到远程', + syncToTarget: '同步到目标', syncing: '同步中...', + syncSuccess: '同步成功', actions: { sync: '同步', } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index a40d999..deb3713 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -7,7 +7,7 @@ import AppearancePage from '@/views/settings/pages/AppearancePage.vue'; import KeyBindingsPage from '@/views/settings/pages/KeyBindingsPage.vue'; import UpdatesPage from '@/views/settings/pages/UpdatesPage.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'; @@ -44,9 +44,9 @@ const settingsChildren: RouteRecordRaw[] = [ component: UpdatesPage }, { - path: 'backup', - name: 'SettingsBackup', - component: BackupPage + path: 'sync', + name: 'SettingsSync', + component: SyncPage } ]; @@ -79,4 +79,4 @@ const router = createRouter({ routes: routes }); -export default router; \ No newline at end of file +export default router; diff --git a/frontend/src/stores/configStore.ts b/frontend/src/stores/configStore.ts index 259ebd3..3bbb135 100644 --- a/frontend/src/stores/configStore.ts +++ b/frontend/src/stores/configStore.ts @@ -1,113 +1,124 @@ import {defineStore} from 'pinia'; import {computed, reactive} from 'vue'; 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 {ConfigUtils} from '@/common/utils/configUtils'; import {FONT_OPTIONS} from '@/common/constant/fonts'; -import { - CONFIG_KEY_MAP, - CONFIG_LIMITS, - ConfigKey, - ConfigSection, - DEFAULT_CONFIG, - NumberConfigKey -} from '@/common/constant/config'; +import {CONFIG_KEY_MAP, CONFIG_LIMITS, ConfigKey, DEFAULT_CONFIG, NumberConfigKey} from '@/common/constant/config'; import * as runtime from '@wailsio/runtime'; export const useConfigStore = defineStore('config', () => { const {locale} = useI18n(); - // 响应式状态 const state = reactive({ - config: {...DEFAULT_CONFIG} as AppConfig, + config: structuredClone(DEFAULT_CONFIG) as AppConfig, isLoading: false, configLoaded: false }); - // Font options (no longer localized) const fontOptions = computed(() => FONT_OPTIONS); - // 统一配置更新方法 - const updateConfig = async (key: K, value: any): Promise => { + const applyConfig = (appConfig?: AppConfig | null): 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 => { if (!state.configLoaded && !state.isLoading) { await initConfig(); } + }; - const backendKey = CONFIG_KEY_MAP[key]; - if (!backendKey) { - throw new Error(`No backend key mapping found for ${String(key)}`); + const setValueByPath = (target: Record, path: string, value: unknown): void => { + const segments = path.split('.'); + const lastIndex = segments.length - 1; + + let current: Record = target; + for (let index = 0; index < lastIndex; index++) { + current = current[segments[index]]; } - - // 从 backendKey 提取 section(例如 'general.alwaysOnTop' -> 'general') - const section = backendKey.split('.')[0] as ConfigSection; - - await ConfigService.Set(backendKey, value); - (state.config[section] as any)[key] = value; + current[segments[lastIndex]] = value; }; - // 只更新本地状态,不保存到后端 - const updateConfigLocal = (key: K, value: any): void => { - const backendKey = CONFIG_KEY_MAP[key]; - const section = backendKey.split('.')[0] as ConfigSection; - (state.config[section] as any)[key] = value; + const getValueByPath = (target: Record, path: string): unknown => { + return path.split('.').reduce((current, segment) => (current as Record)[segment], target); + }; + + const updateConfig = async (key: K, value: unknown): Promise => { + await ensureConfigLoaded(); + const path = CONFIG_KEY_MAP[key]; + await ConfigService.Set(path, value); + setValueByPath(state.config as Record, path, value); + }; + + const updateConfigLocal = (key: K, value: unknown): void => { + setValueByPath(state.config as Record, CONFIG_KEY_MAP[key], value); }; - // 保存指定配置到后端 const saveConfig = async (key: K): Promise => { - const backendKey = CONFIG_KEY_MAP[key]; - const section = backendKey.split('.')[0] as ConfigSection; - await ConfigService.Set(backendKey, (state.config[section] as any)[key]); + const path = CONFIG_KEY_MAP[key]; + await ConfigService.Set(path, getValueByPath(state.config as Record, path)); }; - // 加载配置 + const activeSyncKey = (gitKey: G, localFSKey: L): G | L => ( + state.config.sync.target === SyncTarget.SyncTargetGit ? gitKey : localFSKey + ); + const initConfig = async (): Promise => { if (state.isLoading) return; state.isLoading = true; try { - const appConfig = 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); - } - + applyConfig(await ConfigService.GetConfig()); state.configLoaded = true; - } finally { state.isLoading = false; } }; - // 重置配置 const resetConfig = async (): Promise => { if (state.isLoading) return; state.isLoading = true; try { await ConfigService.ResetConfig(); - const appConfig = await ConfigService.GetConfig(); - if (appConfig) { - state.config = JSON.parse(JSON.stringify(appConfig)) as AppConfig; - } + applyConfig(await ConfigService.GetConfig()); + state.configLoaded = true; } finally { state.isLoading = false; } }; - // 辅助函数:限制数值范围 const clampValue = (value: number, key: NumberConfigKey): number => { const limit = CONFIG_LIMITS[key]; return ConfigUtils.clamp(value, limit.min, limit.max); }; - // 计算属性 const fontConfig = computed(() => ({ fontSize: state.config.editing.fontSize, fontFamily: state.config.editing.fontFamily, @@ -122,7 +133,6 @@ export const useConfigStore = defineStore('config', () => { })); return { - // 状态 config: computed(() => state.config), configLoaded: computed(() => state.configLoaded), isLoading: computed(() => state.isLoading), @@ -130,31 +140,25 @@ export const useConfigStore = defineStore('config', () => { fontConfig, tabConfig, - // 核心方法 initConfig, resetConfig, - // 语言相关方法 - setLanguage: (value: LanguageType) => { - updateConfig('language', value); + setLanguage: async (value: LanguageType) => { + await updateConfig('language', value); locale.value = value as any; }, - // 主题相关方法 setSystemTheme: (value: SystemThemeType) => updateConfig('systemTheme', value), setCurrentTheme: (value: string) => updateConfig('currentTheme', value), - // 字体大小操作 setFontSize: async (value: number) => { await updateConfig('fontSize', clampValue(value, 'fontSize')); }, increaseFontSize: async () => { - const newValue = state.config.editing.fontSize + 1; - await updateConfig('fontSize', clampValue(newValue, 'fontSize')); + await updateConfig('fontSize', clampValue(state.config.editing.fontSize + 1, 'fontSize')); }, decreaseFontSize: async () => { - const newValue = state.config.editing.fontSize - 1; - await updateConfig('fontSize', clampValue(newValue, 'fontSize')); + await updateConfig('fontSize', clampValue(state.config.editing.fontSize - 1, 'fontSize')); }, resetFontSize: async () => { await updateConfig('fontSize', CONFIG_LIMITS.fontSize.default); @@ -169,89 +173,63 @@ export const useConfigStore = defineStore('config', () => { await saveConfig('fontSize'); }, - // 字体操作 setFontFamily: (value: string) => updateConfig('fontFamily', value), setFontWeight: (value: string) => updateConfig('fontWeight', value), - - // 行高操作 setLineHeight: async (value: number) => { await updateConfig('lineHeight', clampValue(value, 'lineHeight')); }, - // Tab操作 setEnableTabIndent: (value: boolean) => updateConfig('enableTabIndent', value), setTabSize: async (value: number) => { await updateConfig('tabSize', clampValue(value, 'tabSize')); }, increaseTabSize: async () => { - const newValue = state.config.editing.tabSize + 1; - await updateConfig('tabSize', clampValue(newValue, 'tabSize')); + await updateConfig('tabSize', clampValue(state.config.editing.tabSize + 1, 'tabSize')); }, decreaseTabSize: async () => { - const newValue = state.config.editing.tabSize - 1; - await updateConfig('tabSize', clampValue(newValue, 'tabSize')); + await updateConfig('tabSize', clampValue(state.config.editing.tabSize - 1, 'tabSize')); }, toggleTabType: async () => { const values = CONFIG_LIMITS.tabType.values; const currentIndex = values.indexOf(state.config.editing.tabType as typeof values[number]); - const nextIndex = (currentIndex + 1) % values.length; - await updateConfig('tabType', values[nextIndex]); + await updateConfig('tabType', values[(currentIndex + 1) % values.length]); }, - // 窗口操作 toggleAlwaysOnTop: async () => { await updateConfig('alwaysOnTop', !state.config.general.alwaysOnTop); await runtime.Window.SetAlwaysOnTop(state.config.general.alwaysOnTop); }, setAlwaysOnTop: (value: boolean) => updateConfig('alwaysOnTop', value), - - // 路径操作 setDataPath: (value: string) => updateConfigLocal('dataPath', value), - - // 保存配置相关方法 setAutoSaveDelay: (value: number) => updateConfig('autoSaveDelay', value), - - // 热键配置相关方法 setEnableGlobalHotkey: (value: boolean) => updateConfig('enableGlobalHotkey', value), setGlobalHotkey: (hotkey: any) => updateConfig('globalHotkey', hotkey), - - // 系统托盘配置相关方法 setEnableSystemTray: (value: boolean) => updateConfig('enableSystemTray', value), - - // 开机启动配置相关方法 setStartAtLogin: async (value: boolean) => { await updateConfig('startAtLogin', value); await StartupService.SetEnabled(value); }, - - // 窗口吸附配置相关方法 setEnableWindowSnap: (value: boolean) => updateConfig('enableWindowSnap', value), - - // 加载动画配置相关方法 setEnableLoadingAnimation: (value: boolean) => updateConfig('enableLoadingAnimation', value), - - // 标签页配置相关方法 setEnableTabs: (value: boolean) => updateConfig('enableTabs', value), - - // 内存监视器配置相关方法 setEnableMemoryMonitor: (value: boolean) => updateConfig('enableMemoryMonitor', value), - - // 快捷键模式配置相关方法 setKeymapMode: (value: any) => updateConfig('keymapMode', value), - - // 更新配置相关方法 setAutoUpdate: (value: boolean) => updateConfig('autoUpdate', value), - // 备份配置相关方法 - setEnableBackup: (value: boolean) => updateConfig('enabled', value), - setAutoBackup: (value: boolean) => updateConfig('auto_backup', value), - setRepoUrl: (value: string) => updateConfig('repo_url', value), - setAuthMethod: (value: AuthMethod) => updateConfig('auth_method', value), - setUsername: (value: string) => updateConfig('username', value), - setPassword: (value: string) => updateConfig('password', value), - setToken: (value: string) => updateConfig('token', value), - setSshKeyPath: (value: string) => updateConfig('ssh_key_path', value), - setSshKeyPassphrase: (value: string) => updateConfig('ssh_key_passphrase', value), - setBackupInterval: (value: number) => updateConfig('backup_interval', value), + setSyncTarget: (value: SyncTarget) => updateConfig('sync_target', value), + setEnableSync: (value: boolean) => updateConfig(activeSyncKey('git_enabled', 'localfs_enabled'), value), + setAutoSync: (value: boolean) => updateConfig(activeSyncKey('git_auto_sync', 'localfs_auto_sync'), value), + setSyncInterval: (value: number) => updateConfig( + activeSyncKey('git_sync_interval', 'localfs_sync_interval'), + Math.max(1, value) + ), + setRepoUrl: (value: string) => updateConfig('git_repo_url', value), + setAuthMethod: (value: AuthMethod) => updateConfig('git_auth_method', value), + setUsername: (value: string) => updateConfig('git_username', 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), }; -}); \ No newline at end of file +}); diff --git a/frontend/src/stores/backupStore.ts b/frontend/src/stores/syncStore.ts similarity index 61% rename from frontend/src/stores/backupStore.ts rename to frontend/src/stores/syncStore.ts index 7ab02b6..8cbe485 100644 --- a/frontend/src/stores/backupStore.ts +++ b/frontend/src/stores/syncStore.ts @@ -1,8 +1,8 @@ import { defineStore } from 'pinia'; 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 sync = async (): Promise => { @@ -11,11 +11,8 @@ export const useBackupStore = defineStore('backup', () => { } isSyncing.value = true; - try { - await BackupService.Sync(); - } catch (e) { - throw e; + await SyncService.Sync(); } finally { isSyncing.value = false; } @@ -25,4 +22,4 @@ export const useBackupStore = defineStore('backup', () => { isSyncing, sync }; -}); \ No newline at end of file +}); diff --git a/frontend/src/views/settings/Settings.vue b/frontend/src/views/settings/Settings.vue index 640f1a4..242301e 100644 --- a/frontend/src/views/settings/Settings.vue +++ b/frontend/src/views/settings/Settings.vue @@ -18,7 +18,7 @@ const navItems = [ { id: 'general', icon: '⚙️', route: '/settings/general' }, { id: 'editing', icon: '✏️', route: '/settings/editing' }, { 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: 'keyBindings', icon: '⌨️', route: '/settings/key-bindings' }, { id: 'updates', icon: '🔄', route: '/settings/updates' } @@ -212,4 +212,4 @@ const goBackToEditor = async () => { } - \ No newline at end of file + diff --git a/frontend/src/views/settings/pages/BackupPage.vue b/frontend/src/views/settings/pages/BackupPage.vue deleted file mode 100644 index 36c37f1..0000000 --- a/frontend/src/views/settings/pages/BackupPage.vue +++ /dev/null @@ -1,321 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/views/settings/pages/SyncPage.vue b/frontend/src/views/settings/pages/SyncPage.vue new file mode 100644 index 0000000..b777537 --- /dev/null +++ b/frontend/src/views/settings/pages/SyncPage.vue @@ -0,0 +1,368 @@ + + + + + diff --git a/internal/models/config.go b/internal/models/config.go index 1729458..c3e558a 100644 --- a/internal/models/config.go +++ b/internal/models/config.go @@ -110,7 +110,7 @@ type UpdatesConfig struct { Github GithubConfig `json:"github"` // GitHub配置 } -// Git备份相关类型定义 +// Git同步相关类型定义 type ( // AuthMethod 定义Git认证方式 AuthMethod string @@ -123,18 +123,43 @@ const ( UserPass AuthMethod = "user_pass" ) -// GitBackupConfig Git备份配置 -type GitBackupConfig struct { - Enabled bool `json:"enabled"` - 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"` - BackupInterval int `json:"backup_interval"` // 分钟 - AutoBackup bool `json:"auto_backup"` +// SyncTarget 定义当前可选择的同步目标。 +type SyncTarget string + +const ( + // SyncTargetGit 表示 Git 同步。 + SyncTargetGit SyncTarget = "git" + // SyncTargetLocalFS 表示本地文件系统同步。 + SyncTargetLocalFS SyncTarget = "localfs" +) + +// GitSyncConfig 描述 Git 同步配置。 +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 应用配置 - 按照前端设置页面分类组织 @@ -143,7 +168,7 @@ type AppConfig struct { Editing EditingConfig `json:"editing"` // 编辑设置 Appearance AppearanceConfig `json:"appearance"` // 外观设置 Updates UpdatesConfig `json:"updates"` // 更新设置 - Backup GitBackupConfig `json:"backup"` // Git备份设置 + Sync SyncConfig `json:"sync"` // 同步设置 Metadata ConfigMetadata `json:"metadata"` // 配置元数据 } @@ -208,16 +233,26 @@ func NewDefaultAppConfig() *AppConfig { Repo: "voidraft", }, }, - Backup: GitBackupConfig{ - Enabled: false, - RepoURL: "", - AuthMethod: UserPass, - Username: "", - Password: "", - Token: "", - SSHKeyPath: "", - BackupInterval: 60, - AutoBackup: false, + Sync: SyncConfig{ + Target: SyncTargetGit, + Git: GitSyncConfig{ + Enabled: false, + AutoSync: false, + SyncInterval: 60, + RepoURL: "", + AuthMethod: UserPass, + Username: "", + Password: "", + Token: "", + SSHKeyPath: "", + SSHKeyPass: "", + }, + LocalFS: LocalFSSyncConfig{ + Enabled: false, + AutoSync: false, + SyncInterval: 60, + RootPath: "", + }, }, Metadata: ConfigMetadata{ LastUpdated: time.Now().Format(time.RFC3339), diff --git a/internal/services/backup_service.go b/internal/services/backup_service.go deleted file mode 100644 index 9e649ff..0000000 --- a/internal/services/backup_service.go +++ /dev/null @@ -1,1246 +0,0 @@ -package services - -import ( - "bufio" - "context" - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" - "sync" - "time" - "voidraft/internal/common/helper" - - "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" - "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" - "github.com/wailsapp/wails/v3/pkg/application" - "github.com/wailsapp/wails/v3/pkg/services/log" - - "voidraft/internal/models" - "voidraft/internal/models/ent" - "voidraft/internal/models/ent/document" - "voidraft/internal/models/ent/extension" - "voidraft/internal/models/ent/keybinding" - "voidraft/internal/models/ent/theme" - "voidraft/internal/models/schema/mixin" -) - -const ( - backupDir = "backup" // Git 仓库目录,JSONL 文件直接放这里 - remoteName = "origin" - branchName = "master" - maxRetries = 3 - jsonlSuffix = ".jsonl" - - // 通用字段名 - fieldUUID = "uuid" - fieldUpdatedAt = "updated_at" -) - -// 定义错误 -var ( - ErrNotInitialized = errors.New("backup service not initialized") - ErrDisabled = errors.New("backup is disabled") - ErrPushFailed = errors.New("push failed after max retries") -) - -// BackupService 提供基于Git的备份同步功能 -type BackupService struct { - configService *ConfigService - dbService *DatabaseService - repository *git.Repository - logger *log.LogService - isInitialized bool - autoBackupTicker *time.Ticker - autoBackupStop chan bool - autoBackupWg sync.WaitGroup - mu sync.Mutex - cancelObservers []helper.CancelFunc -} - -// NewBackupService 创建新的备份服务实例 -func NewBackupService(configService *ConfigService, dbService *DatabaseService, logger *log.LogService) *BackupService { - return &BackupService{ - configService: configService, - dbService: dbService, - logger: logger, - } -} - -func (s *BackupService) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { - // 监听 backup 配置变化 - s.cancelObservers = []helper.CancelFunc{ - s.configService.Watch("backup", s.onBackupConfigChange), - s.configService.Watch("general.dataPath", s.onDataPathChange), - } - if err := s.Initialize(); err != nil { - s.logger.Error("initializing backup service: %v", err) - } - return nil -} - -func (s *BackupService) onBackupConfigChange(oldValue, newValue interface{}) { - config, err := s.configService.GetConfig() - if err != nil { - return - } - _ = s.HandleConfigChange(&config.Backup) -} - -func (s *BackupService) onDataPathChange(oldValue, newValue interface{}) { - if err := s.Reinitialize(); err != nil { - s.logger.Error("Failed to reinitialize backup service after data path change: %v", err) - } -} - -// Initialize 初始化备份服务 -func (s *BackupService) Initialize() error { - config, repoPath, err := s.getConfigAndPath() - if err != nil { - return fmt.Errorf("getting backup config: %w", err) - } - - if !config.Enabled { - return nil - } - - // 仓库地址为空时不初始化 - if strings.TrimSpace(config.RepoURL) == "" { - return nil - } - - if err := s.initializeRepository(config, repoPath); err != nil { - return fmt.Errorf("initializing repository: %w", err) - } - - if err := s.verifyRemoteConnection(config); err != nil { - return fmt.Errorf("verifying remote connection: %w", err) - } - - if config.AutoBackup && config.BackupInterval > 0 { - _ = s.StartAutoBackup() - } - - s.mu.Lock() - s.isInitialized = true - s.mu.Unlock() - - return nil -} - -func (s *BackupService) getConfigAndPath() (*models.GitBackupConfig, string, error) { - appConfig, err := s.configService.GetConfig() - if err != nil { - return nil, "", fmt.Errorf("getting app config: %w", err) - } - // 返回 backup 目录作为 Git 仓库路径 - repoPath := filepath.Join(appConfig.General.DataPath, backupDir) - return &appConfig.Backup, repoPath, nil -} - -func (s *BackupService) initializeRepository(config *models.GitBackupConfig, repoPath string) error { - // 确保父目录存在 - if err := os.MkdirAll(repoPath, 0755); err != nil { - return fmt.Errorf("creating backup directory: %w", err) - } - - gitPath := filepath.Join(repoPath, ".git") - if _, err := os.Stat(gitPath); os.IsNotExist(err) { - repo, err := git.PlainInit(repoPath, false) - if err != nil { - return fmt.Errorf("initializing repository: %w", err) - } - s.repository = repo - - // 创建 .gitignore - gitignorePath := filepath.Join(repoPath, ".gitignore") - if _, err := os.Stat(gitignorePath); os.IsNotExist(err) { - _ = os.WriteFile(gitignorePath, []byte("*.tmp\n*.log\n"), 0644) - } - } else if err != nil { - return fmt.Errorf("checking repository path: %w", err) - } else { - repo, err := git.PlainOpen(repoPath) - if err != nil { - return fmt.Errorf("opening repository: %w", err) - } - s.repository = repo - } - - return s.setupRemote(config.RepoURL) -} - -func (s *BackupService) setupRemote(repoURL string) error { - remote, err := s.repository.Remote(remoteName) - if errors.Is(err, git.ErrRemoteNotFound) { - _, err = s.repository.CreateRemote(&gitConfig.RemoteConfig{ - Name: remoteName, - URLs: []string{repoURL}, - }) - return err - } - if err != nil { - return err - } - - if len(remote.Config().URLs) > 0 && remote.Config().URLs[0] != repoURL { - if err := s.repository.DeleteRemote(remoteName); err != nil { - return err - } - _, err = s.repository.CreateRemote(&gitConfig.RemoteConfig{ - Name: remoteName, - URLs: []string{repoURL}, - }) - return err - } - return nil -} - -func (s *BackupService) verifyRemoteConnection(config *models.GitBackupConfig) error { - auth, err := s.getAuthMethod(config) - if err != nil { - return err - } - - remote, err := s.repository.Remote(remoteName) - if err != nil { - return err - } - - // 验证能否连接远程仓库,空仓库返回空列表是正常的 - _, err = remote.List(&git.ListOptions{Auth: auth}) - if err != nil { - // 空仓库或无引用是允许的(第一次同步场景) - if strings.Contains(err.Error(), "empty") || strings.Contains(err.Error(), "no reference") { - return nil - } - return err - } - return nil -} - -func (s *BackupService) getAuthMethod(config *models.GitBackupConfig) (transport.AuthMethod, error) { - switch config.AuthMethod { - case models.Token: - if config.Token == "" { - return nil, errors.New("token required") - } - return &http.BasicAuth{Username: "git", Password: config.Token}, nil - - case models.UserPass: - if config.Username == "" || config.Password == "" { - return nil, errors.New("username and password required") - } - return &http.BasicAuth{Username: config.Username, Password: config.Password}, nil - - case models.SSHKey: - if config.SSHKeyPath == "" { - return nil, errors.New("SSH key path required") - } - return ssh.NewPublicKeysFromFile("git", config.SSHKeyPath, config.SSHKeyPass) - - default: - return nil, fmt.Errorf("unsupported auth method: %s", config.AuthMethod) - } -} - -// Sync 执行完整的同步流程:导出 -> commit -> pull -> 解决冲突 -> push -> 导入 -func (s *BackupService) Sync() error { - config, repoPath, err := s.getConfigAndPath() - if err != nil { - return err - } - - if !config.Enabled { - return ErrDisabled - } - - // 检查仓库地址是否配置 - if strings.TrimSpace(config.RepoURL) == "" { - return errors.New("repository URL is not configured") - } - - // 如果未初始化,尝试初始化 - s.mu.Lock() - initialized := s.isInitialized - s.mu.Unlock() - - if !initialized { - if err := s.Initialize(); err != nil { - return fmt.Errorf("initializing backup service: %w", err) - } - s.mu.Lock() - initialized = s.isInitialized - s.mu.Unlock() - if !initialized { - return ErrNotInitialized - } - } - - s.mu.Lock() - defer s.mu.Unlock() - - ctx := context.Background() - - auth, err := s.getAuthMethod(config) - if err != nil { - return err - } - - // 1. 拉取远程更新到本地工作区 - if err := s.fetchAndMergeRemote(auth, repoPath); err != nil { - s.logger.Warning("fetch remote: %v", err) - } - - // 2. 先将远程 JSONL 导入本地数据库(用 updated_at 解决记录级冲突) - if err := s.importAll(ctx, repoPath); err != nil { - s.logger.Warning("importing remote data: %v", err) - } - - // 3. 导出合并后的本地数据库到 JSONL - if err := s.exportAll(ctx, repoPath); err != nil { - return fmt.Errorf("exporting data: %w", err) - } - - // 4. 提交更改 - if _, err := s.commitChanges(); err != nil { - return fmt.Errorf("committing changes: %w", err) - } - - // 5. 推送到远程(带重试) - if err := s.pushWithRetry(auth, repoPath); err != nil { - return fmt.Errorf("pushing: %w", err) - } - - return nil -} - -// exportAll 导出所有表到 JSONL 文件 -func (s *BackupService) exportAll(ctx context.Context, dataPath string) error { - // 使用 SkipSoftDelete 获取所有数据(包括已删除的) - ctx = mixin.SkipSoftDelete(ctx) - client := s.dbService.Client - - // 定义导出任务 - exports := []struct { - name string - fn func() error - }{ - {"documents", func() error { - docs, err := client.Document.Query().Order(document.ByUUID()).All(ctx) - if err != nil { - return err - } - return writeJSONLFile(filepath.Join(dataPath, "documents"+jsonlSuffix), docs) - }}, - {"extensions", func() error { - items, err := client.Extension.Query().Order(extension.ByUUID()).All(ctx) - if err != nil { - return err - } - return writeJSONLFile(filepath.Join(dataPath, "extensions"+jsonlSuffix), items) - }}, - {"keybindings", func() error { - items, err := client.KeyBinding.Query().Order(keybinding.ByUUID()).All(ctx) - if err != nil { - return err - } - return writeJSONLFile(filepath.Join(dataPath, "keybindings"+jsonlSuffix), items) - }}, - {"themes", func() error { - items, err := client.Theme.Query().Order(theme.ByUUID()).All(ctx) - if err != nil { - return err - } - return writeJSONLFile(filepath.Join(dataPath, "themes"+jsonlSuffix), items) - }}, - } - - for _, export := range exports { - if err := export.fn(); err != nil { - return fmt.Errorf("exporting %s: %w", export.name, err) - } - } - - return nil -} - -// writeJSONLFile 使用泛型写入 JSONL 文件 -func writeJSONLFile[T any](filePath string, items []T) error { - file, err := os.Create(filePath) - if err != nil { - return err - } - defer file.Close() - - writer := bufio.NewWriter(file) - defer writer.Flush() - - for _, item := range items { - data, err := json.Marshal(item) - if err != nil { - return err - } - if _, err := writer.Write(data); err != nil { - return err - } - if err := writer.WriteByte('\n'); err != nil { - return err - } - } - - return nil -} - -func (s *BackupService) commitChanges() (bool, error) { - w, err := s.repository.Worktree() - if err != nil { - return false, err - } - - // 添加所有变更 - if err := w.AddGlob("*.jsonl"); err != nil { - // 如果没有文件匹配,不是错误 - if !strings.Contains(err.Error(), "no matches found") { - return false, err - } - } - - status, err := w.Status() - if err != nil { - return false, err - } - - if status.IsClean() { - return false, nil - } - - _, err = w.Commit(fmt.Sprintf("Backup %s", time.Now().Format("2006-01-02 15:04:05")), &git.CommitOptions{ - Author: &object.Signature{ - Name: "voidraft", - Email: "backup@voidraft.app", - When: time.Now(), - }, - }) - if err != nil { - return false, err - } - - return true, nil -} - -// fetchAndMergeRemote 拉取远程更新并合并 -func (s *BackupService) fetchAndMergeRemote(auth transport.AuthMethod, dataPath string) error { - // 检查本地是否有 HEAD(是否有任何 commit) - head, err := s.repository.Head() - hasLocalCommits := err == nil && head != nil - - // 先 fetch 远程 - err = s.repository.Fetch(&git.FetchOptions{ - RemoteName: remoteName, - Auth: auth, - }) - if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { - // 远程分支不存在是正常的(首次推送) - if strings.Contains(err.Error(), "couldn't find remote ref") { - return nil - } - return fmt.Errorf("fetching: %w", err) - } - - // 获取远程分支引用 - remoteRef, err := s.repository.Reference(plumbing.NewRemoteReferenceName(remoteName, branchName), true) - if err != nil { - // 远程分支不存在,正常情况 - return nil - } - - // 如果本地没有 commit,直接 checkout 远程分支 - if !hasLocalCommits { - w, err := s.repository.Worktree() - if err != nil { - return err - } - - // 创建本地分支指向远程 - err = w.Checkout(&git.CheckoutOptions{ - Hash: remoteRef.Hash(), - Branch: plumbing.NewBranchReferenceName(branchName), - Create: true, - Force: true, - }) - if err != nil { - return fmt.Errorf("checkout remote: %w", err) - } - return nil - } - - // 本地有 commit,尝试 pull 合并 - w, err := s.repository.Worktree() - if err != nil { - return err - } - - err = w.Pull(&git.PullOptions{ - RemoteName: remoteName, - ReferenceName: plumbing.NewBranchReferenceName(branchName), - Auth: auth, - }) - - if err == nil || errors.Is(err, git.NoErrAlreadyUpToDate) { - return nil - } - - // 处理合并冲突 - if errors.Is(err, git.ErrNonFastForwardUpdate) || - strings.Contains(err.Error(), "conflict") || - strings.Contains(err.Error(), "merge") { - return s.resolveConflicts(dataPath) - } - - // 远程分支不存在(首次推送) - if strings.Contains(err.Error(), "reference not found") || - strings.Contains(err.Error(), "couldn't find remote ref") { - return nil - } - - return err -} - -// pushWithRetry 推送到远程,带重试逻辑 -func (s *BackupService) pushWithRetry(auth transport.AuthMethod, dataPath string) error { - for i := 0; i < maxRetries; i++ { - err := s.repository.Push(&git.PushOptions{ - RemoteName: remoteName, - Auth: auth, - }) - - switch { - case err == nil, errors.Is(err, git.NoErrAlreadyUpToDate): - return nil - - case errors.Is(err, git.ErrNonFastForwardUpdate): - // 非快进更新,需要先拉取合并 - if mergeErr := s.fetchAndMergeRemote(auth, dataPath); mergeErr != nil { - return fmt.Errorf("merge before push: %w", mergeErr) - } - _, _ = s.commitChanges() - continue - - default: - return err - } - } - - return ErrPushFailed -} - -// resolveConflicts 解决 JSONL 文件中的冲突(Last Write Wins) -func (s *BackupService) resolveConflicts(dataPath string) error { - files, err := filepath.Glob(filepath.Join(dataPath, "*.jsonl")) - if err != nil { - return err - } - - for _, file := range files { - content, err := os.ReadFile(file) - if err != nil { - continue - } - - // 检查是否有冲突标记 - if !strings.Contains(string(content), "<<<<<<<") { - continue - } - - resolved, err := s.resolveJSONLConflict(string(content)) - if err != nil { - return fmt.Errorf("resolving conflict in %s: %w", file, err) - } - - if err := os.WriteFile(file, []byte(resolved), 0644); err != nil { - return err - } - } - - // 提交解决后的冲突 - w, err := s.repository.Worktree() - if err != nil { - return err - } - - if err := w.AddGlob("*.jsonl"); err != nil { - return err - } - - _, err = w.Commit("Auto-resolve sync conflicts", &git.CommitOptions{ - Author: &object.Signature{ - Name: "voidraft", - Email: "backup@voidraft.app", - When: time.Now(), - }, - }) - - return err -} - -// resolveJSONLConflict 解析并解决 JSONL 文件中的 Git 冲突 -func (s *BackupService) resolveJSONLConflict(content string) (string, error) { - lines := strings.Split(content, "\n") - var result []string - - var localLines, remoteLines []string - inConflict := false - isLocal := true - - for _, line := range lines { - if strings.HasPrefix(line, "<<<<<<<") { - inConflict = true - isLocal = true - localLines = nil - remoteLines = nil - continue - } - if strings.HasPrefix(line, "=======") { - isLocal = false - continue - } - if strings.HasPrefix(line, ">>>>>>>") { - // 解决这个冲突块 - resolved := s.mergeConflictBlock(localLines, remoteLines) - result = append(result, resolved...) - inConflict = false - continue - } - - if inConflict { - if isLocal { - if line != "" { - localLines = append(localLines, line) - } - } else { - if line != "" { - remoteLines = append(remoteLines, line) - } - } - } else { - result = append(result, line) - } - } - - return strings.Join(result, "\n"), nil -} - -// mergeConflictBlock 合并冲突块,使用 Last Write Wins 策略 -func (s *BackupService) mergeConflictBlock(localLines, remoteLines []string) []string { - // 解析本地和远程的记录 - localRecords := s.parseRecords(localLines) - remoteRecords := s.parseRecords(remoteLines) - - // 合并:按 UUID 索引,updated_at 更新的记录获胜 - merged := make(map[string]map[string]interface{}) - mergedOrder := []string{} - - // 先添加本地记录 - for _, record := range localRecords { - uuid, ok := record[fieldUUID].(string) - if !ok { - continue - } - merged[uuid] = record - mergedOrder = append(mergedOrder, uuid) - } - - // 合并远程记录 - for _, record := range remoteRecords { - uuid, ok := record[fieldUUID].(string) - if !ok { - continue - } - - existing, exists := merged[uuid] - if !exists { - merged[uuid] = record - mergedOrder = append(mergedOrder, uuid) - } else { - // 比较 updated_at,更新的获胜 - localTime := s.parseTime(existing[fieldUpdatedAt]) - remoteTime := s.parseTime(record[fieldUpdatedAt]) - if remoteTime.After(localTime) { - merged[uuid] = record - } - } - } - - // 转回 JSON 行 - var result []string - for _, uuid := range mergedOrder { - if record, ok := merged[uuid]; ok { - data, _ := json.Marshal(record) - result = append(result, string(data)) - delete(merged, uuid) // 避免重复 - } - } - - return result -} - -func (s *BackupService) parseRecords(lines []string) []map[string]interface{} { - var records []map[string]interface{} - for _, line := range lines { - var record map[string]interface{} - if err := json.Unmarshal([]byte(line), &record); err == nil { - records = append(records, record) - } - } - return records -} - -func (s *BackupService) parseTime(v interface{}) time.Time { - if str, ok := v.(string); ok { - t, _ := time.Parse(time.RFC3339, str) - return t - } - return time.Time{} -} - -// importAll 从 JSONL 文件导入数据到数据库 -func (s *BackupService) importAll(ctx context.Context, dataPath string) error { - client := s.dbService.Client - - // 定义导入任务 - imports := []struct { - name string - fn func() error - }{ - {"documents", func() error { return s.importDocuments(ctx, client, dataPath) }}, - {"extensions", func() error { return s.importExtensions(ctx, client, dataPath) }}, - {"keybindings", func() error { return s.importKeyBindings(ctx, client, dataPath) }}, - {"themes", func() error { return s.importThemes(ctx, client, dataPath) }}, - } - - for _, imp := range imports { - if err := imp.fn(); err != nil { - s.logger.Error("importing %s: %v", imp.name, err) - } - } - - return nil -} - -func (s *BackupService) importDocuments(ctx context.Context, client *ent.Client, dataPath string) error { - filePath := filepath.Join(dataPath, "documents.jsonl") - records, err := s.readJSONL(filePath) - if err != nil { - return err - } - - // 跳过软删除过滤和自动更新时间 - importCtx := mixin.SkipAutoUpdate(mixin.SkipSoftDelete(ctx)) - - for _, record := range records { - uuid, _ := record[document.FieldUUID].(string) - if uuid == "" { - continue - } - - // 查找现有记录 - found, err := client.Document.Query(). - Where(document.UUIDEQ(uuid)). - First(importCtx) - - remoteTime := s.parseTime(record[document.FieldUpdatedAt]) - - if err != nil || found == nil { - // 新记录,创建 - if err := s.createDocument(importCtx, client, record); err != nil { - s.logger.Error("creating document: %v", err) - } - } else { - // 比较时间,更新的获胜 - localTime, _ := time.Parse(time.RFC3339, found.UpdatedAt) - if remoteTime.After(localTime) { - if err := s.updateDocument(importCtx, client, found.ID, record); err != nil { - s.logger.Error("updating document: %v", err) - } - } - } - } - - return nil -} - -func (s *BackupService) createDocument(ctx context.Context, client *ent.Client, record map[string]interface{}) error { - builder := client.Document.Create() - if v, ok := record[document.FieldUUID].(string); ok { - builder.SetUUID(v) - } - if v, ok := record[document.FieldTitle].(string); ok { - builder.SetTitle(v) - } - if v, ok := record[document.FieldContent].(string); ok { - builder.SetContent(v) - } - if v, ok := record[document.FieldLocked].(bool); ok { - builder.SetLocked(v) - } - if v, ok := record[document.FieldCreatedAt].(string); ok { - builder.SetCreatedAt(v) - } - if v, ok := record[document.FieldUpdatedAt].(string); ok { - builder.SetUpdatedAt(v) - } - if v, ok := record[document.FieldDeletedAt].(string); ok { - builder.SetDeletedAt(v) - } - return builder.Exec(ctx) -} - -func (s *BackupService) updateDocument(ctx context.Context, client *ent.Client, id int, record map[string]interface{}) error { - builder := client.Document.UpdateOneID(id) - if v, ok := record[document.FieldTitle].(string); ok { - builder.SetTitle(v) - } - if v, ok := record[document.FieldContent].(string); ok { - builder.SetContent(v) - } - if v, ok := record[document.FieldLocked].(bool); ok { - builder.SetLocked(v) - } - if v, ok := record[document.FieldUpdatedAt].(string); ok { - builder.SetUpdatedAt(v) - } - if v, ok := record[document.FieldDeletedAt].(string); ok { - builder.SetDeletedAt(v) - } else { - builder.ClearDeletedAt() - } - return builder.Exec(ctx) -} - -func (s *BackupService) importExtensions(ctx context.Context, client *ent.Client, dataPath string) error { - filePath := filepath.Join(dataPath, "extensions.jsonl") - records, err := s.readJSONL(filePath) - if err != nil { - return err - } - - importCtx := mixin.SkipAutoUpdate(mixin.SkipSoftDelete(ctx)) - - for _, record := range records { - uuid, _ := record[extension.FieldUUID].(string) - if uuid == "" { - continue - } - - found, err := client.Extension.Query(). - Where(extension.UUIDEQ(uuid)). - First(importCtx) - - remoteTime := s.parseTime(record[extension.FieldUpdatedAt]) - - if err != nil || found == nil { - if err := s.createExtension(importCtx, client, record); err != nil { - s.logger.Error("creating extension: %v", err) - } - } else { - localTime, _ := time.Parse(time.RFC3339, found.UpdatedAt) - if remoteTime.After(localTime) { - if err := s.updateExtension(importCtx, client, found.ID, record); err != nil { - s.logger.Error("updating extension: %v", err) - } - } - } - } - - return nil -} - -func (s *BackupService) createExtension(ctx context.Context, client *ent.Client, record map[string]interface{}) error { - builder := client.Extension.Create() - if v, ok := record[extension.FieldUUID].(string); ok { - builder.SetUUID(v) - } - if v, ok := record[extension.FieldName].(string); ok { - builder.SetName(v) - } - if v, ok := record[extension.FieldEnabled].(bool); ok { - builder.SetEnabled(v) - } - if v, ok := record[extension.FieldConfig].(map[string]interface{}); ok { - builder.SetConfig(v) - } - if v, ok := record[extension.FieldCreatedAt].(string); ok { - builder.SetCreatedAt(v) - } - if v, ok := record[extension.FieldUpdatedAt].(string); ok { - builder.SetUpdatedAt(v) - } - if v, ok := record[extension.FieldDeletedAt].(string); ok { - builder.SetDeletedAt(v) - } - return builder.Exec(ctx) -} - -func (s *BackupService) updateExtension(ctx context.Context, client *ent.Client, id int, record map[string]interface{}) error { - builder := client.Extension.UpdateOneID(id) - if v, ok := record[extension.FieldName].(string); ok { - builder.SetName(v) - } - if v, ok := record[extension.FieldEnabled].(bool); ok { - builder.SetEnabled(v) - } - if v, ok := record[extension.FieldConfig].(map[string]interface{}); ok { - builder.SetConfig(v) - } - if v, ok := record[extension.FieldUpdatedAt].(string); ok { - builder.SetUpdatedAt(v) - } - if v, ok := record[extension.FieldDeletedAt].(string); ok { - builder.SetDeletedAt(v) - } else { - builder.ClearDeletedAt() - } - return builder.Exec(ctx) -} - -func (s *BackupService) importKeyBindings(ctx context.Context, client *ent.Client, dataPath string) error { - filePath := filepath.Join(dataPath, "keybindings.jsonl") - records, err := s.readJSONL(filePath) - if err != nil { - return err - } - - importCtx := mixin.SkipAutoUpdate(mixin.SkipSoftDelete(ctx)) - - for _, record := range records { - uuid, _ := record[keybinding.FieldUUID].(string) - if uuid == "" { - continue - } - - found, err := client.KeyBinding.Query(). - Where(keybinding.UUIDEQ(uuid)). - First(importCtx) - - remoteTime := s.parseTime(record[keybinding.FieldUpdatedAt]) - - if err != nil || found == nil { - if err := s.createKeyBinding(importCtx, client, record); err != nil { - s.logger.Error("creating keybinding: %v", err) - } - } else { - localTime, _ := time.Parse(time.RFC3339, found.UpdatedAt) - if remoteTime.After(localTime) { - if err := s.updateKeyBinding(importCtx, client, found.ID, record); err != nil { - s.logger.Error("updating keybinding: %v", err) - } - } - } - } - - return nil -} - -func (s *BackupService) createKeyBinding(ctx context.Context, client *ent.Client, record map[string]interface{}) error { - builder := client.KeyBinding.Create() - if v, ok := record[keybinding.FieldUUID].(string); ok { - builder.SetUUID(v) - } - if v, ok := record[keybinding.FieldName].(string); ok { - builder.SetName(v) - } - if v, ok := record[keybinding.FieldKey].(string); ok { - builder.SetKey(v) - } - if v, ok := record[keybinding.FieldMacos].(string); ok { - builder.SetMacos(v) - } - if v, ok := record[keybinding.FieldWindows].(string); ok { - builder.SetWindows(v) - } - if v, ok := record[keybinding.FieldLinux].(string); ok { - builder.SetLinux(v) - } - if v, ok := record[keybinding.FieldExtension].(string); ok { - builder.SetExtension(v) - } - if v, ok := record[keybinding.FieldEnabled].(bool); ok { - builder.SetEnabled(v) - } - if v, ok := record[keybinding.FieldPreventDefault].(bool); ok { - builder.SetPreventDefault(v) - } - if v, ok := record[keybinding.FieldScope].(string); ok { - builder.SetScope(v) - } - if v, ok := record[keybinding.FieldCreatedAt].(string); ok { - builder.SetCreatedAt(v) - } - if v, ok := record[keybinding.FieldUpdatedAt].(string); ok { - builder.SetUpdatedAt(v) - } - if v, ok := record[keybinding.FieldDeletedAt].(string); ok { - builder.SetDeletedAt(v) - } - return builder.Exec(ctx) -} - -func (s *BackupService) updateKeyBinding(ctx context.Context, client *ent.Client, id int, record map[string]interface{}) error { - builder := client.KeyBinding.UpdateOneID(id) - if v, ok := record[keybinding.FieldName].(string); ok { - builder.SetName(v) - } - if v, ok := record[keybinding.FieldKey].(string); ok { - builder.SetKey(v) - } - if v, ok := record[keybinding.FieldMacos].(string); ok { - builder.SetMacos(v) - } - if v, ok := record[keybinding.FieldWindows].(string); ok { - builder.SetWindows(v) - } - if v, ok := record[keybinding.FieldLinux].(string); ok { - builder.SetLinux(v) - } - if v, ok := record[keybinding.FieldExtension].(string); ok { - builder.SetExtension(v) - } - if v, ok := record[keybinding.FieldEnabled].(bool); ok { - builder.SetEnabled(v) - } - if v, ok := record[keybinding.FieldPreventDefault].(bool); ok { - builder.SetPreventDefault(v) - } - if v, ok := record[keybinding.FieldScope].(string); ok { - builder.SetScope(v) - } - if v, ok := record[keybinding.FieldUpdatedAt].(string); ok { - builder.SetUpdatedAt(v) - } - if v, ok := record[keybinding.FieldDeletedAt].(string); ok { - builder.SetDeletedAt(v) - } else { - builder.ClearDeletedAt() - } - return builder.Exec(ctx) -} - -func (s *BackupService) importThemes(ctx context.Context, client *ent.Client, dataPath string) error { - filePath := filepath.Join(dataPath, "themes.jsonl") - records, err := s.readJSONL(filePath) - if err != nil { - return err - } - - importCtx := mixin.SkipAutoUpdate(mixin.SkipSoftDelete(ctx)) - - for _, record := range records { - uuid, _ := record[theme.FieldUUID].(string) - if uuid == "" { - continue - } - - found, err := client.Theme.Query(). - Where(theme.UUIDEQ(uuid)). - First(importCtx) - - remoteTime := s.parseTime(record[theme.FieldUpdatedAt]) - - if err != nil || found == nil { - if err := s.createTheme(importCtx, client, record); err != nil { - s.logger.Error("creating theme: %v", err) - } - } else { - localTime, _ := time.Parse(time.RFC3339, found.UpdatedAt) - if remoteTime.After(localTime) { - if err := s.updateTheme(importCtx, client, found.ID, record); err != nil { - s.logger.Error("updating theme: %v", err) - } - } - } - } - - return nil -} - -func (s *BackupService) createTheme(ctx context.Context, client *ent.Client, record map[string]interface{}) error { - builder := client.Theme.Create() - if v, ok := record[theme.FieldUUID].(string); ok { - builder.SetUUID(v) - } - if v, ok := record[theme.FieldName].(string); ok { - builder.SetName(v) - } - if v, ok := record[theme.FieldType].(string); ok { - builder.SetType(theme.Type(v)) - } - if v, ok := record[theme.FieldColors].(map[string]interface{}); ok { - builder.SetColors(v) - } - if v, ok := record[theme.FieldCreatedAt].(string); ok { - builder.SetCreatedAt(v) - } - if v, ok := record[theme.FieldUpdatedAt].(string); ok { - builder.SetUpdatedAt(v) - } - if v, ok := record[theme.FieldDeletedAt].(string); ok { - builder.SetDeletedAt(v) - } - return builder.Exec(ctx) -} - -func (s *BackupService) updateTheme(ctx context.Context, client *ent.Client, id int, record map[string]interface{}) error { - builder := client.Theme.UpdateOneID(id) - if v, ok := record[theme.FieldName].(string); ok { - builder.SetName(v) - } - if v, ok := record[theme.FieldType].(string); ok { - builder.SetType(theme.Type(v)) - } - if v, ok := record[theme.FieldColors].(map[string]interface{}); ok { - builder.SetColors(v) - } - if v, ok := record[theme.FieldUpdatedAt].(string); ok { - builder.SetUpdatedAt(v) - } - if v, ok := record[theme.FieldDeletedAt].(string); ok { - builder.SetDeletedAt(v) - } else { - builder.ClearDeletedAt() - } - return builder.Exec(ctx) -} - -func (s *BackupService) readJSONL(filePath string) ([]map[string]interface{}, error) { - file, err := os.Open(filePath) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - defer file.Close() - - var records []map[string]interface{} - scanner := bufio.NewScanner(file) - // 增加 buffer 大小以处理大行 - scanner.Buffer(make([]byte, 1024*1024), 1024*1024) - - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - var record map[string]interface{} - if err := json.Unmarshal([]byte(line), &record); err == nil { - records = append(records, record) - } - } - - return records, scanner.Err() -} - -// StartAutoBackup 启动自动备份 -func (s *BackupService) StartAutoBackup() error { - config, _, err := s.getConfigAndPath() - if err != nil { - return err - } - - if !config.AutoBackup || config.BackupInterval <= 0 { - return nil - } - - s.StopAutoBackup() - - s.autoBackupTicker = time.NewTicker(time.Duration(config.BackupInterval) * time.Minute) - s.autoBackupStop = make(chan bool) - - s.autoBackupWg.Add(1) - go func() { - defer s.autoBackupWg.Done() - for { - select { - case <-s.autoBackupTicker.C: - if err := s.Sync(); err != nil { - s.logger.Error("auto backup failed: %v", err) - } - case <-s.autoBackupStop: - return - } - } - }() - - return nil -} - -// StopAutoBackup 停止自动备份 -func (s *BackupService) StopAutoBackup() { - // 先停止 ticker - if s.autoBackupTicker != nil { - s.autoBackupTicker.Stop() - s.autoBackupTicker = nil - } - - // 安全关闭 channel(只关闭一次) - if s.autoBackupStop != nil { - select { - case <-s.autoBackupStop: - // channel 已关闭,不做任何事 - default: - close(s.autoBackupStop) - } - s.autoBackupWg.Wait() - s.autoBackupStop = nil - } -} - -// Reinitialize 重新初始化 -func (s *BackupService) Reinitialize() error { - s.StopAutoBackup() - - s.mu.Lock() - s.isInitialized = false - s.mu.Unlock() - - return s.Initialize() -} - -// HandleConfigChange 处理配置变更 -func (s *BackupService) HandleConfigChange(config *models.GitBackupConfig) error { - s.mu.Lock() - initialized := s.isInitialized - s.mu.Unlock() - - if !config.Enabled { - s.StopAutoBackup() - s.mu.Lock() - s.isInitialized = false - s.mu.Unlock() - return nil - } - - if initialized { - return s.Reinitialize() - } - - return s.Initialize() -} - -// ServiceShutdown 服务关闭 -func (s *BackupService) ServiceShutdown() { - for _, cancel := range s.cancelObservers { - if cancel != nil { - cancel() - } - } - s.StopAutoBackup() -} diff --git a/internal/services/service_manager.go b/internal/services/service_manager.go index 9314cdc..395cb7b 100644 --- a/internal/services/service_manager.go +++ b/internal/services/service_manager.go @@ -29,7 +29,7 @@ type ServiceManager struct { badgeService *dock.DockService notificationService *notifications.NotificationService testService *TestService // 测试服务(仅开发环境) - BackupService *BackupService + SyncService *SyncService httpClientService *HttpClientService // HTTP客户端服务 logger *log.LogService } @@ -95,8 +95,8 @@ func NewServiceManager() *ServiceManager { // 初始化主题服务 themeService := NewThemeService(databaseService, logger) - // 初始化备份服务 - backupService := NewBackupService(configService, databaseService, logger) + // 初始化同步服务 + syncService := NewSyncService(configService, databaseService, logger) // 初始化HTTP客户端服务 httpClientService := NewHttpClientService(logger) @@ -124,7 +124,7 @@ func NewServiceManager() *ServiceManager { badgeService: badgeService, notificationService: notificationService, testService: testService, - BackupService: backupService, + SyncService: syncService, httpClientService: httpClientService, logger: logger, } @@ -150,7 +150,7 @@ func (sm *ServiceManager) GetServices() []application.Service { application.NewService(sm.badgeService), application.NewService(sm.notificationService), application.NewService(sm.testService), - application.NewService(sm.BackupService), + application.NewService(sm.SyncService), application.NewService(sm.httpClientService), } return services diff --git a/internal/services/sync_service.go b/internal/services/sync_service.go new file mode 100644 index 0000000..6f31018 --- /dev/null +++ b/internal/services/sync_service.go @@ -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, + }, + } +} diff --git a/internal/syncer/app.go b/internal/syncer/app.go new file mode 100644 index 0000000..c9856e2 --- /dev/null +++ b/internal/syncer/app.go @@ -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 +} diff --git a/internal/syncer/backend/backend.go b/internal/syncer/backend/backend.go new file mode 100644 index 0000000..0f83b63 --- /dev/null +++ b/internal/syncer/backend/backend.go @@ -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 +} diff --git a/internal/syncer/backend/git/auth.go b/internal/syncer/backend/git/auth.go new file mode 100644 index 0000000..85c79d1 --- /dev/null +++ b/internal/syncer/backend/git/auth.go @@ -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) + } +} diff --git a/internal/syncer/backend/git/backend.go b/internal/syncer/backend/git/backend.go new file mode 100644 index 0000000..35f04ae --- /dev/null +++ b/internal/syncer/backend/git/backend.go @@ -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") +} diff --git a/internal/syncer/backend/snapshotstore/backend.go b/internal/syncer/backend/snapshotstore/backend.go new file mode 100644 index 0000000..dc0e59b --- /dev/null +++ b/internal/syncer/backend/snapshotstore/backend.go @@ -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 +} diff --git a/internal/syncer/backend/snapshotstore/backend_test.go b/internal/syncer/backend/snapshotstore/backend_test.go new file mode 100644 index 0000000..e3831fa --- /dev/null +++ b/internal/syncer/backend/snapshotstore/backend_test.go @@ -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") + } +} diff --git a/internal/syncer/backend/snapshotstore/blob/blob.go b/internal/syncer/backend/snapshotstore/blob/blob.go new file mode 100644 index 0000000..4758992 --- /dev/null +++ b/internal/syncer/backend/snapshotstore/blob/blob.go @@ -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 +} diff --git a/internal/syncer/backend/snapshotstore/blob/localfs/store.go b/internal/syncer/backend/snapshotstore/blob/localfs/store.go new file mode 100644 index 0000000..12a1283 --- /dev/null +++ b/internal/syncer/backend/snapshotstore/blob/localfs/store.go @@ -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[:]) +} diff --git a/internal/syncer/backend/snapshotstore/blob/localfs/store_test.go b/internal/syncer/backend/snapshotstore/blob/localfs/store_test.go new file mode 100644 index 0000000..a56c567 --- /dev/null +++ b/internal/syncer/backend/snapshotstore/blob/localfs/store_test.go @@ -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") + } +} diff --git a/internal/syncer/config.go b/internal/syncer/config.go new file mode 100644 index 0000000..f02f00c --- /dev/null +++ b/internal/syncer/config.go @@ -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 + } +} diff --git a/internal/syncer/engine/sync_engine.go b/internal/syncer/engine/sync_engine.go new file mode 100644 index 0000000..174b602 --- /dev/null +++ b/internal/syncer/engine/sync_engine.go @@ -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 +} diff --git a/internal/syncer/errors.go b/internal/syncer/errors.go new file mode 100644 index 0000000..408158f --- /dev/null +++ b/internal/syncer/errors.go @@ -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") +) diff --git a/internal/syncer/merge/merger.go b/internal/syncer/merge/merger.go new file mode 100644 index 0000000..c89b61e --- /dev/null +++ b/internal/syncer/merge/merger.go @@ -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) +} diff --git a/internal/syncer/merge/updated_at_wins.go b/internal/syncer/merge/updated_at_wins.go new file mode 100644 index 0000000..f47652e --- /dev/null +++ b/internal/syncer/merge/updated_at_wins.go @@ -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 +} diff --git a/internal/syncer/merge/updated_at_wins_test.go b/internal/syncer/merge/updated_at_wins_test.go new file mode 100644 index 0000000..13bd779 --- /dev/null +++ b/internal/syncer/merge/updated_at_wins_test.go @@ -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) + } +} diff --git a/internal/syncer/resource/adapter.go b/internal/syncer/resource/adapter.go new file mode 100644 index 0000000..a994882 --- /dev/null +++ b/internal/syncer/resource/adapter.go @@ -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 +} diff --git a/internal/syncer/resource/document_adapter.go b/internal/syncer/resource/document_adapter.go new file mode 100644 index 0000000..2f6d8ed --- /dev/null +++ b/internal/syncer/resource/document_adapter.go @@ -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) +} diff --git a/internal/syncer/resource/extension_adapter.go b/internal/syncer/resource/extension_adapter.go new file mode 100644 index 0000000..ae2c4ad --- /dev/null +++ b/internal/syncer/resource/extension_adapter.go @@ -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) +} diff --git a/internal/syncer/resource/helpers.go b/internal/syncer/resource/helpers.go new file mode 100644 index 0000000..c333a77 --- /dev/null +++ b/internal/syncer/resource/helpers.go @@ -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) +} diff --git a/internal/syncer/resource/keybinding_adapter.go b/internal/syncer/resource/keybinding_adapter.go new file mode 100644 index 0000000..39d8200 --- /dev/null +++ b/internal/syncer/resource/keybinding_adapter.go @@ -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) +} diff --git a/internal/syncer/resource/theme_adapter.go b/internal/syncer/resource/theme_adapter.go new file mode 100644 index 0000000..2f56328 --- /dev/null +++ b/internal/syncer/resource/theme_adapter.go @@ -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) +} diff --git a/internal/syncer/scheduler/ticker.go b/internal/syncer/scheduler/ticker.go new file mode 100644 index 0000000..98c124b --- /dev/null +++ b/internal/syncer/scheduler/ticker.go @@ -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 +} diff --git a/internal/syncer/snapshot/snapshot.go b/internal/syncer/snapshot/snapshot.go new file mode 100644 index 0000000..31701d9 --- /dev/null +++ b/internal/syncer/snapshot/snapshot.go @@ -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 +} diff --git a/internal/syncer/snapshot/store.go b/internal/syncer/snapshot/store.go new file mode 100644 index 0000000..dddf428 --- /dev/null +++ b/internal/syncer/snapshot/store.go @@ -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, ¤t); 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) +} diff --git a/internal/syncer/snapshot/store_test.go b/internal/syncer/snapshot/store_test.go new file mode 100644 index 0000000..d6085dd --- /dev/null +++ b/internal/syncer/snapshot/store_test.go @@ -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) + } +} diff --git a/internal/syncer/types.go b/internal/syncer/types.go new file mode 100644 index 0000000..f328e67 --- /dev/null +++ b/internal/syncer/types.go @@ -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 +}