mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Move archive markdown export IO to app-server service
This commit is contained in:
parent
c26dc83521
commit
b40d6cdb5b
11 changed files with 265 additions and 236 deletions
136
src/app-server/services/thread-archive-markdown.ts
Normal file
136
src/app-server/services/thread-archive-markdown.ts
Normal 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");
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
86
tests/app-server/thread-archive-markdown.test.ts
Normal file
86
tests/app-server/thread-archive-markdown.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue