diff --git a/frontend/bindings/voidraft/internal/models/models.ts b/frontend/bindings/voidraft/internal/models/models.ts index 168179f..98f27ad 100644 --- a/frontend/bindings/voidraft/internal/models/models.ts +++ b/frontend/bindings/voidraft/internal/models/models.ts @@ -18,6 +18,11 @@ export class AppConfig { */ "editor": EditorConfig; + /** + * 文档配置 + */ + "document": DocumentConfig; + /** * 路径配置 */ @@ -33,6 +38,9 @@ export class AppConfig { if (!("editor" in $$source)) { this["editor"] = (new EditorConfig()); } + if (!("document" in $$source)) { + this["document"] = (new DocumentConfig()); + } if (!("paths" in $$source)) { this["paths"] = (new PathsConfig()); } @@ -50,15 +58,19 @@ export class AppConfig { const $$createField0_0 = $$createType0; const $$createField1_0 = $$createType1; const $$createField2_0 = $$createType2; + const $$createField3_0 = $$createType3; let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; if ("editor" in $$parsedSource) { $$parsedSource["editor"] = $$createField0_0($$parsedSource["editor"]); } + if ("document" in $$parsedSource) { + $$parsedSource["document"] = $$createField1_0($$parsedSource["document"]); + } if ("paths" in $$parsedSource) { - $$parsedSource["paths"] = $$createField1_0($$parsedSource["paths"]); + $$parsedSource["paths"] = $$createField2_0($$parsedSource["paths"]); } if ("metadata" in $$parsedSource) { - $$parsedSource["metadata"] = $$createField2_0($$parsedSource["metadata"]); + $$parsedSource["metadata"] = $$createField3_0($$parsedSource["metadata"]); } return new AppConfig($$parsedSource as Partial); } @@ -99,6 +111,127 @@ export class ConfigMetadata { } } +/** + * Document 表示一个文档 + */ +export class Document { + /** + * 元数据 + */ + "meta": DocumentMeta; + + /** + * 文档内容 + */ + "content": string; + + /** Creates a new Document instance. */ + constructor($$source: Partial = {}) { + if (!("meta" in $$source)) { + this["meta"] = (new DocumentMeta()); + } + if (!("content" in $$source)) { + this["content"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new Document instance from a string or object. + */ + static createFrom($$source: any = {}): Document { + const $$createField0_0 = $$createType4; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("meta" in $$parsedSource) { + $$parsedSource["meta"] = $$createField0_0($$parsedSource["meta"]); + } + return new Document($$parsedSource as Partial); + } +} + +/** + * DocumentConfig 定义文档配置 + */ +export class DocumentConfig { + /** + * 详细保存选项 + */ + "saveOptions": SaveOptions; + + /** Creates a new DocumentConfig instance. */ + constructor($$source: Partial = {}) { + if (!("saveOptions" in $$source)) { + this["saveOptions"] = (new SaveOptions()); + } + + Object.assign(this, $$source); + } + + /** + * Creates a new DocumentConfig instance from a string or object. + */ + static createFrom($$source: any = {}): DocumentConfig { + const $$createField0_0 = $$createType5; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("saveOptions" in $$parsedSource) { + $$parsedSource["saveOptions"] = $$createField0_0($$parsedSource["saveOptions"]); + } + return new DocumentConfig($$parsedSource as Partial); + } +} + +/** + * DocumentMeta 文档元数据 + */ +export class DocumentMeta { + /** + * 文档唯一标识 + */ + "id": string; + + /** + * 文档标题 + */ + "title": string; + + /** + * 最后更新时间 + */ + "lastUpdated": time$0.Time; + + /** + * 创建时间 + */ + "createdAt": time$0.Time; + + /** Creates a new DocumentMeta instance. */ + constructor($$source: Partial = {}) { + if (!("id" in $$source)) { + this["id"] = ""; + } + if (!("title" in $$source)) { + this["title"] = ""; + } + if (!("lastUpdated" in $$source)) { + this["lastUpdated"] = null; + } + if (!("createdAt" in $$source)) { + this["createdAt"] = null; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new DocumentMeta instance from a string or object. + */ + static createFrom($$source: any = {}): DocumentMeta { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new DocumentMeta($$parsedSource as Partial); + } +} + /** * EditorConfig 定义编辑器配置 */ @@ -221,6 +354,49 @@ export class PathsConfig { } } +/** + * SaveOptions 保存选项 + */ +export class SaveOptions { + /** + * 自动保存延迟(毫秒)- 内容变更后多久自动保存 + */ + "autoSaveDelay": number; + + /** + * 变更字符阈值,超过此阈值立即触发保存 + */ + "changeThreshold": number; + + /** + * 最小保存间隔(毫秒)- 两次保存之间的最小时间间隔,避免频繁IO + */ + "minSaveInterval": number; + + /** Creates a new SaveOptions instance. */ + constructor($$source: Partial = {}) { + if (!("autoSaveDelay" in $$source)) { + this["autoSaveDelay"] = 0; + } + if (!("changeThreshold" in $$source)) { + this["changeThreshold"] = 0; + } + if (!("minSaveInterval" in $$source)) { + this["minSaveInterval"] = 0; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new SaveOptions instance from a string or object. + */ + static createFrom($$source: any = {}): SaveOptions { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new SaveOptions($$parsedSource as Partial); + } +} + /** * TabType 定义了制表符类型 */ @@ -243,5 +419,8 @@ export enum TabType { // Private type creation functions const $$createType0 = EditorConfig.createFrom; -const $$createType1 = PathsConfig.createFrom; -const $$createType2 = ConfigMetadata.createFrom; +const $$createType1 = DocumentConfig.createFrom; +const $$createType2 = PathsConfig.createFrom; +const $$createType3 = ConfigMetadata.createFrom; +const $$createType4 = DocumentMeta.createFrom; +const $$createType5 = SaveOptions.createFrom; diff --git a/frontend/bindings/voidraft/internal/services/documentservice.ts b/frontend/bindings/voidraft/internal/services/documentservice.ts new file mode 100644 index 0000000..30021b7 --- /dev/null +++ b/frontend/bindings/voidraft/internal/services/documentservice.ts @@ -0,0 +1,142 @@ +// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL +// This file is automatically generated. DO NOT EDIT + +/** + * DocumentService 提供文档管理功能 + * @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 models$0 from "../models/models.js"; + +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore: Unused imports +import * as $models from "./models.js"; + +/** + * ForceSave 强制保存当前文档 + */ +export function ForceSave(): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(2767091023) as any; + return $resultPromise; +} + +/** + * GetActiveDocument 获取当前活动文档 + */ +export function GetActiveDocument(): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(1785823398) as any; + let $typingPromise = $resultPromise.then(($result: any) => { + return $$createType1($result); + }) as any; + $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); + return $typingPromise; +} + +/** + * GetActiveDocumentContent 获取当前活动文档内容 + */ +export function GetActiveDocumentContent(): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(922617063) as any; + return $resultPromise; +} + +/** + * GetDiffInfo 获取两个文本之间的详细差异信息 + */ +export function GetDiffInfo(oldText: string, newText: string): Promise<$models.DiffResult> & { cancel(): void } { + let $resultPromise = $Call.ByID(2490726526, oldText, newText) as any; + let $typingPromise = $resultPromise.then(($result: any) => { + return $$createType2($result); + }) as any; + $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); + return $typingPromise; +} + +/** + * GetSaveSettings 获取文档保存设置 + */ +export function GetSaveSettings(): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(4257471801) as any; + let $typingPromise = $resultPromise.then(($result: any) => { + return $$createType4($result); + }) as any; + $typingPromise.cancel = $resultPromise.cancel.bind($resultPromise); + return $typingPromise; +} + +/** + * Initialize 初始化文档服务 + */ +export function Initialize(): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(3418008221) as any; + return $resultPromise; +} + +/** + * LoadDefaultDocument 加载默认文档 + */ +export function LoadDefaultDocument(): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(2343023569) as any; + return $resultPromise; +} + +/** + * SaveDocumentSync 同步保存文档内容 (用于页面关闭前同步保存) + */ +export function SaveDocumentSync(content: string): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(3770207288, content) as any; + return $resultPromise; +} + +/** + * ServiceShutdown 实现应用程序关闭时的服务关闭逻辑 + */ +export function ServiceShutdown(): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(638578044) as any; + return $resultPromise; +} + +/** + * SetSaveCallback 设置保存回调函数 + */ +export function SetSaveCallback(callback: any): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(675315211, callback) as any; + return $resultPromise; +} + +/** + * Shutdown 关闭文档服务,确保所有数据保存 + */ +export function Shutdown(): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(3444504909) as any; + return $resultPromise; +} + +/** + * UpdateActiveDocumentContent 更新当前活动文档内容 + */ +export function UpdateActiveDocumentContent(content: string): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(1486276638, content) as any; + return $resultPromise; +} + +/** + * UpdateSaveSettings 更新文档保存设置 + */ +export function UpdateSaveSettings(docConfig: models$0.DocumentConfig): Promise & { cancel(): void } { + let $resultPromise = $Call.ByID(1245479534, docConfig) as any; + return $resultPromise; +} + +// Private type creation functions +const $$createType0 = models$0.Document.createFrom; +const $$createType1 = $Create.Nullable($$createType0); +const $$createType2 = $models.DiffResult.createFrom; +const $$createType3 = models$0.DocumentConfig.createFrom; +const $$createType4 = $Create.Nullable($$createType3); diff --git a/frontend/bindings/voidraft/internal/services/index.ts b/frontend/bindings/voidraft/internal/services/index.ts index 46882e0..bdd3b58 100644 --- a/frontend/bindings/voidraft/internal/services/index.ts +++ b/frontend/bindings/voidraft/internal/services/index.ts @@ -2,6 +2,10 @@ // This file is automatically generated. DO NOT EDIT import * as ConfigService from "./configservice.js"; +import * as DocumentService from "./documentservice.js"; export { - ConfigService + ConfigService, + DocumentService }; + +export * from "./models.js"; diff --git a/frontend/bindings/voidraft/internal/services/models.ts b/frontend/bindings/voidraft/internal/services/models.ts new file mode 100644 index 0000000..619e014 --- /dev/null +++ b/frontend/bindings/voidraft/internal/services/models.ts @@ -0,0 +1,141 @@ +// 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"; + +/** + * DiffResult 包含差异比较的结果信息 + */ +export class DiffResult { + /** + * 编辑操作列表 + */ + "Edits": Edit[]; + + /** + * 插入的字符数 + */ + "InsertCount": number; + + /** + * 删除的字符数 + */ + "DeleteCount": number; + + /** + * 变更的行数 + */ + "ChangedLines": number; + + /** + * 总变更字符数(插入+删除) + */ + "TotalChanges": number; + + /** + * 变更的token数(如单词、标识符等) + */ + "ChangedTokens": number; + + /** Creates a new DiffResult instance. */ + constructor($$source: Partial = {}) { + if (!("Edits" in $$source)) { + this["Edits"] = []; + } + if (!("InsertCount" in $$source)) { + this["InsertCount"] = 0; + } + if (!("DeleteCount" in $$source)) { + this["DeleteCount"] = 0; + } + if (!("ChangedLines" in $$source)) { + this["ChangedLines"] = 0; + } + if (!("TotalChanges" in $$source)) { + this["TotalChanges"] = 0; + } + if (!("ChangedTokens" in $$source)) { + this["ChangedTokens"] = 0; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new DiffResult instance from a string or object. + */ + static createFrom($$source: any = {}): DiffResult { + const $$createField0_0 = $$createType1; + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + if ("Edits" in $$parsedSource) { + $$parsedSource["Edits"] = $$createField0_0($$parsedSource["Edits"]); + } + return new DiffResult($$parsedSource as Partial); + } +} + +/** + * Edit 表示单个编辑操作 + */ +export class Edit { + /** + * 操作类型 + */ + "Type": EditType; + + /** + * 操作内容 + */ + "Content": string; + + /** Creates a new Edit instance. */ + constructor($$source: Partial = {}) { + if (!("Type" in $$source)) { + this["Type"] = (0 as EditType); + } + if (!("Content" in $$source)) { + this["Content"] = ""; + } + + Object.assign(this, $$source); + } + + /** + * Creates a new Edit instance from a string or object. + */ + static createFrom($$source: any = {}): Edit { + let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source; + return new Edit($$parsedSource as Partial); + } +} + +/** + * Edit 表示编辑操作类型 + */ +export enum EditType { + /** + * The Go zero value for the underlying type of the enum. + */ + $zero = 0, + + /** + * EditInsert 插入操作 + */ + EditInsert = 0, + + /** + * EditDelete 删除操作 + */ + EditDelete = 1, + + /** + * EditEqual 相等部分 + */ + EditEqual = 2, +}; + +// Private type creation functions +const $$createType0 = Edit.createFrom; +const $$createType1 = $Create.Array($$createType0); diff --git a/frontend/src/editor/Editor.vue b/frontend/src/editor/Editor.vue index ff6e4c0..ee68570 100644 --- a/frontend/src/editor/Editor.vue +++ b/frontend/src/editor/Editor.vue @@ -4,30 +4,46 @@ import {EditorState, Extension} from '@codemirror/state'; import {EditorView} from '@codemirror/view'; import {useEditorStore} from '@/stores/editorStore'; import {useConfigStore} from '@/stores/configStore'; +import {useDocumentStore} from '@/stores/documentStore'; +import {useLogStore} from '@/stores/logStore'; import {createBasicSetup} from './extensions/basicSetup'; import { createStatsUpdateExtension, createWheelZoomHandler, getTabExtensions, updateStats, - updateTabConfig + updateTabConfig, + createAutoSavePlugin, + createSaveShortcutPlugin, } from './extensions'; +import { useI18n } from 'vue-i18n'; +import { DocumentService } from '@/../bindings/voidraft/internal/services'; const editorStore = useEditorStore(); const configStore = useConfigStore(); +const documentStore = useDocumentStore(); +const logStore = useLogStore(); +const { t } = useI18n(); const props = defineProps({ initialDoc: { type: String, - default: '// 在此处编写文本...' + default: '' } }); const editorElement = ref(null); +const editorCreated = ref(false); +let isDestroying = false; // 创建编辑器 -const createEditor = () => { - if (!editorElement.value) return; +const createEditor = async () => { + if (!editorElement.value || editorCreated.value) return; + editorCreated.value = true; + + // 加载文档内容 + await documentStore.initialize(); + const docContent = documentStore.documentContent || props.initialDoc; // 获取基本扩展 const basicExtensions = createBasicSetup(); @@ -44,16 +60,35 @@ const createEditor = () => { editorStore.updateDocumentStats ); + // 创建保存快捷键插件 + const saveShortcutPlugin = createSaveShortcutPlugin(() => { + if (editorStore.editorView) { + handleManualSave(); + } + }); + + // 创建自动保存插件 + const autoSavePlugin = createAutoSavePlugin({ + debounceDelay: 300, // 300毫秒的输入防抖 + onSave: (success) => { + if (success) { + documentStore.lastSaved = new Date(); + } + } + }); + // 组合所有扩展 const extensions: Extension[] = [ ...basicExtensions, ...tabExtensions, - statsExtension + statsExtension, + saveShortcutPlugin, + autoSavePlugin ]; // 创建编辑器状态 const state = EditorState.create({ - doc: props.initialDoc, + doc: docContent, extensions }); @@ -71,6 +106,7 @@ const createEditor = () => { // 立即更新统计信息,不等待用户交互 updateStats(view, editorStore.updateDocumentStats); + }; // 创建滚轮事件处理器 @@ -79,6 +115,20 @@ const handleWheel = createWheelZoomHandler( configStore.decreaseFontSize ); +// 手动保存文档 +const handleManualSave = async () => { + if (!editorStore.editorView || isDestroying) return; + + const view = editorStore.editorView as EditorView; + const content = view.state.doc.toString(); + + // 使用文档存储的强制保存方法 + const success = await documentStore.forceSaveDocument(content); + if (success) { + logStore.info(t('document.manualSaveSuccess')); + } +}; + // 重新配置编辑器(仅在必要时) const reconfigureTabSettings = () => { if (!editorStore.editorView) return; @@ -118,12 +168,14 @@ onMounted(() => { }); onBeforeUnmount(() => { + isDestroying = true; + // 移除滚轮事件监听 if (editorElement.value) { editorElement.value.removeEventListener('wheel', handleWheel); } - // 销毁编辑器 + // 直接销毁编辑器 if (editorStore.editorView) { editorStore.editorView.destroy(); editorStore.setEditorView(null); diff --git a/frontend/src/editor/extensions/autoSaveExtension.ts b/frontend/src/editor/extensions/autoSaveExtension.ts new file mode 100644 index 0000000..d51c30b --- /dev/null +++ b/frontend/src/editor/extensions/autoSaveExtension.ts @@ -0,0 +1,117 @@ +import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'; +import { DocumentService } from '@/../bindings/voidraft/internal/services'; +import { useDebounceFn } from '@vueuse/core'; + +// 定义自动保存配置选项 +export interface AutoSaveOptions { + // 保存回调 + onSave?: (success: boolean) => void; + // 内容变更延迟传递(毫秒)- 输入时不会立即发送,有一个小延迟,避免频繁调用后端 + debounceDelay?: number; +} + +/** + * 创建自动保存插件 + * @param options 配置选项 + * @returns EditorView.Plugin + */ +export function createAutoSavePlugin(options: AutoSaveOptions = {}) { + const { + onSave = () => {}, + debounceDelay = 1000 // 默认1000ms延迟,原为300ms + } = options; + + return ViewPlugin.fromClass( + class { + private isActive: boolean = true; + private isSaving: boolean = false; + private contentUpdateFn: (view: EditorView) => void; + + constructor(private view: EditorView) { + // 创建内容更新函数 + this.contentUpdateFn = this.createDebouncedUpdateFn(debounceDelay); + } + + /** + * 创建防抖的内容更新函数 + */ + private createDebouncedUpdateFn(delay: number): (view: EditorView) => void { + // 使用VueUse的防抖函数创建一个新函数 + return useDebounceFn(async (view: EditorView) => { + // 如果插件已不活跃或正在保存中,不发送 + if (!this.isActive || this.isSaving) return; + + this.isSaving = true; + const content = view.state.doc.toString(); + + try { + await DocumentService.UpdateActiveDocumentContent(content); + onSave(true); + } catch (err) { + console.error('Failed to update document content:', err); + onSave(false); + } finally { + this.isSaving = false; + } + }, delay); + } + + update(update: ViewUpdate) { + // 如果内容没有变化,直接返回 + if (!update.docChanged) return; + + // 调用防抖函数 + this.contentUpdateFn(this.view); + } + + destroy() { + // 标记插件不再活跃 + this.isActive = false; + + // 直接发送最终内容 + const content = this.view.state.doc.toString(); + DocumentService.UpdateActiveDocumentContent(content) + .then(() => console.log('Successfully sent final content on destroy')) + .catch(err => console.error('Failed to send content on destroy:', err)); + } + } + ); +} + +/** + * 创建处理保存快捷键的插件 + * @param onSave 保存回调 + * @returns EditorView.Plugin + */ +export function createSaveShortcutPlugin(onSave: () => void) { + return EditorView.domEventHandlers({ + keydown: (event) => { + // Ctrl+S / Cmd+S + if ((event.ctrlKey || event.metaKey) && event.key === 's') { + event.preventDefault(); + onSave(); + return true; + } + return false; + } + }); +} + +/** + * 手动触发文档保存 + * @param view 编辑器视图 + * @returns Promise + */ +export async function saveDocument(view: EditorView): Promise { + try { + const content = view.state.doc.toString(); + // 更新内容 + await DocumentService.UpdateActiveDocumentContent(content); + // 强制保存到磁盘 + await DocumentService.ForceSave(); + return true; + } catch (err) { + console.error('Failed to save document:', err); + return false; + } +} \ No newline at end of file diff --git a/frontend/src/editor/extensions/index.ts b/frontend/src/editor/extensions/index.ts index 233c8ce..22f2480 100644 --- a/frontend/src/editor/extensions/index.ts +++ b/frontend/src/editor/extensions/index.ts @@ -1,4 +1,5 @@ // 统一导出所有扩展 export * from './tabExtension'; export * from './wheelZoomExtension'; -export * from './statsExtension'; \ No newline at end of file +export * from './statsExtension'; +export * from './autoSaveExtension'; \ 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 f5497a0..73472ce 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -31,5 +31,17 @@ export default { languages: { 'zh-CN': '简体中文', 'en-US': 'English' + }, + document: { + loadSuccess: 'Document loaded successfully', + loadFailed: 'Failed to load document', + saveSuccess: 'Document saved successfully', + saveFailed: 'Failed to save document', + manualSaveSuccess: 'Manually saved successfully', + settings: { + loadFailed: 'Failed to load save settings', + saveSuccess: 'Save settings updated', + saveFailed: 'Failed to update save settings' + } } }; \ No newline at end of file diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index b4341d3..bd84d07 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -31,5 +31,17 @@ export default { languages: { 'zh-CN': '简体中文', 'en-US': 'English' + }, + document: { + loadSuccess: '文档加载成功', + loadFailed: '文档加载失败', + saveSuccess: '文档保存成功', + saveFailed: '文档保存失败', + manualSaveSuccess: '手动保存成功', + settings: { + loadFailed: '加载保存设置失败', + saveSuccess: '保存设置已更新', + saveFailed: '保存设置更新失败' + } } }; \ No newline at end of file diff --git a/frontend/src/stores/documentStore.ts b/frontend/src/stores/documentStore.ts new file mode 100644 index 0000000..048783c --- /dev/null +++ b/frontend/src/stores/documentStore.ts @@ -0,0 +1,123 @@ +import {defineStore} from 'pinia'; +import {computed, ref} from 'vue'; +import {DocumentService} from '@/../bindings/voidraft/internal/services'; +import {Document} from '@/../bindings/voidraft/internal/models/models'; +import {useLogStore} from './logStore'; +import {useI18n} from 'vue-i18n'; + +export const useDocumentStore = defineStore('document', () => { + const logStore = useLogStore(); + const { t } = useI18n(); + + // 状态 + const activeDocument = ref(null); + const isLoading = ref(false); + const isSaving = ref(false); + const lastSaved = ref(null); + + // 计算属性 + const documentContent = computed(() => activeDocument.value?.content || ''); + const documentTitle = computed(() => activeDocument.value?.meta?.title || ''); + const hasActiveDocument = computed(() => !!activeDocument.value); + const isSaveInProgress = computed(() => isSaving.value); + const lastSavedTime = computed(() => lastSaved.value); + + // 加载文档 + async function loadDocument() { + if (isLoading.value) return; + + isLoading.value = true; + try { + activeDocument.value = await DocumentService.GetActiveDocument(); + logStore.info(t('document.loadSuccess')); + } catch (err) { + console.error('Failed to load document:', err); + logStore.error(t('document.loadFailed')); + activeDocument.value = null; + } finally { + isLoading.value = false; + } + } + + // 保存文档 + async function saveDocument(content: string): Promise { + if (isSaving.value) return false; + + isSaving.value = true; + try { + await DocumentService.UpdateActiveDocumentContent(content); + lastSaved.value = new Date(); + + // 如果我们有活动文档,更新本地副本 + if (activeDocument.value) { + activeDocument.value.content = content; + activeDocument.value.meta.lastUpdated = lastSaved.value; + } + + logStore.info(t('document.saveSuccess')); + return true; + } catch (err) { + console.error('Failed to save document:', err); + logStore.error(t('document.saveFailed')); + return false; + } finally { + isSaving.value = false; + } + } + + // 强制保存文档到磁盘 + async function forceSaveDocument(content: string): Promise { + if (isSaving.value) return false; + + isSaving.value = true; + try { + // 先更新内容 + await DocumentService.UpdateActiveDocumentContent(content); + // 然后强制保存 + await DocumentService.ForceSave(); + + lastSaved.value = new Date(); + + // 如果我们有活动文档,更新本地副本 + if (activeDocument.value) { + activeDocument.value.content = content; + activeDocument.value.meta.lastUpdated = lastSaved.value; + } + + logStore.info(t('document.manualSaveSuccess')); + return true; + } catch (err) { + console.error('Failed to force save document:', err); + logStore.error(t('document.saveFailed')); + return false; + } finally { + isSaving.value = false; + } + } + + // 初始化 + async function initialize() { + await loadDocument(); + } + + return { + // 状态 + activeDocument, + isLoading, + isSaving, + lastSaved, + + // 计算属性 + documentContent, + documentTitle, + hasActiveDocument, + isSaveInProgress, + lastSavedTime, + + // 方法 + loadDocument, + saveDocument, + forceSaveDocument, + initialize + }; +}); \ No newline at end of file diff --git a/internal/models/config.go b/internal/models/config.go index bd395a0..cdaed00 100644 --- a/internal/models/config.go +++ b/internal/models/config.go @@ -16,6 +16,21 @@ const ( TabTypeTab TabType = "tab" ) +// SaveOptions 保存选项 +type SaveOptions struct { + // 自动保存延迟(毫秒)- 内容变更后多久自动保存 + AutoSaveDelay int `json:"autoSaveDelay"` + // 变更字符阈值,超过此阈值立即触发保存 + ChangeThreshold int `json:"changeThreshold"` + // 最小保存间隔(毫秒)- 两次保存之间的最小时间间隔,避免频繁IO + MinSaveInterval int `json:"minSaveInterval"` +} + +// DocumentConfig 定义文档配置 +type DocumentConfig struct { + SaveOptions SaveOptions `json:"saveOptions"` // 详细保存选项 +} + // EditorConfig 定义编辑器配置 type EditorConfig struct { FontSize int `json:"fontSize"` // 字体大小 @@ -45,6 +60,7 @@ type PathsConfig struct { // AppConfig 应用配置 - 包含业务配置和路径配置 type AppConfig struct { Editor EditorConfig `json:"editor"` // 编辑器配置 + Document DocumentConfig `json:"document"` // 文档配置 Paths PathsConfig `json:"paths"` // 路径配置 Metadata ConfigMetadata `json:"metadata"` // 配置元数据 } @@ -65,6 +81,7 @@ func NewDefaultAppConfig() *AppConfig { // 默认路径配置 rootDir := filepath.Join(homePath, ".voidraft") + dataDir := filepath.Join(rootDir, "data") return &AppConfig{ Editor: EditorConfig{ @@ -75,9 +92,16 @@ func NewDefaultAppConfig() *AppConfig { Language: LangZhCN, AlwaysOnTop: false, }, + Document: DocumentConfig{ + SaveOptions: SaveOptions{ + AutoSaveDelay: 5000, // 5秒后自动保存 + ChangeThreshold: 500, // 500个字符变更触发保存 + MinSaveInterval: 1000, // 最小间隔1000毫秒 + }, + }, Paths: PathsConfig{ LogPath: filepath.Join(rootDir, "logs"), - DataPath: filepath.Join(rootDir, "data"), + DataPath: dataDir, }, Metadata: ConfigMetadata{ Version: "1.0.0", diff --git a/internal/models/document.go b/internal/models/document.go new file mode 100644 index 0000000..db788da --- /dev/null +++ b/internal/models/document.go @@ -0,0 +1,41 @@ +package models + +import ( + "time" +) + +// DocumentMeta 文档元数据 +type DocumentMeta struct { + ID string `json:"id"` // 文档唯一标识 + Title string `json:"title"` // 文档标题 + LastUpdated time.Time `json:"lastUpdated"` // 最后更新时间 + CreatedAt time.Time `json:"createdAt"` // 创建时间 +} + +// Document 表示一个文档 +type Document struct { + Meta DocumentMeta `json:"meta"` // 元数据 + Content string `json:"content"` // 文档内容 +} + +// DocumentInfo 文档信息(不包含内容,用于列表展示) +type DocumentInfo struct { + ID string `json:"id"` // 文档ID + Title string `json:"title"` // 文档标题 + LastUpdated time.Time `json:"lastUpdated"` // 最后更新时间 + Path string `json:"path"` // 文档路径 +} + +// NewDefaultDocument 创建默认文档 +func NewDefaultDocument() *Document { + now := time.Now() + return &Document{ + Meta: DocumentMeta{ + ID: "default", + Title: "默认文档", + LastUpdated: now, + CreatedAt: now, + }, + Content: "// 在此处编写文本...", + } +} diff --git a/internal/services/config_service.go b/internal/services/config_service.go index 57b17b1..eaba9c7 100644 --- a/internal/services/config_service.go +++ b/internal/services/config_service.go @@ -35,9 +35,8 @@ func (p *DefaultConfigPathProvider) GetConfigPath() string { // ConfigOption 配置服务选项 type ConfigOption struct { - Logger *log.LoggerService // 日志服务 - PathProvider ConfigPath // 路径提供者 - AutoSaveEnabled bool // 是否启用自动保存 + Logger *log.LoggerService // 日志服务 + PathProvider ConfigPath // 路径提供者 } // ConfigService 提供配置管理功能 @@ -98,7 +97,6 @@ func NewConfigService(opt ...ConfigOption) *ConfigService { // 创建存储 store := NewStore[models.AppConfig](StoreOption{ FilePath: configPath, - AutoSave: option.AutoSaveEnabled, Logger: logger, }) diff --git a/internal/services/document_diff.go b/internal/services/document_diff.go new file mode 100644 index 0000000..b53a8b1 --- /dev/null +++ b/internal/services/document_diff.go @@ -0,0 +1,306 @@ +package services + +// Edit 表示编辑操作类型 +type EditType int + +const ( + // EditInsert 插入操作 + EditInsert EditType = iota + // EditDelete 删除操作 + EditDelete + // EditEqual 相等部分 + EditEqual +) + +// Edit 表示单个编辑操作 +type Edit struct { + Type EditType // 操作类型 + Content string // 操作内容 +} + +// DiffResult 包含差异比较的结果信息 +type DiffResult struct { + Edits []Edit // 编辑操作列表 + InsertCount int // 插入的字符数 + DeleteCount int // 删除的字符数 + ChangedLines int // 变更的行数 + TotalChanges int // 总变更字符数(插入+删除) + ChangedTokens int // 变更的token数(如单词、标识符等) +} + +// calculateChangesDetailed 使用Myers差分算法计算两个字符串之间的具体变更 +func calculateChangesDetailed(oldText, newText string) DiffResult { + // 将文本分割成行 + oldLines := splitLines(oldText) + newLines := splitLines(newText) + + // 计算行级别的差异 + edits := computeLineEdits(oldLines, newLines) + + // 计算变更统计 + result := DiffResult{ + Edits: edits, + } + + // 统计变更 + for _, edit := range edits { + switch edit.Type { + case EditInsert: + result.InsertCount += len(edit.Content) + result.ChangedLines++ + case EditDelete: + result.DeleteCount += len(edit.Content) + result.ChangedLines++ + } + } + + result.TotalChanges = result.InsertCount + result.DeleteCount + result.ChangedTokens = estimateChangedTokens(edits) + + return result +} + +// splitLines 将文本分割成行 +func splitLines(text string) []string { + var lines []string + var currentLine string + + for _, char := range text { + if char == '\n' { + lines = append(lines, currentLine) + currentLine = "" + } else { + currentLine += string(char) + } + } + + // 添加最后一行(如果不是以换行符结尾) + if currentLine != "" { + lines = append(lines, currentLine) + } + + return lines +} + +// computeLineEdits 使用Myers差分算法计算行级别的差异 +func computeLineEdits(oldLines, newLines []string) []Edit { + var edits []Edit + + // 使用Myers差分算法计算行级别的差异 + script := myersDiff(oldLines, newLines) + + // 将差异脚本转换为编辑操作 + for _, op := range script { + switch op.Type { + case EditEqual: + edits = append(edits, Edit{ + Type: EditEqual, + Content: oldLines[op.OldStart], + }) + case EditDelete: + edits = append(edits, Edit{ + Type: EditDelete, + Content: oldLines[op.OldStart], + }) + case EditInsert: + edits = append(edits, Edit{ + Type: EditInsert, + Content: newLines[op.NewStart], + }) + } + } + + return edits +} + +// DiffOp 表示差分操作 +type DiffOp struct { + Type EditType + OldStart int + OldEnd int + NewStart int + NewEnd int +} + +// myersDiff 实现Myers差分算法 +func myersDiff(oldLines, newLines []string) []DiffOp { + // 基本思路:Myers差分算法通过建立编辑图来寻找最短编辑路径 + // 简化版实现 + var script []DiffOp + + oldLen := len(oldLines) + newLen := len(newLines) + + // 使用动态规划找出最长公共子序列(LCS) + lcs := longestCommonSubsequence(oldLines, newLines) + + // 根据LCS构建差分脚本 + oldIndex, newIndex := 0, 0 + for _, entry := range lcs { + // 处理LCS之前的差异 + for oldIndex < entry.OldIndex { + script = append(script, DiffOp{ + Type: EditDelete, + OldStart: oldIndex, + OldEnd: oldIndex + 1, + NewStart: newIndex, + NewEnd: newIndex, + }) + oldIndex++ + } + + for newIndex < entry.NewIndex { + script = append(script, DiffOp{ + Type: EditInsert, + OldStart: oldIndex, + OldEnd: oldIndex, + NewStart: newIndex, + NewEnd: newIndex + 1, + }) + newIndex++ + } + + // 处理相等部分 + script = append(script, DiffOp{ + Type: EditEqual, + OldStart: oldIndex, + OldEnd: oldIndex + 1, + NewStart: newIndex, + NewEnd: newIndex + 1, + }) + + oldIndex++ + newIndex++ + } + + // 处理剩余差异 + for oldIndex < oldLen { + script = append(script, DiffOp{ + Type: EditDelete, + OldStart: oldIndex, + OldEnd: oldIndex + 1, + NewStart: newIndex, + NewEnd: newIndex, + }) + oldIndex++ + } + + for newIndex < newLen { + script = append(script, DiffOp{ + Type: EditInsert, + OldStart: oldIndex, + OldEnd: oldIndex, + NewStart: newIndex, + NewEnd: newIndex + 1, + }) + newIndex++ + } + + return script +} + +// LCSEntry 表示最长公共子序列中的一个条目 +type LCSEntry struct { + OldIndex int + NewIndex int +} + +// longestCommonSubsequence 寻找两个字符串数组的最长公共子序列 +func longestCommonSubsequence(oldLines, newLines []string) []LCSEntry { + oldLen := len(oldLines) + newLen := len(newLines) + + // 创建动态规划表 + dp := make([][]int, oldLen+1) + for i := range dp { + dp[i] = make([]int, newLen+1) + } + + // 填充DP表 + for i := 1; i <= oldLen; i++ { + for j := 1; j <= newLen; j++ { + if oldLines[i-1] == newLines[j-1] { + dp[i][j] = dp[i-1][j-1] + 1 + } else { + dp[i][j] = max(dp[i-1][j], dp[i][j-1]) + } + } + } + + // 回溯找出LCS + var lcs []LCSEntry + i, j := oldLen, newLen + for i > 0 && j > 0 { + if oldLines[i-1] == newLines[j-1] { + lcs = append([]LCSEntry{{OldIndex: i - 1, NewIndex: j - 1}}, lcs...) + i-- + j-- + } else if dp[i-1][j] > dp[i][j-1] { + i-- + } else { + j-- + } + } + + return lcs +} + +// max 返回两个整数中的较大值 +func max(a, b int) int { + if a > b { + return a + } + return b +} + +// estimateChangedTokens 估计变更的token数量 +// 这里使用简单的单词分割来估计 +func estimateChangedTokens(edits []Edit) int { + tokenCount := 0 + + for _, edit := range edits { + switch edit.Type { + case EditInsert, EditDelete: + // 简单地将内容按空白字符分割成单词 + words := splitIntoWords(edit.Content) + tokenCount += len(words) + } + } + + return tokenCount +} + +// splitIntoWords 将文本分割成单词 +func splitIntoWords(text string) []string { + var words []string + var currentWord string + + // 简单的状态机: + // - 如果是字母、数字或下划线,添加到当前单词 + // - 否则,结束当前单词并开始新单词 + for _, char := range text { + if isWordChar(char) { + currentWord += string(char) + } else { + if currentWord != "" { + words = append(words, currentWord) + currentWord = "" + } + } + } + + // 添加最后一个单词(如果有) + if currentWord != "" { + words = append(words, currentWord) + } + + return words +} + +// isWordChar 判断字符是否是单词字符(字母、数字或下划线) +func isWordChar(char rune) bool { + return (char >= 'a' && char <= 'z') || + (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || + char == '_' +} diff --git a/internal/services/document_service.go b/internal/services/document_service.go new file mode 100644 index 0000000..4fa1d20 --- /dev/null +++ b/internal/services/document_service.go @@ -0,0 +1,592 @@ +package services + +import ( + "errors" + "fmt" + "github.com/wailsapp/wails/v3/pkg/services/log" + "os" + "path/filepath" + "sync" + "time" + "voidraft/internal/models" +) + +// SaveTrigger 保存触发器类型 +type SaveTrigger int + +const ( + // SaveTriggerAuto 自动保存 + SaveTriggerAuto SaveTrigger = iota + // SaveTriggerManual 手动触发保存 + SaveTriggerManual + // SaveTriggerThreshold 超过阈值触发保存 + SaveTriggerThreshold + // SaveTriggerShutdown 程序关闭触发保存 + SaveTriggerShutdown +) + +// DocumentError 文档操作错误 +type DocumentError struct { + Operation string // 操作名称 + Err error // 原始错误 +} + +// Error 实现error接口 +func (e *DocumentError) Error() string { + return fmt.Sprintf("document error during %s: %v", e.Operation, e.Err) +} + +// Unwrap 获取原始错误 +func (e *DocumentError) Unwrap() error { + return e.Err +} + +// DocumentService 提供文档管理功能 +type DocumentService struct { + configService *ConfigService + logger *log.LoggerService + activeDoc *models.Document + docStore *Store[models.Document] + memoryCache *models.Document // 内存缓存,小改动只更新此缓存 + lock sync.RWMutex + lastSaveTime time.Time + changeCounter int // 变更计数器,记录自上次保存后的变更数量 + saveTimer *time.Timer // 自动保存定时器 + pendingSave bool // 是否有等待保存的更改 + saveChannel chan SaveTrigger // 保存通道,用于接收保存触发信号 + shutdownChan chan struct{} // 关闭通道,用于程序退出时通知保存协程 + shutdownWg sync.WaitGroup // 等待组,用于确保保存协程正常退出 + onSaveCallback func(trigger SaveTrigger) // 保存回调函数 +} + +// NewDocumentService 创建新的文档服务实例 +func NewDocumentService(configService *ConfigService, logger *log.LoggerService) *DocumentService { + if logger == nil { + logger = log.New() + } + + service := &DocumentService{ + configService: configService, + logger: logger, + saveChannel: make(chan SaveTrigger, 10), + shutdownChan: make(chan struct{}), + lastSaveTime: time.Now(), + } + + return service +} + +// Initialize 初始化文档服务 +func (ds *DocumentService) Initialize() error { + // 确保文档目录存在 + err := ds.ensureDocumentsDir() + if err != nil { + ds.logger.Error("Document: Failed to ensure documents directory", "error", err) + return &DocumentError{Operation: "initialize", Err: err} + } + + // 初始化文档存储 + err = ds.initDocumentStore() + if err != nil { + ds.logger.Error("Document: Failed to initialize document store", "error", err) + return &DocumentError{Operation: "init_store", Err: err} + } + + // 加载默认文档 + err = ds.LoadDefaultDocument() + if err != nil { + ds.logger.Error("Document: Failed to load default document", "error", err) + return &DocumentError{Operation: "load_default", Err: err} + } + + // 启动保存处理协程 + ds.startSaveProcessor() + + return nil +} + +// startSaveProcessor 启动保存处理协程 +func (ds *DocumentService) startSaveProcessor() { + ds.shutdownWg.Add(1) + go func() { + defer ds.shutdownWg.Done() + + for { + select { + case trigger := <-ds.saveChannel: + // 接收到保存信号,执行保存 + ds.saveToStore(trigger) + case <-ds.shutdownChan: + // 接收到关闭信号,保存并退出 + if ds.pendingSave { + ds.saveToStore(SaveTriggerShutdown) + } + return + } + } + }() +} + +// scheduleAutoSave 安排自动保存 +func (ds *DocumentService) scheduleAutoSave() { + // 获取配置 + config, err := ds.configService.GetConfig() + if err != nil { + ds.logger.Error("Document: Failed to get config for auto save", "error", err) + // 使用默认值2秒 + ds.scheduleTimerWithDelay(2000) + return + } + + // 检查配置有效性 + if config == nil { + ds.logger.Error("Document: Config is nil, using default delay") + ds.scheduleTimerWithDelay(2000) + return + } + + // 打印保存设置,便于调试 + ds.logger.Debug("Document: Auto save settings", + "autoSaveDelay", config.Document.SaveOptions.AutoSaveDelay, + "changeThreshold", config.Document.SaveOptions.ChangeThreshold, + "minSaveInterval", config.Document.SaveOptions.MinSaveInterval) + + ds.lock.Lock() + defer ds.lock.Unlock() + + // 重置自动保存定时器 + if ds.saveTimer != nil { + ds.saveTimer.Stop() + } + + // 创建新的自动保存定时器 + autoSaveDelay := config.Document.SaveOptions.AutoSaveDelay + ds.logger.Debug("Document: Scheduling auto save", "delay", autoSaveDelay) + ds.scheduleTimerWithDelay(autoSaveDelay) +} + +// scheduleTimerWithDelay 使用指定延迟创建定时器 +func (ds *DocumentService) scheduleTimerWithDelay(delayMs int) { + ds.saveTimer = time.AfterFunc(time.Duration(delayMs)*time.Millisecond, func() { + // 只有在有待保存的更改时才触发保存 + if ds.pendingSave { + ds.saveChannel <- SaveTriggerAuto + } + }) +} + +// saveToStore 保存文档到存储 +func (ds *DocumentService) saveToStore(trigger SaveTrigger) { + ds.lock.Lock() + defer ds.lock.Unlock() + + // 如果没有内存缓存或活动文档,直接返回 + if ds.memoryCache == nil || ds.activeDoc == nil { + return + } + + // 获取配置 + config, err := ds.configService.GetConfig() + if err != nil { + ds.logger.Error("Document: Failed to get config for save", "error", err) + // 继续使用默认值 + } + + // 设置默认值 + minInterval := 500 // 默认500毫秒 + + // 如果成功获取了配置,使用配置值 + if err == nil && config != nil { + minInterval = config.Document.SaveOptions.MinSaveInterval + } + + // 如果是自动保存,检查最小保存间隔 + if trigger == SaveTriggerAuto { + now := time.Now() + elapsed := now.Sub(ds.lastSaveTime).Milliseconds() + + // 如果距离上次保存时间太短,重新安排保存 + if elapsed < int64(minInterval) { + // 重新安排保存,延迟 = 最小间隔 - 已经过的时间 + delayMs := minInterval - int(elapsed) + ds.logger.Debug("Document: Rescheduling save due to min interval", + "minInterval", minInterval, + "elapsed", elapsed, + "delayMs", delayMs) + + ds.lock.Unlock() // 解锁后再启动定时器,避免死锁 + ds.scheduleTimerWithDelay(delayMs) + ds.lock.Lock() // 恢复锁 + return + } + } + + // 更新活动文档 + ds.activeDoc = ds.memoryCache + ds.activeDoc.Meta.LastUpdated = time.Now() + + // 保存到存储 + ds.logger.Info("Document: Saving document to disk", + "trigger", trigger, + "id", ds.activeDoc.Meta.ID, + "contentLength", len(ds.activeDoc.Content)) + + err = ds.docStore.Set(*ds.activeDoc) + if err != nil { + ds.logger.Error("Document: Failed to save document", "trigger", trigger, "error", err) + return + } + + // 强制确保保存到磁盘 + err = ds.docStore.Save() + if err != nil { + ds.logger.Error("Document: Failed to force save document", "trigger", trigger, "error", err) + return + } + + // 重置计数器和状态 + ds.changeCounter = 0 + ds.pendingSave = false + ds.lastSaveTime = time.Now() + + // 触发回调 + if ds.onSaveCallback != nil { + ds.onSaveCallback(trigger) + } + + ds.logger.Info("Document: Saved document", "trigger", trigger, "id", ds.activeDoc.Meta.ID) +} + +// Shutdown 关闭文档服务,确保所有数据保存 +func (ds *DocumentService) Shutdown() { + // 发送关闭信号 + close(ds.shutdownChan) + + // 等待保存协程退出 + ds.shutdownWg.Wait() + + // 停止定时器 + if ds.saveTimer != nil { + ds.saveTimer.Stop() + } + + ds.logger.Info("Document: Service shutdown completed") +} + +// SetSaveCallback 设置保存回调函数 +func (ds *DocumentService) SetSaveCallback(callback func(trigger SaveTrigger)) { + ds.onSaveCallback = callback +} + +// initDocumentStore 初始化文档存储 +func (ds *DocumentService) initDocumentStore() error { + docPath, err := ds.getDefaultDocumentPath() + if err != nil { + return err + } + + ds.logger.Info("Document: Initializing document store", "path", docPath) + + // 创建文档存储,强制保存和Service触发的保存都使用同步保存到磁盘 + ds.docStore = NewStore[models.Document](StoreOption{ + FilePath: docPath, + AutoSave: true, // 启用自动保存,确保Set操作直接写入磁盘 + Logger: ds.logger, + }) + + return nil +} + +// ensureDocumentsDir 确保文档目录存在 +func (ds *DocumentService) ensureDocumentsDir() error { + config, err := ds.configService.GetConfig() + if err != nil { + return err + } + + // 创建文档目录 + docsDir := filepath.Join(config.Paths.DataPath, "docs") + err = os.MkdirAll(docsDir, 0755) + if err != nil { + return err + } + + return nil +} + +// getDocumentsDir 获取文档目录路径 +func (ds *DocumentService) getDocumentsDir() (string, error) { + config, err := ds.configService.GetConfig() + if err != nil { + return "", err + } + return filepath.Join(config.Paths.DataPath, "docs"), nil +} + +// getDefaultDocumentPath 获取默认文档路径 +func (ds *DocumentService) getDefaultDocumentPath() (string, error) { + docsDir, err := ds.getDocumentsDir() + if err != nil { + return "", err + } + return filepath.Join(docsDir, "default.json"), nil +} + +// LoadDefaultDocument 加载默认文档 +func (ds *DocumentService) LoadDefaultDocument() error { + // 从Store加载文档 + doc := ds.docStore.Get() + + // 检查文档是否有效 + if doc.Meta.ID == "" { + // 创建默认文档 + defaultDoc := models.NewDefaultDocument() + ds.lock.Lock() + ds.activeDoc = defaultDoc + ds.memoryCache = defaultDoc // 同时更新内存缓存 + ds.lock.Unlock() + + // 保存默认文档 + err := ds.docStore.Set(*defaultDoc) + if err != nil { + return &DocumentError{Operation: "save_default", Err: err} + } + + ds.logger.Info("Document: Created and saved default document") + return nil + } + + // 设置为活动文档 + ds.lock.Lock() + ds.activeDoc = &doc + ds.memoryCache = &doc // 同时更新内存缓存 + ds.lock.Unlock() + + ds.logger.Info("Document: Loaded default document", "id", doc.Meta.ID) + return nil +} + +// GetActiveDocument 获取当前活动文档 +func (ds *DocumentService) GetActiveDocument() (*models.Document, error) { + ds.lock.RLock() + defer ds.lock.RUnlock() + + if ds.memoryCache == nil { + return nil, errors.New("no active document loaded") + } + + // 返回内存缓存中的文档,确保获得最新版本 + return ds.memoryCache, nil +} + +// GetActiveDocumentContent 获取当前活动文档内容 +func (ds *DocumentService) GetActiveDocumentContent() (string, error) { + ds.lock.RLock() + defer ds.lock.RUnlock() + + if ds.memoryCache == nil { + return "", errors.New("no active document loaded") + } + + return ds.memoryCache.Content, nil +} + +// UpdateActiveDocumentContent 更新当前活动文档内容 +func (ds *DocumentService) UpdateActiveDocumentContent(content string) error { + // 获取配置 + config, err := ds.configService.GetConfig() + if err != nil { + ds.logger.Error("Document: Failed to get config for content update", "error", err) + // 出错时仍继续,使用默认行为 + } + + // 设置默认配置值 + threshold := 100 // 默认值 + + // 如果成功获取了配置,使用配置值 + if err == nil && config != nil { + threshold = config.Document.SaveOptions.ChangeThreshold + } + + ds.lock.Lock() + + if ds.memoryCache == nil { + ds.lock.Unlock() + return errors.New("no active document loaded") + } + + // 计算变更数量 + oldContent := ds.memoryCache.Content + changedChars := calculateChanges(oldContent, content) + ds.changeCounter += changedChars + + // 调试信息 + ds.logger.Debug("Document: Content updated", + "changedChars", changedChars, + "totalChanges", ds.changeCounter, + "threshold", threshold) + + // 更新内存缓存 + ds.memoryCache.Content = content + ds.memoryCache.Meta.LastUpdated = time.Now() + ds.pendingSave = true + + // 如果变更超过阈值,触发保存 + if ds.changeCounter >= threshold { + ds.logger.Info("Document: Change threshold reached, triggering save", + "threshold", threshold, + "changes", ds.changeCounter) + + // 提前解锁,避免死锁 + ds.lock.Unlock() + ds.saveChannel <- SaveTriggerThreshold + } else { + // 否则安排自动保存 + ds.lock.Unlock() + ds.scheduleAutoSave() + } + + return nil +} + +// SaveDocumentSync 同步保存文档内容 (用于页面关闭前同步保存) +func (ds *DocumentService) SaveDocumentSync(content string) error { + ds.lock.Lock() + + if ds.memoryCache == nil { + ds.lock.Unlock() + return errors.New("no active document loaded") + } + + // 更新内存缓存 + ds.memoryCache.Content = content + ds.memoryCache.Meta.LastUpdated = time.Now() + + // 直接保存到存储 + doc := *ds.memoryCache + ds.lock.Unlock() + + err := ds.docStore.Set(doc) + if err != nil { + return err + } + + // 重置状态 + ds.lock.Lock() + ds.pendingSave = false + ds.changeCounter = 0 + ds.lastSaveTime = time.Now() + ds.lock.Unlock() + + ds.logger.Info("Document: Synced document save completed") + return nil +} + +// ForceSave 强制保存当前文档 +func (ds *DocumentService) ForceSave() error { + ds.logger.Info("Document: Force save triggered") + + // 获取当前文档内容 + ds.lock.RLock() + if ds.memoryCache == nil { + ds.lock.RUnlock() + return errors.New("no active document loaded") + } + content := ds.memoryCache.Content + ds.lock.RUnlock() + + // 使用同步方法直接保存到磁盘 + if err := ds.SaveDocumentSync(content); err != nil { + ds.logger.Error("Document: Force save failed", "error", err) + return err + } + + ds.logger.Info("Document: Force save completed successfully") + return nil +} + +// calculateChanges 计算两个字符串之间的变更数量 +func calculateChanges(old, new string) int { + // 使用详细的差分算法计算变更 + result := calculateChangesDetailed(old, new) + + // 返回总变更字符数 + return result.TotalChanges +} + +// GetDiffInfo 获取两个文本之间的详细差异信息 +func (ds *DocumentService) GetDiffInfo(oldText, newText string) DiffResult { + return calculateChangesDetailed(oldText, newText) +} + +// GetSaveSettings 获取文档保存设置 +func (ds *DocumentService) GetSaveSettings() (*models.DocumentConfig, error) { + config, err := ds.configService.GetConfig() + if err != nil { + return nil, &DocumentError{Operation: "get_save_settings", Err: err} + } + return &config.Document, nil +} + +// UpdateSaveSettings 更新文档保存设置 +func (ds *DocumentService) UpdateSaveSettings(docConfig models.DocumentConfig) error { + // 获取当前配置 + config, err := ds.configService.GetConfig() + if err != nil { + return &DocumentError{Operation: "update_save_settings", Err: err} + } + + // 更新保存设置 + config.Document = docConfig + + // 保存配置 + err = ds.configService.SaveConfig(config) + if err != nil { + return &DocumentError{Operation: "update_save_settings_save", Err: err} + } + + // 安排自动保存(不再需要检查保存模式) + ds.scheduleAutoSave() + + ds.logger.Info("Document: Updated save settings") + return nil +} + +// ServiceShutdown 实现应用程序关闭时的服务关闭逻辑 +func (ds *DocumentService) ServiceShutdown() error { + ds.logger.Info("Document: Service is shutting down, saving document...") + + // 获取当前活动文档 + ds.lock.RLock() + if ds.memoryCache == nil { + ds.lock.RUnlock() + ds.logger.Info("Document: No active document to save on shutdown") + return nil + } + + // 获取要保存的内容 + content := ds.memoryCache.Content + ds.lock.RUnlock() + + // 同步保存文档内容 + err := ds.SaveDocumentSync(content) + if err != nil { + ds.logger.Error("Document: Failed to save document on shutdown", "error", err) + return err + } + + ds.logger.Info("Document: Document saved successfully on shutdown") + + // 关闭通道以通知保存协程退出 + close(ds.shutdownChan) + + // 等待保存协程退出 + ds.shutdownWg.Wait() + + // 停止所有计时器 + if ds.saveTimer != nil { + ds.saveTimer.Stop() + } + + return nil +} diff --git a/internal/services/service_manager.go b/internal/services/service_manager.go index 66d73f7..dc71c71 100644 --- a/internal/services/service_manager.go +++ b/internal/services/service_manager.go @@ -7,8 +7,9 @@ import ( // ServiceManager 服务管理器,负责协调各个服务 type ServiceManager struct { - configService *ConfigService - logger *log.LoggerService + configService *ConfigService + documentService *DocumentService + logger *log.LoggerService } // NewServiceManager 创建新的服务管理器实例 @@ -18,14 +19,24 @@ func NewServiceManager() *ServiceManager { // 初始化配置服务 configService := NewConfigService(ConfigOption{ - Logger: logger, - PathProvider: nil, - AutoSaveEnabled: true, + Logger: logger, + PathProvider: nil, }) + // 初始化文档服务 + documentService := NewDocumentService(configService, logger) + + // 初始化文档服务 + err := documentService.Initialize() + if err != nil { + logger.Error("Failed to initialize document service", "error", err) + panic(err) + } + return &ServiceManager{ - configService: configService, - logger: logger, + configService: configService, + documentService: documentService, + logger: logger, } } @@ -33,10 +44,6 @@ func NewServiceManager() *ServiceManager { func (sm *ServiceManager) GetServices() []application.Service { return []application.Service{ application.NewService(sm.configService), + application.NewService(sm.documentService), } } - -// GetConfigService 获取配置服务实例 -func (sm *ServiceManager) GetConfigService() *ConfigService { - return sm.configService -}