From 8edb115e08c9d7c38409d8a784e0c553ff059b3c Mon Sep 17 00:00:00 2001 From: murashit Date: Wed, 24 Jun 2026 15:13:35 +0900 Subject: [PATCH] Align thread archive export with Obsidian vault paths --- .../services/thread-archive-markdown.ts | 53 ++++++++++++------ src/app-server/services/thread-archive.ts | 8 +-- src/domain/threads/archive-markdown.ts | 6 -- src/features/chat/host/runtime.ts | 4 +- src/features/chat/host/session-graph.ts | 2 +- src/features/chat/host/view.ts | 3 +- src/features/threads-view/session.ts | 7 ++- src/features/threads-view/view.ts | 3 +- src/features/threads/thread-operations.ts | 7 ++- .../obsidian/archive-export-destination.ts | 14 +++++ .../thread-archive-markdown.test.ts | 55 ++++++++++++++----- tests/app-server/thread-archive.test.ts | 31 ++++++----- .../features/chat/host/session-graph.test.ts | 2 +- .../threads/thread-management-actions.test.ts | 45 +++++++-------- .../threads/thread-operations.test.ts | 11 ++-- tests/mocks/obsidian.ts | 8 +++ .../archive-export-destination.test.ts | 35 ++++++++++++ 17 files changed, 197 insertions(+), 97 deletions(-) create mode 100644 src/shared/obsidian/archive-export-destination.ts create mode 100644 tests/shared/obsidian/archive-export-destination.test.ts diff --git a/src/app-server/services/thread-archive-markdown.ts b/src/app-server/services/thread-archive-markdown.ts index bf5bf3b2..1995a752 100644 --- a/src/app-server/services/thread-archive-markdown.ts +++ b/src/app-server/services/thread-archive-markdown.ts @@ -1,5 +1,4 @@ import { - type ArchiveExportAdapter, type ArchiveExportSettings, type ArchiveThreadInput, archivedThreadMarkdown, @@ -11,6 +10,13 @@ export interface ArchiveExportResult { path: string; } +export interface ArchiveExportDestination { + normalizePath(path: string): string; + exists(path: string): Promise; + createFolder(path: string): Promise; + createMarkdownFile(path: string, data: string): Promise; +} + interface TemplateContext { date: string; time: string; @@ -22,16 +28,17 @@ interface TemplateContext { export async function exportArchivedThreadMarkdown( thread: ArchiveThreadInput, settings: ArchiveExportSettings, - adapter: ArchiveExportAdapter, + destination: ArchiveExportDestination, now = new Date(), ): Promise { const context = templateContext(thread, now); - const folder = folderPathFromTemplate(settings.archiveExportFolderTemplate, context); - const filename = filenameFromTemplate(settings.archiveExportFilenameTemplate, context); - await ensureFolder(adapter, folder); + const normalizePath = destination.normalizePath; + const folder = folderPathFromTemplate(settings.archiveExportFolderTemplate, context, normalizePath); + const filename = filenameFromTemplate(settings.archiveExportFilenameTemplate, context, normalizePath); + await ensureFolder(destination, folder); - const path = await uniqueMarkdownPath(adapter, folder, filename); - await adapter.write(path, archivedThreadMarkdown(thread, now, settings)); + const path = await uniqueMarkdownPath(destination, folder, filename, normalizePath); + await destination.createMarkdownFile(path, archivedThreadMarkdown(thread, now, settings)); return { path }; } @@ -50,7 +57,7 @@ function expandTemplate(template: string, context: TemplateContext): string { return template.replace(/{{\s*(date|time|title|id|shortId)\s*}}/g, (_match, key: keyof TemplateContext) => context[key]); } -function folderPathFromTemplate(template: string, context: TemplateContext): string { +function folderPathFromTemplate(template: string, context: TemplateContext, normalizePath: (path: string) => string): string { const expanded = expandTemplate(template, context).trim().replaceAll("\\", "/"); if (!expanded) throw new Error("Archive export folder template produced an empty path."); if (expanded.startsWith("/") || /^[A-Za-z]:\//.test(expanded)) { @@ -65,38 +72,48 @@ function folderPathFromTemplate(template: string, context: TemplateContext): str if (segments.some((segment) => segment === "." || segment === "..")) { throw new Error("Archive export folder cannot contain relative path segments."); } - return segments.map(sanitizePathSegment).join("/"); + const folder = normalizePath(segments.map(sanitizePathSegment).join("/")); + if (!folder) throw new Error("Archive export folder template produced an empty path."); + if (folder.split("/").some((segment) => segment === "." || segment === "..")) { + throw new Error("Archive export folder cannot contain relative path segments."); + } + return folder; } -function filenameFromTemplate(template: string, context: TemplateContext): string { +function filenameFromTemplate(template: string, context: TemplateContext, normalizePath: (path: string) => string): string { const expanded = expandTemplate(template, context) .trim() .replace(/[\\/]+/g, "-"); - const filename = sanitizePathSegment(expanded); + const filename = normalizePath(sanitizePathSegment(expanded)); if (!filename || filename === "." || filename === "..") { throw new Error("Archive export filename template produced an empty filename."); } return filename.toLowerCase().endsWith(".md") ? filename : `${filename}.md`; } -async function ensureFolder(adapter: ArchiveExportAdapter, folder: string): Promise { +async function ensureFolder(destination: ArchiveExportDestination, folder: string): Promise { const segments = folder.split("/"); for (let index = 0; index < segments.length; index += 1) { const path = segments.slice(0, index + 1).join("/"); - if (!(await adapter.exists(path))) { - await adapter.mkdir(path); + if (!(await destination.exists(path))) { + await destination.createFolder(path); } } } -async function uniqueMarkdownPath(adapter: ArchiveExportAdapter, folder: string, filename: string): Promise { +async function uniqueMarkdownPath( + destination: ArchiveExportDestination, + folder: string, + filename: string, + normalizePath: (path: string) => string, +): Promise { const dotIndex = filename.toLowerCase().endsWith(".md") ? filename.length - 3 : filename.length; const stem = filename.slice(0, dotIndex); const extension = filename.slice(dotIndex); - let candidate = `${folder}/${filename}`; + let candidate = normalizePath(`${folder}/${filename}`); let suffix = 2; - while (await adapter.exists(candidate)) { - candidate = `${folder}/${stem} ${String(suffix)}${extension}`; + while (await destination.exists(candidate)) { + candidate = normalizePath(`${folder}/${stem} ${String(suffix)}${extension}`); suffix += 1; } return candidate; diff --git a/src/app-server/services/thread-archive.ts b/src/app-server/services/thread-archive.ts index d2c96fb6..b9a9b306 100644 --- a/src/app-server/services/thread-archive.ts +++ b/src/app-server/services/thread-archive.ts @@ -1,13 +1,13 @@ -import type { ArchiveExportAdapter, ArchiveExportSettings } from "../../domain/threads/archive-markdown"; +import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown"; import type { AppServerClient } from "../connection/client"; import { readThreadForArchiveExport } from "../threads"; -import { exportArchivedThreadMarkdown } from "./thread-archive-markdown"; +import { type ArchiveExportDestination, exportArchivedThreadMarkdown } from "./thread-archive-markdown"; export interface ArchiveThreadOptions { settings: ArchiveExportSettings; vaultPath: string; vaultConfigDir: string; - archiveAdapter: () => ArchiveExportAdapter; + archiveDestination: () => ArchiveExportDestination; saveMarkdown: boolean; } @@ -25,7 +25,7 @@ export async function archiveThreadOnAppServer( const result = await exportArchivedThreadMarkdown( await readThreadForArchiveExport(client, threadId), { ...options.settings, vaultPath: options.vaultPath, vaultConfigDir: options.vaultConfigDir }, - options.archiveAdapter(), + options.archiveDestination(), ); exportedPath = result.path; } diff --git a/src/domain/threads/archive-markdown.ts b/src/domain/threads/archive-markdown.ts index 46cb3fff..754017d1 100644 --- a/src/domain/threads/archive-markdown.ts +++ b/src/domain/threads/archive-markdown.ts @@ -26,12 +26,6 @@ export interface ArchiveExportSettings { vaultConfigDir?: string; } -export interface ArchiveExportAdapter { - exists(path: string): Promise; - mkdir(path: string): Promise; - write(path: string, data: string): Promise; -} - export interface ArchiveThreadInput extends Thread { transcriptEntries: readonly ThreadTranscriptEntry[]; } diff --git a/src/features/chat/host/runtime.ts b/src/features/chat/host/runtime.ts index 24b91b6c..07262c91 100644 --- a/src/features/chat/host/runtime.ts +++ b/src/features/chat/host/runtime.ts @@ -1,9 +1,9 @@ import type { App, Component, EventRef } from "obsidian"; +import type { ArchiveExportDestination } from "../../../app-server/services/thread-archive-markdown"; import type { ModelMetadata } from "../../../domain/catalog/metadata"; import type { ObservedDataListener } from "../../../domain/observed-data"; import type { SharedServerMetadata } from "../../../domain/server/metadata"; -import type { ArchiveExportAdapter } from "../../../domain/threads/archive-markdown"; import type { CodexPanelSettings } from "../../../settings/model"; import type { ThreadCatalogActiveReader, ThreadCatalogEventSink } from "../../../workspace/thread-catalog"; import type { ChatTurnDiffViewState } from "../domain/turn-diff"; @@ -47,7 +47,7 @@ export interface ChatPanelEnvironment { viewId: string; registerEvent: (eventRef: EventRef) => void; registerPointerDown: (handler: (event: PointerEvent) => void) => void; - archiveAdapter: () => ArchiveExportAdapter; + archiveDestination: () => ArchiveExportDestination; requestWorkspaceLayoutSave: () => void; }; plugin: CodexChatHost; diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-graph.ts index 766331e0..1c5a3c70 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-graph.ts @@ -543,7 +543,7 @@ function createSessionThreadOperations(environment: ChatPanelEnvironment, curren vaultPath: environment.plugin.settingsRef.vaultPath, vaultConfigDir: environment.obsidian.app.vault.configDir, }, - archiveAdapter: environment.obsidian.archiveAdapter, + archiveDestination: environment.obsidian.archiveDestination, catalog: environment.plugin.threadCatalog, notice: (text) => { new Notice(text); diff --git a/src/features/chat/host/view.ts b/src/features/chat/host/view.ts index c464ad62..5ad90bc7 100644 --- a/src/features/chat/host/view.ts +++ b/src/features/chat/host/view.ts @@ -2,6 +2,7 @@ import { ItemView, type ViewStateResult, type WorkspaceLeaf } from "obsidian"; import { VIEW_TYPE_CODEX_PANEL } from "../../../constants"; import { createLocalIdSource } from "../../../shared/id/local-id"; +import { createObsidianArchiveExportDestination } from "../../../shared/obsidian/archive-export-destination"; import type { CodexChatHost } from "./runtime"; import { ChatPanelSession } from "./session"; import type { ChatSurfaceHandle } from "./surface-handle"; @@ -23,7 +24,7 @@ export class CodexChatView extends ItemView { registerPointerDown: (handler) => { this.registerDomEvent(this.containerEl.doc, "pointerdown", handler); }, - archiveAdapter: () => this.app.vault.adapter, + archiveDestination: () => createObsidianArchiveExportDestination(this.app.vault), requestWorkspaceLayoutSave: () => { void this.app.workspace.requestSaveLayout(); }, diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index 5d21097b..497c358a 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -2,10 +2,11 @@ import { Notice } from "obsidian"; import type { AppServerClientAccess } from "../../app-server/connection/client-access"; import { isStaleAppServerSharedQueryContextError } from "../../app-server/query/shared-queries"; +import type { ArchiveExportDestination } from "../../app-server/services/thread-archive-markdown"; import type { ReasoningEffort } from "../../domain/catalog/metadata"; import type { ObservedDataResult } from "../../domain/observed-data"; import { observedData, observedInitialError, observedInitialLoading } from "../../domain/observed-data"; -import type { ArchiveExportAdapter, ArchiveExportSettings } from "../../domain/threads/archive-markdown"; +import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown"; import type { Thread } from "../../domain/threads/model"; import type { OpenCodexPanelSnapshot } from "../../workspace/panel-coordinator"; import type { ThreadCatalogActiveReader, ThreadCatalogEventSink } from "../../workspace/thread-catalog"; @@ -51,7 +52,7 @@ export interface CodexThreadsSessionEnvironment { root: HTMLElement; host: CodexThreadsHost; registerPointerDown(handler: (event: PointerEvent) => void): void; - archiveAdapter(): ArchiveExportAdapter; + archiveDestination(): ArchiveExportDestination; vaultConfigDir(): string; viewWindow(): Window | null; } @@ -86,7 +87,7 @@ export class CodexThreadsSession { vaultPath: this.host.vaultPath, vaultConfigDir: this.environment.vaultConfigDir(), }, - archiveAdapter: () => this.environment.archiveAdapter(), + archiveDestination: () => this.environment.archiveDestination(), catalog: this.host.threadCatalog, notice: (message) => { new Notice(message); diff --git a/src/features/threads-view/view.ts b/src/features/threads-view/view.ts index ceade8ed..07ceefe3 100644 --- a/src/features/threads-view/view.ts +++ b/src/features/threads-view/view.ts @@ -1,6 +1,7 @@ import { ItemView, type WorkspaceLeaf } from "obsidian"; import { VIEW_TYPE_CODEX_THREADS } from "../../constants"; +import { createObsidianArchiveExportDestination } from "../../shared/obsidian/archive-export-destination"; import { type CodexThreadsHost, CodexThreadsSession } from "./session"; export class CodexThreadsView extends ItemView { @@ -14,7 +15,7 @@ export class CodexThreadsView extends ItemView { registerPointerDown: (handler) => { this.registerDomEvent(this.containerEl.doc, "pointerdown", handler); }, - archiveAdapter: () => this.app.vault.adapter, + archiveDestination: () => createObsidianArchiveExportDestination(this.app.vault), vaultConfigDir: () => this.app.vault.configDir, viewWindow: () => this.containerEl.doc.defaultView, }); diff --git a/src/features/threads/thread-operations.ts b/src/features/threads/thread-operations.ts index ef0778ae..a406860d 100644 --- a/src/features/threads/thread-operations.ts +++ b/src/features/threads/thread-operations.ts @@ -1,6 +1,7 @@ import type { AppServerClientAccess } from "../../app-server/connection/client-access"; import { type ArchiveThreadResult, archiveThreadOnAppServer } from "../../app-server/services/thread-archive"; -import type { ArchiveExportAdapter, ArchiveExportSettings } from "../../domain/threads/archive-markdown"; +import type { ArchiveExportDestination } from "../../app-server/services/thread-archive-markdown"; +import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown"; import { normalizeExplicitThreadName } from "../../domain/threads/model"; import type { ThreadCatalogEventSink } from "../../workspace/thread-catalog"; @@ -12,7 +13,7 @@ export interface ThreadOperationsHost { vaultPath: string; vaultConfigDir: string; }; - archiveAdapter(): ArchiveExportAdapter; + archiveDestination(): ArchiveExportDestination; catalog: ThreadCatalogEventSink; notice(message: string): void; } @@ -65,7 +66,7 @@ async function archiveThread( settings: archiveSettings, vaultPath: host.archiveExport.vaultPath, vaultConfigDir: host.archiveExport.vaultConfigDir, - archiveAdapter: () => host.archiveAdapter(), + archiveDestination: () => host.archiveDestination(), saveMarkdown: options.saveMarkdown ?? host.archiveExport.enabled(), }), ); diff --git a/src/shared/obsidian/archive-export-destination.ts b/src/shared/obsidian/archive-export-destination.ts new file mode 100644 index 00000000..e662bb86 --- /dev/null +++ b/src/shared/obsidian/archive-export-destination.ts @@ -0,0 +1,14 @@ +import { normalizePath, type Vault } from "obsidian"; + +export function createObsidianArchiveExportDestination(vault: Vault) { + return { + normalizePath, + exists: async (path: string): Promise => vault.getAbstractFileByPath(normalizePath(path)) !== null, + createFolder: async (path: string): Promise => { + await vault.createFolder(normalizePath(path)); + }, + createMarkdownFile: async (path: string, data: string): Promise => { + await vault.create(normalizePath(path), data); + }, + }; +} diff --git a/tests/app-server/thread-archive-markdown.test.ts b/tests/app-server/thread-archive-markdown.test.ts index ffcd229c..3ef225e7 100644 --- a/tests/app-server/thread-archive-markdown.test.ts +++ b/tests/app-server/thread-archive-markdown.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import { exportArchivedThreadMarkdown } from "../../src/app-server/services/thread-archive-markdown"; -import type { ArchiveExportAdapter, ArchiveThreadInput } from "../../src/domain/threads/archive-markdown"; +import { type ArchiveExportDestination, exportArchivedThreadMarkdown } from "../../src/app-server/services/thread-archive-markdown"; +import type { ArchiveThreadInput } from "../../src/domain/threads/archive-markdown"; import type { ThreadTranscriptEntry } from "../../src/domain/threads/transcript"; describe("thread archive markdown export service", () => { it("expands templates, sanitizes paths, creates folders, and preserves existing files", async () => { - const adapter = new MemoryAdapter(["Codex Archives/2026-05-18/My-Thread- abcdef12.md"]); + const destination = new MemoryDestination(["Codex Archives/2026-05-18/My-Thread- abcdef12.md"]); const result = await exportArchivedThreadMarkdown( thread({ id: "abcdef12-9999", name: "My/Thread?" }), @@ -15,30 +15,53 @@ describe("thread archive markdown export service", () => { archiveExportFilenameTemplate: "{{title}} {{shortId}}", archiveExportTags: "codex, archive", }, - adapter, + destination, new Date(2026, 4, 18, 9, 8, 7), ); expect(result.path).toBe("Codex Archives/2026-05-18/My-Thread- abcdef12 2.md"); - expect(adapter.folders).toContain("Codex Archives"); - expect(adapter.folders).toContain("Codex Archives/2026-05-18"); - expect(adapter.files.get(result.path)).toContain('thread_id: "abcdef12-9999"'); - expect(adapter.files.get(result.path)).toContain('tags: ["codex", "archive"]'); + expect(destination.folders).toContain("Codex Archives"); + expect(destination.folders).toContain("Codex Archives/2026-05-18"); + expect(destination.files.get(result.path)).toContain('thread_id: "abcdef12-9999"'); + expect(destination.files.get(result.path)).toContain('tags: ["codex", "archive"]'); }); it("rejects vault-external or empty export paths", async () => { - const adapter = new MemoryAdapter(); + const destination = new MemoryDestination(); await expect( exportArchivedThreadMarkdown( thread(), { archiveExportFolderTemplate: "../outside", archiveExportFilenameTemplate: "{{title}}.md" }, - adapter, + destination, ), ).rejects.toThrow("relative path segments"); await expect( - exportArchivedThreadMarkdown(thread(), { archiveExportFolderTemplate: "Exports", archiveExportFilenameTemplate: " " }, adapter), + exportArchivedThreadMarkdown(thread(), { archiveExportFolderTemplate: "Exports", archiveExportFilenameTemplate: " " }, destination), ).rejects.toThrow("empty filename"); }); + + it("normalizes generated vault paths through the archive destination", async () => { + const destination = new MemoryDestination([], (path) => + path + .replace(/\u00a0/g, " ") + .replace(/\/+/g, "/") + .normalize(), + ); + + const result = await exportArchivedThreadMarkdown( + thread({ name: "Thread\u00a0Cafe\u0301" }), + { + archiveExportFolderTemplate: "Codex\u00a0Archives/Cafe\u0301", + archiveExportFilenameTemplate: "{{title}}", + }, + destination, + new Date(2026, 4, 18, 9, 8, 7), + ); + + expect(result.path).toBe("Codex Archives/Café/Thread Café.md"); + expect(destination.folders).toContain("Codex Archives/Café"); + expect(destination.files.has(result.path)).toBe(true); + }); }); function thread(overrides: Partial = {}): ArchiveThreadInput { @@ -58,11 +81,13 @@ function transcriptEntry(kind: ThreadTranscriptEntry["kind"], text: string, time return { kind, text, timestamp }; } -class MemoryAdapter implements ArchiveExportAdapter { +class MemoryDestination implements ArchiveExportDestination { readonly files = new Map(); readonly folders = new Set(); + readonly normalizePath: (path: string) => string; - constructor(existingFiles: string[] = []) { + constructor(existingFiles: string[] = [], normalizePath?: (path: string) => string) { + this.normalizePath = normalizePath ?? ((path) => path); for (const file of existingFiles) { this.files.set(file, ""); const parts = file.split("/"); @@ -76,11 +101,11 @@ class MemoryAdapter implements ArchiveExportAdapter { return this.files.has(path) || this.folders.has(path); } - async mkdir(path: string): Promise { + async createFolder(path: string): Promise { this.folders.add(path); } - async write(path: string, data: string): Promise { + async createMarkdownFile(path: string, data: string): Promise { this.files.set(path, data); } } diff --git a/tests/app-server/thread-archive.test.ts b/tests/app-server/thread-archive.test.ts index 6ce008ff..99ce3404 100644 --- a/tests/app-server/thread-archive.test.ts +++ b/tests/app-server/thread-archive.test.ts @@ -3,13 +3,13 @@ import { describe, expect, it, vi } from "vitest"; import type { AppServerClient } from "../../src/app-server/connection/client"; import type { ThreadRecord } from "../../src/app-server/protocol/thread"; import { archiveThreadOnAppServer } from "../../src/app-server/services/thread-archive"; -import type { ArchiveExportAdapter } from "../../src/domain/threads/archive-markdown"; +import type { ArchiveExportDestination } from "../../src/app-server/services/thread-archive-markdown"; import { DEFAULT_SETTINGS } from "../../src/settings/model"; describe("thread archive operation", () => { it("exports markdown before archiving when requested", async () => { const client = fakeClient(); - const adapter = archiveAdapter(); + const destination = archiveDestination(); const result = await archiveThreadOnAppServer(client, "thread", { settings: { @@ -19,36 +19,36 @@ describe("thread archive operation", () => { }, vaultPath: "/vault", vaultConfigDir: "vault-config", - archiveAdapter: () => adapter, + archiveDestination: () => destination, saveMarkdown: true, }); expect(client.readThread).toHaveBeenCalledWith("thread", true); - expect(adapter.write).toHaveBeenCalledWith( + expect(destination.createMarkdownFile).toHaveBeenCalledWith( "Archive/Archived Thread abcdef12.md", expect.stringContaining('thread_id: "abcdef12-9999"'), ); expect(client.archiveThread).toHaveBeenCalledWith("thread"); expect(result).toEqual({ exportedPath: "Archive/Archived Thread abcdef12.md" }); - expect(callOrder(adapter.write)).toBeLessThan(callOrder(client.archiveThread)); + expect(callOrder(destination.createMarkdownFile)).toBeLessThan(callOrder(client.archiveThread)); }); it("archives without reading transcript history when markdown export is disabled", async () => { const client = fakeClient(); - const archiveAdapterFactory = vi.fn(() => archiveAdapter()); + const archiveDestinationFactory = vi.fn(() => archiveDestination()); await expect( archiveThreadOnAppServer(client, "thread", { settings: DEFAULT_SETTINGS, vaultPath: "/vault", vaultConfigDir: "vault-config", - archiveAdapter: archiveAdapterFactory, + archiveDestination: archiveDestinationFactory, saveMarkdown: false, }), ).resolves.toEqual({ exportedPath: null }); expect(client.readThread).not.toHaveBeenCalled(); - expect(archiveAdapterFactory).not.toHaveBeenCalled(); + expect(archiveDestinationFactory).not.toHaveBeenCalled(); expect(client.archiveThread).toHaveBeenCalledWith("thread"); }); }); @@ -66,15 +66,16 @@ function fakeClient(): AppServerClient & { }; } -function archiveAdapter(): ArchiveExportAdapter & { - exists: ReturnType>; - mkdir: ReturnType>; - write: ReturnType>; +function archiveDestination(): ArchiveExportDestination & { + exists: ReturnType>; + createFolder: ReturnType>; + createMarkdownFile: ReturnType>; } { return { - exists: vi.fn().mockResolvedValue(false), - mkdir: vi.fn().mockResolvedValue(undefined), - write: vi.fn().mockResolvedValue(undefined), + normalizePath: (path) => path, + exists: vi.fn().mockResolvedValue(false), + createFolder: vi.fn().mockResolvedValue(undefined), + createMarkdownFile: vi.fn().mockResolvedValue(undefined), }; } diff --git a/tests/features/chat/host/session-graph.test.ts b/tests/features/chat/host/session-graph.test.ts index 0a866bd4..790612f2 100644 --- a/tests/features/chat/host/session-graph.test.ts +++ b/tests/features/chat/host/session-graph.test.ts @@ -266,7 +266,7 @@ describe("createChatPanelSessionGraph actions", () => { viewId: "codex-test-view", registerEvent: vi.fn(), registerPointerDown: vi.fn(), - archiveAdapter: vi.fn(), + archiveDestination: vi.fn(), requestWorkspaceLayoutSave: vi.fn(), ...overrides.obsidian, }, diff --git a/tests/features/chat/threads/thread-management-actions.test.ts b/tests/features/chat/threads/thread-management-actions.test.ts index 2603facd..c9d40e28 100644 --- a/tests/features/chat/threads/thread-management-actions.test.ts +++ b/tests/features/chat/threads/thread-management-actions.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import type { AppServerClient } from "../../../../src/app-server/connection/client"; import type { ThreadRecord } from "../../../../src/app-server/protocol/thread"; import { archiveThreadOnAppServer } from "../../../../src/app-server/services/thread-archive"; -import type { ArchiveExportAdapter } from "../../../../src/domain/threads/archive-markdown"; +import type { ArchiveExportDestination } from "../../../../src/app-server/services/thread-archive-markdown"; import { normalizeExplicitThreadName } from "../../../../src/domain/threads/model"; import { createChatStateStore } from "../../../../src/features/chat/application/state/store"; import { @@ -17,10 +17,10 @@ import { deferred, waitForAsyncWork } from "../../../support/async"; import { chatStateMessageStreamItems, withChatStateMessageStreamItems } from "../support/message-stream"; import { chatStateFixture } from "../support/state"; -type MockArchiveExportAdapter = ArchiveExportAdapter & { - exists: ReturnType>; - mkdir: ReturnType>; - write: ReturnType>; +type MockArchiveExportDestination = ArchiveExportDestination & { + exists: ReturnType>; + createFolder: ReturnType>; + createMarkdownFile: ReturnType>; }; describe("thread management actions", () => { @@ -108,12 +108,12 @@ describe("thread management actions", () => { it("saves archive markdown before archiving and notifying shared surfaces", async () => { const client = clientMock(); - const adapter = archiveAdapterMock(); + const destination = archiveDestinationMock(); client.readThread.mockResolvedValue({ thread: archivedThread() }); const host = hostMock({ client, items: [], - archiveAdapter: adapter, + archiveDestination: destination, settings: { archiveExportEnabled: true, archiveExportFolderTemplate: "Archive", @@ -126,26 +126,26 @@ describe("thread management actions", () => { expect(host.ensureConnected).toHaveBeenCalledOnce(); expect(client.readThread).toHaveBeenCalledWith("source", true); - expect(adapter.write).toHaveBeenCalledWith( + expect(destination.createMarkdownFile).toHaveBeenCalledWith( "Archive/Archived Thread abcdef12.md", expect.stringContaining('thread_id: "abcdef12-9999"'), ); expect(client.archiveThread).toHaveBeenCalledWith("source"); expect(host.notifyThreadArchived).toHaveBeenCalledWith("source"); expect(host.showNotice).toHaveBeenCalledWith("Saved archived thread to Archive/Archived Thread abcdef12.md."); - expect(callOrder(adapter.write)).toBeLessThan(callOrder(client.archiveThread)); + expect(callOrder(destination.createMarkdownFile)).toBeLessThan(callOrder(client.archiveThread)); expect(callOrder(host.ensureConnected)).toBeLessThan(callOrder(client.readThread)); expect(callOrder(client.archiveThread)).toBeLessThan(callOrder(host.notifyThreadArchived)); }); it("does not archive or notify surfaces when archive markdown export fails", async () => { const client = clientMock(); - const adapter = archiveAdapterMock({ write: vi.fn().mockRejectedValue(new Error("disk full")) }); + const destination = archiveDestinationMock({ createMarkdownFile: vi.fn().mockRejectedValue(new Error("disk full")) }); client.readThread.mockResolvedValue({ thread: archivedThread() }); const host = hostMock({ client, items: [], - archiveAdapter: adapter, + archiveDestination: destination, settings: { archiveExportEnabled: true, archiveExportFolderTemplate: "Archive", @@ -182,12 +182,12 @@ describe("thread management actions", () => { it("saves the source before replacing the panel during fork and archive", async () => { const client = clientMock(); - const adapter = archiveAdapterMock(); + const destination = archiveDestinationMock(); client.readThread.mockResolvedValue({ thread: archivedThread() }); const host = hostMock({ client, items: turnItems(), - archiveAdapter: adapter, + archiveDestination: destination, settings: { archiveExportEnabled: true, archiveExportFolderTemplate: "Archive", @@ -200,11 +200,11 @@ describe("thread management actions", () => { expect(client.forkThread).toHaveBeenCalledWith("source", "/vault"); expect(client.readThread).toHaveBeenCalledWith("source", true); - expect(adapter.write).toHaveBeenCalledWith("Archive/Archived Thread abcdef12.md", expect.any(String)); + expect(destination.createMarkdownFile).toHaveBeenCalledWith("Archive/Archived Thread abcdef12.md", expect.any(String)); expect(client.archiveThread).toHaveBeenCalledWith("source"); expect(host.openThreadInCurrentPanel).toHaveBeenCalledWith("forked"); expect(host.notifyThreadArchived).toHaveBeenCalledWith("source"); - expect(callOrder(adapter.write)).toBeLessThan(callOrder(client.archiveThread)); + expect(callOrder(destination.createMarkdownFile)).toBeLessThan(callOrder(client.archiveThread)); expect(callOrder(client.archiveThread)).toBeLessThan(callOrder(host.notifyThreadArchived)); expect(callOrder(host.notifyThreadArchived)).toBeLessThan(callOrder(host.openThreadInCurrentPanel)); }); @@ -503,13 +503,13 @@ function threadManagementActions(host: ThreadManagementActionsHost): ThreadManag function hostMock({ client, items, - archiveAdapter = archiveAdapterMock(), + archiveDestination = archiveDestinationMock(), settings = {}, currentClient, }: { client: ReturnType; items: MessageStreamItem[]; - archiveAdapter?: ArchiveExportAdapter; + archiveDestination?: ArchiveExportDestination; settings?: Partial; currentClient?: () => AppServerClient | null; }) { @@ -541,7 +541,7 @@ function hostMock({ settings: { ...DEFAULT_SETTINGS, ...settings }, vaultPath: "/vault", vaultConfigDir: "vault-config", - archiveAdapter: () => archiveAdapter, + archiveDestination: () => archiveDestination, saveMarkdown: options.saveMarkdown ?? settings.archiveExportEnabled ?? DEFAULT_SETTINGS.archiveExportEnabled, }); if (result.exportedPath) showNotice(`Saved archived thread to ${result.exportedPath}.`); @@ -566,11 +566,12 @@ function hostMock({ }; } -function archiveAdapterMock(overrides: Partial = {}): MockArchiveExportAdapter { +function archiveDestinationMock(overrides: Partial = {}): MockArchiveExportDestination { return { - exists: vi.fn().mockResolvedValue(false), - mkdir: vi.fn().mockResolvedValue(undefined), - write: vi.fn().mockResolvedValue(undefined), + normalizePath: (path) => path, + exists: vi.fn().mockResolvedValue(false), + createFolder: vi.fn().mockResolvedValue(undefined), + createMarkdownFile: vi.fn().mockResolvedValue(undefined), ...overrides, }; } diff --git a/tests/features/threads/thread-operations.test.ts b/tests/features/threads/thread-operations.test.ts index 45d75d8f..4b31b9b7 100644 --- a/tests/features/threads/thread-operations.test.ts +++ b/tests/features/threads/thread-operations.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { AppServerClient } from "../../../src/app-server/connection/client"; import type { ArchiveThreadResult } from "../../../src/app-server/services/thread-archive"; -import type { ArchiveExportAdapter } from "../../../src/domain/threads/archive-markdown"; +import type { ArchiveExportDestination } from "../../../src/app-server/services/thread-archive-markdown"; import { createThreadOperations, type ThreadOperationsHost } from "../../../src/features/threads/thread-operations"; import { DEFAULT_SETTINGS } from "../../../src/settings/model"; @@ -116,7 +116,7 @@ function operationsFixture(options: { client?: MockClient | null | (() => MockCl vaultPath: "/vault", vaultConfigDir: "vault-config", }, - archiveAdapter: () => archiveAdapterMock(), + archiveDestination: () => archiveDestinationMock(), catalog, notice, }; @@ -132,10 +132,11 @@ function clientMock() { }; } -function archiveAdapterMock(): ArchiveExportAdapter { +function archiveDestinationMock(): ArchiveExportDestination { return { + normalizePath: (path) => path, exists: vi.fn().mockResolvedValue(false), - mkdir: vi.fn().mockResolvedValue(undefined), - write: vi.fn().mockResolvedValue(undefined), + createFolder: vi.fn().mockResolvedValue(undefined), + createMarkdownFile: vi.fn().mockResolvedValue(undefined), }; } diff --git a/tests/mocks/obsidian.ts b/tests/mocks/obsidian.ts index ee5a2716..158d1943 100644 --- a/tests/mocks/obsidian.ts +++ b/tests/mocks/obsidian.ts @@ -86,6 +86,14 @@ export function parseLinktext(linktext: string): { path: string; subpath: string : { path: linktext.slice(0, subpathStart), subpath: linktext.slice(subpathStart) }; } +export function normalizePath(path: string): string { + return path + .replace(/\u00a0/g, " ") + .replace(/[\\/]+/g, "/") + .replace(/^\/+|\/+$/g, "") + .normalize(); +} + export class Modal { readonly contentEl: HTMLElement; diff --git a/tests/shared/obsidian/archive-export-destination.test.ts b/tests/shared/obsidian/archive-export-destination.test.ts new file mode 100644 index 00000000..65ca21e9 --- /dev/null +++ b/tests/shared/obsidian/archive-export-destination.test.ts @@ -0,0 +1,35 @@ +import type { Vault } from "obsidian"; +import { describe, expect, it, vi } from "vitest"; + +import { createObsidianArchiveExportDestination } from "../../../src/shared/obsidian/archive-export-destination"; + +describe("createObsidianArchiveExportDestination", () => { + it("uses normalized Vault paths for existence checks, folder creation, and file writes", async () => { + const vault = vaultMock(new Set(["Codex Archives/Café"])); + const destination = createObsidianArchiveExportDestination(vault); + + await expect(destination.exists("//Codex\u00a0Archives//Cafe\u0301//")).resolves.toBe(true); + await destination.createFolder("//Codex\u00a0Archives//New//"); + await destination.createMarkdownFile("//Codex\u00a0Archives//Cafe\u0301//Thread.md", "body"); + + expect(vault.getAbstractFileByPath).toHaveBeenCalledWith("Codex Archives/Café"); + expect(vault.createFolder).toHaveBeenCalledWith("Codex Archives/New"); + expect(vault.create).toHaveBeenCalledWith("Codex Archives/Café/Thread.md", "body"); + }); +}); + +function vaultMock(existingPaths: Set): Vault & { + getAbstractFileByPath: ReturnType; + createFolder: ReturnType; + create: ReturnType; +} { + return { + getAbstractFileByPath: vi.fn((path: string) => (existingPaths.has(path) ? { path } : null)), + createFolder: vi.fn(async (path: string) => ({ path })), + create: vi.fn(async (path: string) => ({ path })), + } as unknown as Vault & { + getAbstractFileByPath: ReturnType; + createFolder: ReturnType; + create: ReturnType; + }; +}