mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Align thread archive export with Obsidian vault paths
This commit is contained in:
parent
b393000132
commit
8edb115e08
17 changed files with 197 additions and 97 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
14
src/shared/obsidian/archive-export-destination.ts
Normal file
14
src/shared/obsidian/archive-export-destination.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
35
tests/shared/obsidian/archive-export-destination.test.ts
Normal file
35
tests/shared/obsidian/archive-export-destination.test.ts
Normal 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>;
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue