Move archive markdown export IO to app-server service

This commit is contained in:
murashit 2026-06-15 09:58:20 +09:00
parent c26dc83521
commit b40d6cdb5b
11 changed files with 265 additions and 236 deletions

View file

@ -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<boolean>;
mkdir(path: string): Promise<void>;
write(path: string, data: string): Promise<void>;
}
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<ArchiveExportResult> {
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<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);
}
}
}
async function uniqueMarkdownPath(adapter: ArchiveExportAdapter, folder: string, filename: 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 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");
}

View file

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

View file

@ -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<boolean>;
mkdir(path: string): Promise<void>;
write(path: string, data: string): Promise<void>;
}
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<ArchiveExportResult> {
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<ArchiveExportSettings>): string {
const title = exportThreadTitle(thread);
exportedAt = new Date(),
settings?: Partial<ArchiveExportSettings>,
): 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<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);
}
}
}
async function uniqueMarkdownPath(adapter: ArchiveExportAdapter, folder: string, filename: 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 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");
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<ArchiveExportSettings> = {},
): Promise<string> {
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<string, string>();
readonly folders = new Set<string>();
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<boolean> {
return this.files.has(path) || this.folders.has(path);
}
async mkdir(path: string): Promise<void> {
this.folders.add(path);
}
async write(path: string, data: string): Promise<void> {
this.files.set(path, data);
}
): string {
return archivedThreadMarkdown(source, now, {
archiveExportFolderTemplate: "Exports",
archiveExportFilenameTemplate: "{{title}}",
...settings,
});
}
function thread(

View file

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