From 7f2c1694051ed3084bbf8abe4372b06b37a4a215 Mon Sep 17 00:00:00 2001 From: SkyJourney Date: Sat, 1 Aug 2026 04:09:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=98=E5=8C=96=E6=A1=8C=E9=9D=A2?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E9=BB=98=E8=AE=A4=E4=BF=9D=E5=AD=98=E8=B7=AF?= =?UTF-8?q?=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/desktop-application-controller.ts | 52 ++++-- apps/desktop/src/main.ts | 7 +- apps/desktop/src/save-location-state.ts | 164 ++++++++++++++++++ .../desktop/tests/save-location-state.test.ts | 153 ++++++++++++++++ docs/PROGRESS.md | 11 +- 5 files changed, 371 insertions(+), 16 deletions(-) create mode 100644 apps/desktop/src/save-location-state.ts create mode 100644 apps/desktop/tests/save-location-state.test.ts diff --git a/apps/desktop/src/desktop-application-controller.ts b/apps/desktop/src/desktop-application-controller.ts index 1be3375..4b06519 100644 --- a/apps/desktop/src/desktop-application-controller.ts +++ b/apps/desktop/src/desktop-application-controller.ts @@ -75,6 +75,7 @@ import { type WindowState } from "./window-state.js"; import { getWindowCloseDecision } from "./window-close-policy.js"; +import { SaveLocationStore } from "./save-location-state.js"; const DEFAULT_WINDOW_SIZE = { width: 1440, height: 960 }; const MINIMUM_WINDOW_SIZE = { width: 1024, height: 720 }; @@ -113,6 +114,8 @@ export interface DesktopApplicationControllerOptions { iconPath: string; themeDirectory: string; windowStatePath: string; + saveLocationStatePath: string; + documentsDirectory: string; } function focusWindow(window: BrowserWindow) { @@ -134,6 +137,7 @@ export class DesktopApplicationController { readonly #iconPath: string; readonly #themeDirectory: string; readonly #windowStatePath: string; + readonly #saveLocationStore: SaveLocationStore; readonly #pdfGenerator: ElectronPdfGenerator; readonly #docxExportService: DocxExportService; readonly #docxMediaEngine: ElectronDocxMediaEngine; @@ -153,6 +157,10 @@ export class DesktopApplicationController { this.#iconPath = options.iconPath; this.#themeDirectory = options.themeDirectory; this.#windowStatePath = options.windowStatePath; + this.#saveLocationStore = new SaveLocationStore( + options.saveLocationStatePath, + options.documentsDirectory + ); this.#pdfGenerator = new ElectronPdfGenerator({ renderUrl: options.renderUrl, preloadPath: options.pdfPreloadPath @@ -181,12 +189,22 @@ export class DesktopApplicationController { } async initialize() { - this.#savedWindowState = await loadWindowState( - this.#windowStatePath - ); + const [savedWindowState] = await Promise.all([ + loadWindowState(this.#windowStatePath), + this.#saveLocationStore.initialize() + ]); + this.#savedWindowState = savedWindowState; this.#registerIpcHandlers(); } + async #recordSuccessfulFile(filePath: string) { + await this.#saveLocationStore + .recordSuccessfulFile(filePath) + .catch((error: unknown) => { + console.warn("无法持久化最近保存目录", error); + }); + } + async createWindow(filePath?: string) { let pendingSnapshot: MarkdownFileSnapshot | undefined; if (filePath) { @@ -733,9 +751,10 @@ export class DesktopApplicationController { ? "另存为 Markdown" : "保存 Markdown", defaultPath: - forceSaveAs && session.currentFilePath - ? session.currentFilePath - : path.join(app.getPath("documents"), suggestedName), + await this.#saveLocationStore.resolveDefaultPath( + session.currentFilePath, + suggestedName + ), filters: [ { name: "Markdown 文件", @@ -788,6 +807,7 @@ export class DesktopApplicationController { throw new Error("该 Markdown 已在另一个窗口中打开"); } this.#commitSnapshot(session, snapshot); + await this.#recordSuccessfulFile(targetPath); return { fileName: snapshot.document.fileName }; }; @@ -905,16 +925,18 @@ export class DesktopApplicationController { : `${path.basename(unsafeFileName)}.pdf`; const selection = await dialog.showSaveDialog(session.window, { title: "导出 PDF", - defaultPath: path.join( - app.getPath("documents"), - suggestedName - ), + defaultPath: + await this.#saveLocationStore.resolveDefaultPath( + session.currentFilePath, + suggestedName + ), filters: [{ name: "PDF 文件", extensions: ["pdf"] }] }); if (selection.canceled || !selection.filePath) { return false; } await writeFile(selection.filePath, unsafePdf); + await this.#recordSuccessfulFile(selection.filePath); return true; } ); @@ -971,10 +993,11 @@ export class DesktopApplicationController { session.window, { title: "导出 DOCX", - defaultPath: path.join( - app.getPath("documents"), - generated.fileName - ), + defaultPath: + await this.#saveLocationStore.resolveDefaultPath( + session.currentFilePath, + generated.fileName + ), filters: [ { name: "Word 文档", @@ -996,6 +1019,7 @@ export class DesktopApplicationController { selection.filePath, generated.docx ); + await this.#recordSuccessfulFile(saved.targetPath); return { ok: true, saved: true, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index e2af05e..7bf7c4f 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -337,7 +337,12 @@ if (!hasSingleInstanceLock) { windowStatePath: path.join( app.getPath("userData"), "window-state.json" - ) + ), + saveLocationStatePath: path.join( + app.getPath("userData"), + "save-location.json" + ), + documentsDirectory: app.getPath("documents") }); await applicationController.initialize(); diff --git a/apps/desktop/src/save-location-state.ts b/apps/desktop/src/save-location-state.ts new file mode 100644 index 0000000..831ac13 --- /dev/null +++ b/apps/desktop/src/save-location-state.ts @@ -0,0 +1,164 @@ +import { + mkdir, + readFile, + rename, + rm, + stat, + writeFile +} from "node:fs/promises"; +import path from "node:path"; + +const SAVE_LOCATION_STATE_VERSION = 1; + +export interface SaveLocationState { + version: 1; + directory: string; +} + +export interface ResolveDefaultSavePathOptions { + sourceFilePath?: string; + lastSaveDirectory?: string; + documentsDirectory: string; + suggestedFileName: string; +} + +export function parseSaveLocationState( + value: unknown +): SaveLocationState | undefined { + if (!value || typeof value !== "object") { + return undefined; + } + const state = value as Partial; + if ( + state.version !== SAVE_LOCATION_STATE_VERSION || + typeof state.directory !== "string" || + !path.isAbsolute(state.directory) + ) { + return undefined; + } + return { + version: SAVE_LOCATION_STATE_VERSION, + directory: path.resolve(state.directory) + }; +} + +async function isAvailableDirectory(directory: string | undefined) { + if (!directory || !path.isAbsolute(directory)) { + return false; + } + try { + return (await stat(directory)).isDirectory(); + } catch { + return false; + } +} + +function assertSuggestedFileName(value: string) { + const fileName = path.basename(value.trim()); + if (!fileName || fileName === ".") { + throw new Error("默认保存文件名无效"); + } + return fileName; +} + +export async function resolveDefaultSavePath( + options: ResolveDefaultSavePathOptions +) { + const fileName = assertSuggestedFileName( + options.suggestedFileName + ); + const sourceDirectory = options.sourceFilePath + ? path.dirname(path.resolve(options.sourceFilePath)) + : undefined; + if (await isAvailableDirectory(sourceDirectory)) { + return path.join(sourceDirectory!, fileName); + } + if (await isAvailableDirectory(options.lastSaveDirectory)) { + return path.join(options.lastSaveDirectory!, fileName); + } + return path.join( + path.resolve(options.documentsDirectory), + fileName + ); +} + +export async function loadSaveLocationState(filePath: string) { + try { + return parseSaveLocationState( + JSON.parse(await readFile(filePath, "utf8")) as unknown + ); + } catch { + return undefined; + } +} + +export async function saveSaveLocationState( + filePath: string, + directory: string +) { + const state = parseSaveLocationState({ + version: SAVE_LOCATION_STATE_VERSION, + directory + }); + if (!state) { + throw new Error("最近保存目录无效"); + } + await mkdir(path.dirname(filePath), { recursive: true }); + const temporaryPath = `${filePath}.${process.pid}.tmp`; + try { + await writeFile( + temporaryPath, + `${JSON.stringify(state, null, 2)}\n`, + "utf8" + ); + await rename(temporaryPath, filePath); + } catch (error) { + await rm(temporaryPath, { force: true }).catch(() => undefined); + throw error; + } + return state; +} + +export class SaveLocationStore { + readonly #statePath: string; + readonly #documentsDirectory: string; + #lastSaveDirectory: string | undefined; + #pendingWrite = Promise.resolve(); + + constructor(statePath: string, documentsDirectory: string) { + this.#statePath = statePath; + this.#documentsDirectory = documentsDirectory; + } + + async initialize() { + this.#lastSaveDirectory = ( + await loadSaveLocationState(this.#statePath) + )?.directory; + } + + resolveDefaultPath( + sourceFilePath: string | undefined, + suggestedFileName: string + ) { + return resolveDefaultSavePath({ + documentsDirectory: this.#documentsDirectory, + suggestedFileName, + ...(sourceFilePath ? { sourceFilePath } : {}), + ...(this.#lastSaveDirectory + ? { lastSaveDirectory: this.#lastSaveDirectory } + : {}) + }); + } + + async recordSuccessfulFile(filePath: string) { + const directory = path.dirname(path.resolve(filePath)); + this.#lastSaveDirectory = directory; + const write = this.#pendingWrite + .catch(() => undefined) + .then(() => + saveSaveLocationState(this.#statePath, directory) + ); + this.#pendingWrite = write.then(() => undefined); + await write; + } +} diff --git a/apps/desktop/tests/save-location-state.test.ts b/apps/desktop/tests/save-location-state.test.ts new file mode 100644 index 0000000..759c449 --- /dev/null +++ b/apps/desktop/tests/save-location-state.test.ts @@ -0,0 +1,153 @@ +import { + mkdir, + mkdtemp, + readFile, + rm +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + SaveLocationStore, + loadSaveLocationState, + parseSaveLocationState, + resolveDefaultSavePath, + saveSaveLocationState +} from "../src/save-location-state.js"; + +let temporaryDirectory: string | undefined; + +async function createTemporaryDirectory() { + temporaryDirectory = await mkdtemp( + path.join(tmpdir(), "md-to-pdf-save-location-") + ); + return temporaryDirectory; +} + +afterEach(async () => { + if (temporaryDirectory) { + await rm(temporaryDirectory, { recursive: true, force: true }); + temporaryDirectory = undefined; + } +}); + +describe("桌面默认保存位置", () => { + it("只接受版本匹配的绝对目录状态", () => { + expect( + parseSaveLocationState({ + version: 1, + directory: path.resolve("C:/Documents") + }) + ).toEqual({ + version: 1, + directory: path.resolve("C:/Documents") + }); + expect( + parseSaveLocationState({ version: 2, directory: "C:/Documents" }) + ).toBeUndefined(); + expect( + parseSaveLocationState({ version: 1, directory: "relative" }) + ).toBeUndefined(); + }); + + it("优先使用有效的源文件目录", async () => { + const root = await createTemporaryDirectory(); + const sourceDirectory = path.join(root, "source"); + const historyDirectory = path.join(root, "history"); + const documentsDirectory = path.join(root, "documents"); + await Promise.all([ + mkdir(sourceDirectory), + mkdir(historyDirectory), + mkdir(documentsDirectory) + ]); + + await expect( + resolveDefaultSavePath({ + sourceFilePath: path.join(sourceDirectory, "source.md"), + lastSaveDirectory: historyDirectory, + documentsDirectory, + suggestedFileName: "report.pdf" + }) + ).resolves.toBe(path.join(sourceDirectory, "report.pdf")); + }); + + it("源目录不可用时使用最近成功目录", async () => { + const root = await createTemporaryDirectory(); + const historyDirectory = path.join(root, "history"); + const documentsDirectory = path.join(root, "documents"); + await Promise.all([ + mkdir(historyDirectory), + mkdir(documentsDirectory) + ]); + + await expect( + resolveDefaultSavePath({ + sourceFilePath: path.join(root, "missing", "source.md"), + lastSaveDirectory: historyDirectory, + documentsDirectory, + suggestedFileName: "report.docx" + }) + ).resolves.toBe(path.join(historyDirectory, "report.docx")); + }); + + it("源目录和历史目录均不可用时回退到文档目录", async () => { + const root = await createTemporaryDirectory(); + const documentsDirectory = path.join(root, "documents"); + await mkdir(documentsDirectory); + + await expect( + resolveDefaultSavePath({ + lastSaveDirectory: path.join(root, "missing"), + documentsDirectory, + suggestedFileName: "draft.md" + }) + ).resolves.toBe(path.join(documentsDirectory, "draft.md")); + }); + + it("以原子 JSON 状态持久化并恢复最近目录", async () => { + const root = await createTemporaryDirectory(); + const directory = path.join(root, "saved"); + const statePath = path.join(root, "state", "save-location.json"); + await mkdir(directory); + + await saveSaveLocationState(statePath, directory); + + await expect(loadSaveLocationState(statePath)).resolves.toEqual({ + version: 1, + directory + }); + expect(JSON.parse(await readFile(statePath, "utf8"))).toEqual({ + version: 1, + directory + }); + }); + + it("跨 Store 实例恢复最近成功文件目录", async () => { + const root = await createTemporaryDirectory(); + const savedDirectory = path.join(root, "saved"); + const documentsDirectory = path.join(root, "documents"); + const statePath = path.join(root, "save-location.json"); + await Promise.all([ + mkdir(savedDirectory), + mkdir(documentsDirectory) + ]); + const first = new SaveLocationStore( + statePath, + documentsDirectory + ); + await first.initialize(); + await first.recordSuccessfulFile( + path.join(savedDirectory, "first.pdf") + ); + + const restored = new SaveLocationStore( + statePath, + documentsDirectory + ); + await restored.initialize(); + + await expect( + restored.resolveDefaultPath(undefined, "second.docx") + ).resolves.toBe(path.join(savedDirectory, "second.docx")); + }); +}); diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 8ece731..7efdec8 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,6 +1,6 @@ # Markdown PDF 导出器进度 -最后更新:2026-07-31 +最后更新:2026-08-01 ## 1. 当前概况 @@ -353,6 +353,15 @@ SHA-256;交互模式仅在文件存在且哈希正确时显示默认勾选项 文档右侧留白分别约为 30.38px、30.05px 和 30.45px;120% 与 400% 均准确到达各自最大横向滚动值,快速和连续预览结构未改变。 +Desktop 已统一 Markdown 保存/另存为、PDF 导出和 DOCX 导出的默认路径 +策略:有效源文件目录优先,其次使用最近一次成功写入文件的目录,最后 +回退到 Electron 返回的用户“文档”目录。最近目录只在目标文件实际写入 +成功后更新,普通保存已有 Markdown 也会更新;状态使用版本化 JSON、 +临时文件和原子替换写入应用数据目录,因此可跨窗口和应用重启复用。源 +目录或历史目录被删除、移动、变为普通文件或不可访问时会自动跳过,不 +阻断保存对话框。状态持久化失败不会把已经成功写入的用户文件误报为保存 +失败。该优化作为独立提交完成,不与 FP3-C Docker 部署改动混合。 + ## 2. 已完成 ### 2.1 项目骨架