From b40d6cdb5b524df85e04a6b975ddf489f585c380 Mon Sep 17 00:00:00 2001 From: murashit Date: Mon, 15 Jun 2026 09:58:20 +0900 Subject: [PATCH] Move archive markdown export IO to app-server service --- .../services/thread-archive-markdown.ts | 136 ++++++++++++++++++ src/app-server/services/thread-archive.ts | 4 +- src/domain/threads/archive-markdown.ts | 136 ++---------------- .../chat/application/threads/composition.ts | 2 +- .../threads/thread-management-actions.ts | 2 +- src/features/chat/host/session.ts | 2 +- src/features/threads-view/session.ts | 2 +- .../thread-archive-markdown.test.ts | 86 +++++++++++ tests/app-server/thread-archive.test.ts | 2 +- tests/domain/threads/archive-markdown.test.ts | 127 ++++------------ .../threads/thread-management-actions.test.ts | 2 +- 11 files changed, 265 insertions(+), 236 deletions(-) create mode 100644 src/app-server/services/thread-archive-markdown.ts create mode 100644 tests/app-server/thread-archive-markdown.test.ts diff --git a/src/app-server/services/thread-archive-markdown.ts b/src/app-server/services/thread-archive-markdown.ts new file mode 100644 index 00000000..28a5c427 --- /dev/null +++ b/src/app-server/services/thread-archive-markdown.ts @@ -0,0 +1,136 @@ +import { + archivedThreadMarkdown, + archivedThreadTitle, + type ArchiveExportSettings, + type ArchiveThreadInput, +} from "../../domain/threads/archive-markdown"; +import { shortThreadId } from "../../utils"; + +export interface ArchiveExportAdapter { + exists(path: string): Promise; + mkdir(path: string): Promise; + write(path: string, data: string): Promise; +} + +export interface ArchiveExportResult { + path: string; +} + +interface TemplateContext { + date: string; + time: string; + title: string; + id: string; + shortId: string; +} + +export async function exportArchivedThreadMarkdown( + thread: ArchiveThreadInput, + settings: ArchiveExportSettings, + adapter: ArchiveExportAdapter, + 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 path = await uniqueMarkdownPath(adapter, folder, filename); + await adapter.write(path, archivedThreadMarkdown(thread, now, settings)); + return { path }; +} + +function templateContext(thread: ArchiveThreadInput, now: Date): TemplateContext { + const title = sanitizePathSegment(archivedThreadTitle(thread)); + return { + date: formatDate(now), + time: formatTime(now), + title, + id: sanitizePathSegment(thread.id), + shortId: sanitizePathSegment(shortThreadId(thread.id)), + }; +} + +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 { + 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)) { + throw new Error("Archive export folder must be relative to the vault."); + } + + const segments = expanded + .split("/") + .map((segment) => segment.trim()) + .filter(Boolean); + if (segments.length === 0) throw new Error("Archive export folder template produced an empty path."); + if (segments.some((segment) => segment === "." || segment === "..")) { + throw new Error("Archive export folder cannot contain relative path segments."); + } + return segments.map(sanitizePathSegment).join("/"); +} + +function filenameFromTemplate(template: string, context: TemplateContext): string { + const expanded = expandTemplate(template, context) + .trim() + .replace(/[\\/]+/g, "-"); + const filename = 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 { + 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); + } + } +} + +async function uniqueMarkdownPath(adapter: ArchiveExportAdapter, folder: string, filename: 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 suffix = 2; + while (await adapter.exists(candidate)) { + candidate = `${folder}/${stem} ${String(suffix)}${extension}`; + suffix += 1; + } + return candidate; +} + +function sanitizePathSegment(value: string): string { + return value + .split("") + .map((char) => (isUnsafePathChar(char) ? "-" : char)) + .join("") + .replace(/\s+/g, " ") + .trim() + .replace(/^\.+$/, "") + .slice(0, 120) + .trim(); +} + +function isUnsafePathChar(char: string): boolean { + return char.charCodeAt(0) < 32 || '<>:"/\\|?*'.includes(char); +} + +function formatDate(date: Date): string { + return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; +} + +function formatTime(date: Date): string { + return `${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`; +} + +function pad2(value: number): string { + return value.toString().padStart(2, "0"); +} diff --git a/src/app-server/services/thread-archive.ts b/src/app-server/services/thread-archive.ts index a8dd2b41..9163f5e4 100644 --- a/src/app-server/services/thread-archive.ts +++ b/src/app-server/services/thread-archive.ts @@ -1,6 +1,6 @@ -import type { ArchiveExportAdapter, ArchiveExportSettings } from "../../domain/threads/archive-markdown"; +import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown"; import type { AppServerClient } from "../connection/client"; -import { exportArchivedThreadMarkdown } from "../../domain/threads/archive-markdown"; +import { exportArchivedThreadMarkdown, type ArchiveExportAdapter } from "./thread-archive-markdown"; import { readThreadForArchiveExport } from "./threads"; export interface ArchiveThreadOptions { diff --git a/src/domain/threads/archive-markdown.ts b/src/domain/threads/archive-markdown.ts index 49e484bd..5be39054 100644 --- a/src/domain/threads/archive-markdown.ts +++ b/src/domain/threads/archive-markdown.ts @@ -1,26 +1,7 @@ -import { shortThreadId } from "../../utils"; import { getThreadTitle, type Thread } from "./model"; import { referencedThreadMetadataFromPrompt } from "./reference"; import type { ThreadTranscriptEntry } from "./transcript"; -export interface ArchiveExportAdapter { - exists(path: string): Promise; - mkdir(path: string): Promise; - write(path: string, data: string): Promise; -} - -export interface ArchiveExportResult { - path: string; -} - -interface TemplateContext { - date: string; - time: string; - title: string; - id: string; - shortId: string; -} - interface ParsedMarkdownLink { raw: string; label: string; @@ -39,24 +20,12 @@ export interface ArchiveThreadInput extends Thread { transcriptEntries: readonly ThreadTranscriptEntry[]; } -export async function exportArchivedThreadMarkdown( +export function archivedThreadMarkdown( thread: ArchiveThreadInput, - settings: ArchiveExportSettings, - adapter: ArchiveExportAdapter, - 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 path = await uniqueMarkdownPath(adapter, folder, filename); - await adapter.write(path, markdownFromThread(thread, now, settings)); - return { path }; -} - -function markdownFromThread(thread: ArchiveThreadInput, exportedAt = new Date(), settings?: Partial): string { - const title = exportThreadTitle(thread); + exportedAt = new Date(), + settings?: Partial, +): string { + const title = archivedThreadTitle(thread); const tags = normalizedArchiveTags(settings?.archiveExportTags ?? ""); const lines = [ "---", @@ -74,6 +43,10 @@ function markdownFromThread(thread: ArchiveThreadInput, exportedAt = new Date(), return settings?.vaultPath ? normalizeExportedMarkdownLinks(markdown, settings.vaultPath) : markdown; } +export function archivedThreadTitle(thread: ArchiveThreadInput): string { + return getThreadTitle(thread) || "Untitled thread"; +} + function normalizeExportedMarkdownLinks(markdown: string, vaultPath: string): string { const lines = markdown.split("\n"); let inFence = false; @@ -172,93 +145,6 @@ function stripMatchingQuotes(value: string): string { return (first === `"` || first === `'`) && first === last ? value.slice(1, -1) : value; } -function templateContext(thread: ArchiveThreadInput, now: Date): TemplateContext { - const title = sanitizePathSegment(exportThreadTitle(thread)); - return { - date: formatDate(now), - time: formatTime(now), - title, - id: sanitizePathSegment(thread.id), - shortId: sanitizePathSegment(shortThreadId(thread.id)), - }; -} - -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 { - 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)) { - throw new Error("Archive export folder must be relative to the vault."); - } - - const segments = expanded - .split("/") - .map((segment) => segment.trim()) - .filter(Boolean); - if (segments.length === 0) throw new Error("Archive export folder template produced an empty path."); - if (segments.some((segment) => segment === "." || segment === "..")) { - throw new Error("Archive export folder cannot contain relative path segments."); - } - return segments.map(sanitizePathSegment).join("/"); -} - -function filenameFromTemplate(template: string, context: TemplateContext): string { - const expanded = expandTemplate(template, context) - .trim() - .replace(/[\\/]+/g, "-"); - const filename = 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 { - 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); - } - } -} - -async function uniqueMarkdownPath(adapter: ArchiveExportAdapter, folder: string, filename: 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 suffix = 2; - while (await adapter.exists(candidate)) { - candidate = `${folder}/${stem} ${String(suffix)}${extension}`; - suffix += 1; - } - return candidate; -} - -function exportThreadTitle(thread: ArchiveThreadInput): string { - return getThreadTitle(thread) || "Untitled thread"; -} - -function sanitizePathSegment(value: string): string { - return value - .split("") - .map((char) => (isUnsafePathChar(char) ? "-" : char)) - .join("") - .replace(/\s+/g, " ") - .trim() - .replace(/^\.+$/, "") - .slice(0, 120) - .trim(); -} - -function isUnsafePathChar(char: string): boolean { - return char.charCodeAt(0) < 32 || '<>:"/\\|?*'.includes(char); -} - function unwrappedMarkdownHref(value: string): string { if (value.startsWith("<") && value.endsWith(">")) return value.slice(1, -1); return value; @@ -341,10 +227,6 @@ function formatDate(date: Date): string { return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; } -function formatTime(date: Date): string { - return `${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`; -} - function pad2(value: number): string { return value.toString().padStart(2, "0"); } diff --git a/src/features/chat/application/threads/composition.ts b/src/features/chat/application/threads/composition.ts index cee4ada2..27eb23da 100644 --- a/src/features/chat/application/threads/composition.ts +++ b/src/features/chat/application/threads/composition.ts @@ -1,5 +1,5 @@ import type { AppServerClient } from "../../../../app-server/connection/client"; -import type { ArchiveExportAdapter } from "../../../../domain/threads/archive-markdown"; +import type { ArchiveExportAdapter } from "../../../../app-server/services/thread-archive-markdown"; import type { GoalActions } from "./goal-actions"; import { createSelectionActions } from "./selection-actions"; import type { ChatResumeWorkTracker, ChatViewDeferredTasks } from "../lifecycle"; diff --git a/src/features/chat/application/threads/thread-management-actions.ts b/src/features/chat/application/threads/thread-management-actions.ts index a0c9fbcb..9e4c85d2 100644 --- a/src/features/chat/application/threads/thread-management-actions.ts +++ b/src/features/chat/application/threads/thread-management-actions.ts @@ -2,7 +2,7 @@ import type { AppServerClient } from "../../../../app-server/connection/client"; import { rollbackThread as rollbackThreadOnAppServer } from "../../../../app-server/services/threads"; import { inheritedForkThreadName } from "../../../../domain/threads/model"; import type { CodexPanelSettings } from "../../../../settings/model"; -import type { ArchiveExportAdapter } from "../../../../domain/threads/archive-markdown"; +import type { ArchiveExportAdapter } from "../../../../app-server/services/thread-archive-markdown"; import { archiveThreadOnAppServer } from "../../../../app-server/services/thread-archive"; import { renameThreadOnAppServer, threadRenameFromValue, type ThreadRename } from "../../../../app-server/services/thread-rename"; import { diff --git a/src/features/chat/host/session.ts b/src/features/chat/host/session.ts index 4c4d72a5..94900427 100644 --- a/src/features/chat/host/session.ts +++ b/src/features/chat/host/session.ts @@ -7,7 +7,7 @@ import { getThreadTitle } from "../../../domain/threads/model"; import type { SharedServerMetadata } from "../../../domain/server/metadata"; import { shortThreadId } from "../../../utils"; import type { OpenCodexPanelSnapshot } from "../../../workspace/open-panel-snapshot"; -import type { ArchiveExportAdapter } from "../../../domain/threads/archive-markdown"; +import type { ArchiveExportAdapter } from "../../../app-server/services/thread-archive-markdown"; import type { CodexChatHost } from "../application/ports/chat-host"; import type { ChatConnectionController } from "../application/connection/connection-controller"; import { reconnectPanel, type ChatReconnectActionsHost } from "../application/connection/reconnect-actions"; diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index 4ff87d3b..3fb9d555 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -7,7 +7,7 @@ import type { Thread } from "../../domain/threads/model"; import type { CodexPanelSettings } from "../../settings/model"; import type { OpenCodexPanelSnapshot } from "../../workspace/open-panel-snapshot"; import { archiveThreadOnAppServer } from "../../app-server/services/thread-archive"; -import type { ArchiveExportAdapter } from "../../domain/threads/archive-markdown"; +import type { ArchiveExportAdapter } from "../../app-server/services/thread-archive-markdown"; import { renameThreadOnAppServer, threadRenameFromValue } from "../../app-server/services/thread-rename"; import { generateThreadTitleWithCodex } from "../../app-server/services/thread-title-generation"; import { findThreadTitleContext, THREAD_TITLE_CONTEXT_UNAVAILABLE_MESSAGE } from "../../domain/threads/title-generation-model"; diff --git a/tests/app-server/thread-archive-markdown.test.ts b/tests/app-server/thread-archive-markdown.test.ts new file mode 100644 index 00000000..79703b56 --- /dev/null +++ b/tests/app-server/thread-archive-markdown.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { exportArchivedThreadMarkdown, type ArchiveExportAdapter } 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 result = await exportArchivedThreadMarkdown( + thread({ id: "abcdef12-9999", name: "My/Thread?" }), + { + archiveExportFolderTemplate: "Codex Archives/{{date}}", + archiveExportFilenameTemplate: "{{title}} {{shortId}}", + archiveExportTags: "codex, archive", + }, + adapter, + 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"]'); + }); + + it("rejects vault-external or empty export paths", async () => { + const adapter = new MemoryAdapter(); + await expect( + exportArchivedThreadMarkdown( + thread(), + { archiveExportFolderTemplate: "../outside", archiveExportFilenameTemplate: "{{title}}.md" }, + adapter, + ), + ).rejects.toThrow("relative path segments"); + await expect( + exportArchivedThreadMarkdown(thread(), { archiveExportFolderTemplate: "Exports", archiveExportFilenameTemplate: " " }, adapter), + ).rejects.toThrow("empty filename"); + }); +}); + +function thread(overrides: Partial = {}): ArchiveThreadInput { + return { + id: "019e0182-cb70-7a72-ab48-8bc9d0b0d781", + preview: "Preview", + createdAt: 1, + updatedAt: 1, + name: "Thread", + archived: false, + transcriptEntries: [transcriptEntry("user", "Hello", 1)], + ...overrides, + }; +} + +function transcriptEntry(kind: ThreadTranscriptEntry["kind"], text: string, timestamp: number | null): ThreadTranscriptEntry { + return { kind, text, timestamp }; +} + +class MemoryAdapter implements ArchiveExportAdapter { + readonly files = new Map(); + readonly folders = new Set(); + + constructor(existingFiles: string[] = []) { + for (const file of existingFiles) { + this.files.set(file, ""); + const parts = file.split("/"); + for (let index = 1; index < parts.length; index += 1) { + this.folders.add(parts.slice(0, index).join("/")); + } + } + } + + async exists(path: string): Promise { + return this.files.has(path) || this.folders.has(path); + } + + async mkdir(path: string): Promise { + this.folders.add(path); + } + + async write(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 4ec10ab2..c2ea8fe7 100644 --- a/tests/app-server/thread-archive.test.ts +++ b/tests/app-server/thread-archive.test.ts @@ -2,7 +2,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 type { ArchiveExportAdapter } from "../../src/domain/threads/archive-markdown"; +import type { ArchiveExportAdapter } from "../../src/app-server/services/thread-archive-markdown"; import { archiveThreadOnAppServer } from "../../src/app-server/services/thread-archive"; import { DEFAULT_SETTINGS } from "../../src/settings/model"; diff --git a/tests/domain/threads/archive-markdown.test.ts b/tests/domain/threads/archive-markdown.test.ts index 17d91f08..82d59b8f 100644 --- a/tests/domain/threads/archive-markdown.test.ts +++ b/tests/domain/threads/archive-markdown.test.ts @@ -1,17 +1,13 @@ import { describe, expect, it } from "vitest"; -import { - exportArchivedThreadMarkdown, - type ArchiveExportAdapter, - type ArchiveExportSettings, -} from "../../../src/domain/threads/archive-markdown"; +import { archivedThreadMarkdown, type ArchiveExportSettings } from "../../../src/domain/threads/archive-markdown"; import type { Thread } from "../../../src/domain/threads/model"; import { referencedThreadPromptBundle } from "../../../src/domain/threads/reference"; import type { ThreadTranscriptEntry } from "../../../src/domain/threads/transcript"; describe("thread archive export", () => { - it("writes frontmatter and readable user/codex turns with turn timestamps", async () => { - const output = await exportedMarkdown( + it("writes frontmatter and readable user/codex turns with turn timestamps", () => { + const output = exportedMarkdown( thread({ id: "thread-12345678", name: "Exported thread", @@ -54,8 +50,8 @@ describe("thread archive export", () => { expect(output).not.toContain("npm test"); }); - it("falls back when turn timestamps are missing and uses start time for incomplete agent output", async () => { - const output = await exportedMarkdown( + it("falls back when turn timestamps are missing and uses start time for incomplete agent output", () => { + const output = exportedMarkdown( thread({ transcriptEntries: [ transcriptEntry("user", "古い依頼", null), @@ -73,7 +69,7 @@ describe("thread archive export", () => { expect(output).toContain("## Codex - 2026-05-18 10:01\n\n途中の回答"); }); - it("exports only the thread history remaining after rollback", async () => { + it("exports only the thread history remaining after rollback", () => { const rolledBackUserText = "rollbackされた依頼"; const rolledBackAssistantText = "rollbackされた回答"; const remainingEntries = [ @@ -84,7 +80,7 @@ describe("thread archive export", () => { transcriptEntry("user", rolledBackUserText, timestamp(2026, 5, 18, 10, 0)), transcriptEntry("assistant", rolledBackAssistantText, timestamp(2026, 5, 18, 10, 3)), ]; - const output = await exportedMarkdown( + const output = exportedMarkdown( thread({ transcriptEntries: remainingEntries, }), @@ -98,14 +94,14 @@ describe("thread archive export", () => { expect(output).not.toContain(rolledBackAssistantText); }); - it("hides embedded /refer context and keeps a compact reference line", async () => { + it("hides embedded /refer context and keeps a compact reference line", () => { const { prompt } = referencedThreadPromptBundle( thread({ id: "thread-ref", name: "参照元" }), [{ userText: "元の依頼", assistantText: "回答" }], "続きです", ); - const output = await exportedMarkdown(thread({ transcriptEntries: [transcriptEntry("user", prompt, 1)] }), new Date(2026, 4, 18)); + const output = exportedMarkdown(thread({ transcriptEntries: [transcriptEntry("user", prompt, 1)] }), new Date(2026, 4, 18)); expect(output).toContain("続きです"); expect(output).toContain("> Referenced: 参照元 (1/20 turns, thread-ref)"); @@ -113,30 +109,30 @@ describe("thread archive export", () => { expect(output).not.toContain("元の依頼"); }); - it("writes optional frontmatter tags from fixed comma-separated settings", async () => { - const output = await exportedMarkdown(thread({ name: "Tagged thread" }), new Date(2026, 4, 18), { + it("writes optional frontmatter tags from fixed comma-separated settings", () => { + const output = exportedMarkdown(thread({ name: "Tagged thread" }), new Date(2026, 4, 18), { archiveExportTags: '#codex, "archive", codex, {{title}}', }); expect(output).toContain('tags: ["codex", "archive", "{{title}}"]'); }); - it("omits frontmatter tags when archive tags are empty", async () => { - const output = await exportedMarkdown(thread(), new Date(2026, 4, 18), { archiveExportTags: " , # , " }); + it("omits frontmatter tags when archive tags are empty", () => { + const output = exportedMarkdown(thread(), new Date(2026, 4, 18), { archiveExportTags: " , # , " }); expect(output).not.toContain("tags:"); }); - it("normalizes archive tags without sorting or changing unmatched quotes", async () => { - const output = await exportedMarkdown(thread(), new Date(2026, 4, 18), { + it("normalizes archive tags without sorting or changing unmatched quotes", () => { + const output = exportedMarkdown(thread(), new Date(2026, 4, 18), { archiveExportTags: ` "codex" , 'archive', #note/tag, codex, "unfinished `, }); expect(output).toContain('tags: ["codex", "archive", "note/tag", "\\"unfinished"]'); }); - it("normalizes exported markdown links for vault and external absolute paths", async () => { - const output = await exportedMarkdown( + it("normalizes exported markdown links for vault and external absolute paths", () => { + const output = exportedMarkdown( thread({ transcriptEntries: [ transcriptEntry( @@ -177,8 +173,8 @@ describe("thread archive export", () => { ); }); - it("normalizes exported thread markdown links when vault path is provided", async () => { - const output = await exportedMarkdown( + it("normalizes exported thread markdown links when vault path is provided", () => { + const output = exportedMarkdown( thread({ transcriptEntries: [ transcriptEntry( @@ -195,89 +191,18 @@ describe("thread archive export", () => { expect(output).toContain("[Vault](topics/Alpha.md)"); expect(output).toContain("External (`/Users/showhey/Repos/project/README.md`)"); }); - - 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 result = await exportArchivedThreadMarkdown( - thread({ id: "abcdef12-9999", name: "My/Thread?" }), - { - archiveExportFolderTemplate: "Codex Archives/{{date}}", - archiveExportFilenameTemplate: "{{title}} {{shortId}}", - archiveExportTags: "codex, archive", - }, - adapter, - 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"]'); - }); - - it("rejects vault-external or empty export paths", async () => { - const adapter = new MemoryAdapter(); - await expect( - exportArchivedThreadMarkdown( - thread(), - { archiveExportFolderTemplate: "../outside", archiveExportFilenameTemplate: "{{title}}.md" }, - adapter, - ), - ).rejects.toThrow("relative path segments"); - await expect( - exportArchivedThreadMarkdown(thread(), { archiveExportFolderTemplate: "Exports", archiveExportFilenameTemplate: " " }, adapter), - ).rejects.toThrow("empty filename"); - }); }); -async function exportedMarkdown( +function exportedMarkdown( source: Thread & { transcriptEntries: ThreadTranscriptEntry[] }, now: Date, settings: Partial = {}, -): Promise { - const adapter = new MemoryAdapter(); - const result = await exportArchivedThreadMarkdown( - source, - { - archiveExportFolderTemplate: "Exports", - archiveExportFilenameTemplate: "{{title}}", - ...settings, - }, - adapter, - now, - ); - const markdown = adapter.files.get(result.path); - if (markdown === undefined) throw new Error(`Expected exported markdown at ${result.path}`); - return markdown; -} - -class MemoryAdapter implements ArchiveExportAdapter { - readonly files = new Map(); - readonly folders = new Set(); - - constructor(existingFiles: string[] = []) { - for (const file of existingFiles) { - this.files.set(file, ""); - const parts = file.split("/"); - for (let index = 1; index < parts.length; index += 1) { - this.folders.add(parts.slice(0, index).join("/")); - } - } - } - - async exists(path: string): Promise { - return this.files.has(path) || this.folders.has(path); - } - - async mkdir(path: string): Promise { - this.folders.add(path); - } - - async write(path: string, data: string): Promise { - this.files.set(path, data); - } +): string { + return archivedThreadMarkdown(source, now, { + archiveExportFolderTemplate: "Exports", + archiveExportFilenameTemplate: "{{title}}", + ...settings, + }); } function thread( diff --git a/tests/features/chat/threads/thread-management-actions.test.ts b/tests/features/chat/threads/thread-management-actions.test.ts index 212cf93d..c4fb38b3 100644 --- a/tests/features/chat/threads/thread-management-actions.test.ts +++ b/tests/features/chat/threads/thread-management-actions.test.ts @@ -2,7 +2,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 type { ArchiveExportAdapter } from "../../../../src/domain/threads/archive-markdown"; +import type { ArchiveExportAdapter } from "../../../../src/app-server/services/thread-archive-markdown"; import { createChatState } from "../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../src/features/chat/application/state/store"; import {