Align thread archive export with Obsidian vault paths

This commit is contained in:
murashit 2026-06-24 15:13:35 +09:00
parent b393000132
commit 8edb115e08
17 changed files with 197 additions and 97 deletions

View file

@ -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<boolean>;
createFolder(path: string): Promise<void>;
createMarkdownFile(path: string, data: string): Promise<void>;
}
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<ArchiveExportResult> {
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<void> {
async function ensureFolder(destination: ArchiveExportDestination, folder: string): Promise<void> {
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<string> {
async function uniqueMarkdownPath(
destination: ArchiveExportDestination,
folder: string,
filename: string,
normalizePath: (path: string) => string,
): Promise<string> {
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;

View file

@ -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;
}

View file

@ -26,12 +26,6 @@ export interface ArchiveExportSettings {
vaultConfigDir?: string;
}
export interface ArchiveExportAdapter {
exists(path: string): Promise<boolean>;
mkdir(path: string): Promise<void>;
write(path: string, data: string): Promise<void>;
}
export interface ArchiveThreadInput extends Thread {
transcriptEntries: readonly ThreadTranscriptEntry[];
}

View file

@ -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;

View file

@ -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);

View file

@ -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();
},

View file

@ -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);

View file

@ -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,
});

View file

@ -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(),
}),
);

View file

@ -0,0 +1,14 @@
import { normalizePath, type Vault } from "obsidian";
export function createObsidianArchiveExportDestination(vault: Vault) {
return {
normalizePath,
exists: async (path: string): Promise<boolean> => vault.getAbstractFileByPath(normalizePath(path)) !== null,
createFolder: async (path: string): Promise<void> => {
await vault.createFolder(normalizePath(path));
},
createMarkdownFile: async (path: string, data: string): Promise<void> => {
await vault.create(normalizePath(path), data);
},
};
}

View file

@ -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> = {}): 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<string, string>();
readonly folders = new Set<string>();
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<void> {
async createFolder(path: string): Promise<void> {
this.folders.add(path);
}
async write(path: string, data: string): Promise<void> {
async createMarkdownFile(path: string, data: string): Promise<void> {
this.files.set(path, data);
}
}

View file

@ -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<typeof vi.fn<ArchiveExportAdapter["exists"]>>;
mkdir: ReturnType<typeof vi.fn<ArchiveExportAdapter["mkdir"]>>;
write: ReturnType<typeof vi.fn<ArchiveExportAdapter["write"]>>;
function archiveDestination(): ArchiveExportDestination & {
exists: ReturnType<typeof vi.fn<ArchiveExportDestination["exists"]>>;
createFolder: ReturnType<typeof vi.fn<ArchiveExportDestination["createFolder"]>>;
createMarkdownFile: ReturnType<typeof vi.fn<ArchiveExportDestination["createMarkdownFile"]>>;
} {
return {
exists: vi.fn<ArchiveExportAdapter["exists"]>().mockResolvedValue(false),
mkdir: vi.fn<ArchiveExportAdapter["mkdir"]>().mockResolvedValue(undefined),
write: vi.fn<ArchiveExportAdapter["write"]>().mockResolvedValue(undefined),
normalizePath: (path) => path,
exists: vi.fn<ArchiveExportDestination["exists"]>().mockResolvedValue(false),
createFolder: vi.fn<ArchiveExportDestination["createFolder"]>().mockResolvedValue(undefined),
createMarkdownFile: vi.fn<ArchiveExportDestination["createMarkdownFile"]>().mockResolvedValue(undefined),
};
}

View file

@ -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,
},

View file

@ -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<typeof vi.fn<ArchiveExportAdapter["exists"]>>;
mkdir: ReturnType<typeof vi.fn<ArchiveExportAdapter["mkdir"]>>;
write: ReturnType<typeof vi.fn<ArchiveExportAdapter["write"]>>;
type MockArchiveExportDestination = ArchiveExportDestination & {
exists: ReturnType<typeof vi.fn<ArchiveExportDestination["exists"]>>;
createFolder: ReturnType<typeof vi.fn<ArchiveExportDestination["createFolder"]>>;
createMarkdownFile: ReturnType<typeof vi.fn<ArchiveExportDestination["createMarkdownFile"]>>;
};
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<typeof clientMock>;
items: MessageStreamItem[];
archiveAdapter?: ArchiveExportAdapter;
archiveDestination?: ArchiveExportDestination;
settings?: Partial<typeof DEFAULT_SETTINGS>;
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> = {}): MockArchiveExportAdapter {
function archiveDestinationMock(overrides: Partial<MockArchiveExportDestination> = {}): MockArchiveExportDestination {
return {
exists: vi.fn<ArchiveExportAdapter["exists"]>().mockResolvedValue(false),
mkdir: vi.fn<ArchiveExportAdapter["mkdir"]>().mockResolvedValue(undefined),
write: vi.fn<ArchiveExportAdapter["write"]>().mockResolvedValue(undefined),
normalizePath: (path) => path,
exists: vi.fn<ArchiveExportDestination["exists"]>().mockResolvedValue(false),
createFolder: vi.fn<ArchiveExportDestination["createFolder"]>().mockResolvedValue(undefined),
createMarkdownFile: vi.fn<ArchiveExportDestination["createMarkdownFile"]>().mockResolvedValue(undefined),
...overrides,
};
}

View file

@ -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),
};
}

View file

@ -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;

View file

@ -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<string>): Vault & {
getAbstractFileByPath: ReturnType<typeof vi.fn>;
createFolder: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
} {
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<typeof vi.fn>;
createFolder: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
};
}