From fd9078c3cda2a2d4ef050f3c2ebdc751a7bc2dfd Mon Sep 17 00:00:00 2001 From: murashit Date: Wed, 27 May 2026 11:56:59 +0900 Subject: [PATCH] Normalize exported markdown file links --- src/domain/threads/export.ts | 116 +++++++++++++++++++++++++++- src/features/chat/thread-actions.ts | 6 +- src/features/threads-view/view.ts | 6 +- tests/domain/threads/export.test.ts | 54 +++++++++++++ 4 files changed, 179 insertions(+), 3 deletions(-) diff --git a/src/domain/threads/export.ts b/src/domain/threads/export.ts index 7df42b65..1bfdf905 100644 --- a/src/domain/threads/export.ts +++ b/src/domain/threads/export.ts @@ -23,10 +23,18 @@ interface TemplateContext { shortId: string; } +interface ParsedMarkdownLink { + raw: string; + label: string; + href: string; + end: number; +} + export interface ArchiveExportSettings { archiveExportFolderTemplate: string; archiveExportFilenameTemplate: string; archiveExportTags?: string; + vaultPath?: string; } export async function exportArchivedThreadMarkdown( @@ -60,7 +68,39 @@ export function markdownFromThread(thread: Thread, exportedAt = new Date(), sett "", ...turnMarkdownLines(thread.turns), ]; - return `${trimTrailingBlankLines(lines).join("\n")}\n`; + const markdown = `${trimTrailingBlankLines(lines).join("\n")}\n`; + return settings?.vaultPath ? normalizeExportedMarkdownLinks(markdown, settings.vaultPath) : markdown; +} + +export function normalizeExportedMarkdownLinks(markdown: string, vaultPath: string): string { + const lines = markdown.split("\n"); + let inFence = false; + return lines + .map((line) => { + if (line.trimStart().startsWith("```")) { + inFence = !inFence; + return line; + } + return inFence ? line : normalizeExportedMarkdownLinksInLine(line, vaultPath); + }) + .join("\n"); +} + +function normalizeExportedMarkdownLinksInLine(line: string, vaultPath: string): string { + let output = ""; + let index = 0; + while (index < line.length) { + const parsed = parseMarkdownLinkAt(line, index); + if (!parsed || isInsideInlineCode(line, index)) { + output += line[index] ?? ""; + index += 1; + continue; + } + + output += normalizedExportedMarkdownLink(parsed, vaultPath); + index = parsed.end; + } + return output; } export function normalizedArchiveTags(value: string): string[] { @@ -224,6 +264,80 @@ 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; +} + +function parseMarkdownLinkAt(line: string, start: number): ParsedMarkdownLink | null { + if (line[start] !== "[" || line[start - 1] === "!") return null; + + const labelEnd = line.indexOf("]", start + 1); + if (labelEnd === -1 || line[labelEnd + 1] !== "(") return null; + + const hrefStart = labelEnd + 2; + const hrefEnd = line[hrefStart] === "<" ? line.indexOf(">)", hrefStart + 1) : line.indexOf(")", hrefStart); + if (hrefEnd === -1) return null; + + const end = line[hrefStart] === "<" ? hrefEnd + 2 : hrefEnd + 1; + return { + raw: line.slice(start, end), + label: line.slice(start + 1, labelEnd), + href: line.slice(hrefStart, hrefEnd + (line[hrefStart] === "<" ? 1 : 0)), + end, + }; +} + +function normalizedExportedMarkdownLink(link: ParsedMarkdownLink, vaultPath: string): string { + const href = unwrappedMarkdownHref(link.href.trim()); + if (!href || isExternalHref(href)) return link.raw; + + const vaultRelative = vaultRelativePath(vaultPath, href); + if (vaultRelative) return `[${link.label}](${markdownHref(vaultRelative)})`; + + return isAbsolutePath(normalizeFilePath(href)) ? `${link.label} (\`${href.replace(/`/g, "\\`")}\`)` : link.raw; +} + +function markdownHref(value: string): string { + return /[\s()]/.test(value) ? `<${value}>` : value; +} + +function isInsideInlineCode(line: string, offset: number): boolean { + let inCode = false; + for (let index = 0; index < offset; index += 1) { + if (line[index] === "`") inCode = !inCode; + } + return inCode; +} + +function vaultRelativePath(vaultPath: string, path: string): string | null { + const normalizedPath = normalizeFilePath(path); + const normalizedVaultPath = normalizeFilePath(vaultPath); + if (!normalizedPath || !normalizedVaultPath) return null; + if (!isAbsolutePath(normalizedPath) || normalizedPath === normalizedVaultPath) return null; + + const vaultPrefix = normalizedVaultPath.endsWith("/") ? normalizedVaultPath : `${normalizedVaultPath}/`; + return normalizedPath.startsWith(vaultPrefix) ? normalizedPath.slice(vaultPrefix.length) : null; +} + +function normalizeFilePath(path: string): string { + const normalized = path.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/$/, ""); + return normalized.replace(/^\.\//, ""); +} + +function isExternalHref(href: string): boolean { + if (isWindowsAbsolutePath(href)) return false; + return /^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith("//"); +} + +function isAbsolutePath(path: string): boolean { + return path.startsWith("/") || isWindowsAbsolutePath(path); +} + +function isWindowsAbsolutePath(path: string): boolean { + return /^[a-z]:[\\/]/i.test(path); +} + function yamlString(value: string): string { return JSON.stringify(value); } diff --git a/src/features/chat/thread-actions.ts b/src/features/chat/thread-actions.ts index 6eb52295..4dcdd619 100644 --- a/src/features/chat/thread-actions.ts +++ b/src/features/chat/thread-actions.ts @@ -42,7 +42,11 @@ export class ChatThreadActionController { const settings = this.host.settings(); if (saveMarkdown) { const response = await client.readThread(threadId, true); - const result = await exportArchivedThreadMarkdown(response.thread, settings, this.host.archiveAdapter()); + const result = await exportArchivedThreadMarkdown( + response.thread, + { ...settings, vaultPath: this.host.vaultPath }, + this.host.archiveAdapter(), + ); new Notice(`Saved archived thread to ${result.path}.`); } await client.archiveThread(threadId); diff --git a/src/features/threads-view/view.ts b/src/features/threads-view/view.ts index 611f683a..ce7e2046 100644 --- a/src/features/threads-view/view.ts +++ b/src/features/threads-view/view.ts @@ -330,7 +330,11 @@ export class CodexThreadsView extends ItemView { if (!this.client) return; if (saveMarkdown) { const response = await this.client.readThread(threadId, true); - const result = await exportArchivedThreadMarkdown(response.thread, this.plugin.settings, this.app.vault.adapter); + const result = await exportArchivedThreadMarkdown( + response.thread, + { ...this.plugin.settings, vaultPath: this.plugin.vaultPath }, + this.app.vault.adapter, + ); new Notice(`Saved archived thread to ${result.path}.`); } await this.client.archiveThread(threadId); diff --git a/tests/domain/threads/export.test.ts b/tests/domain/threads/export.test.ts index 54ef5530..f116afe6 100644 --- a/tests/domain/threads/export.test.ts +++ b/tests/domain/threads/export.test.ts @@ -6,6 +6,7 @@ import type { Turn } from "../../../src/generated/app-server/v2/Turn"; import { exportArchivedThreadMarkdown, markdownFromThread, + normalizeExportedMarkdownLinks, normalizedArchiveTags, type ArchiveExportAdapter, } from "../../../src/domain/threads/export"; @@ -150,6 +151,59 @@ describe("thread archive export", () => { ]); }); + it("normalizes exported markdown links for vault and external absolute paths", () => { + const output = normalizeExportedMarkdownLinks( + [ + "[Vault note]()", + "[Vault note with parens]()", + "[External file](/Users/showhey/Repos/project/README.md)", + "[Relative](topics/Other.md)", + "[Website](https://example.com/docs)", + "![Image](/Users/showhey/Repos/project/image.png)", + "`[Code link](/Users/showhey/Repos/project/README.md)`", + "```", + "[Code block link](/Users/showhey/Repos/project/README.md)", + "```", + ].join("\n"), + "/Users/showhey/Vault", + ); + + expect(output).toBe( + [ + "[Vault note]()", + "[Vault note with parens]()", + "External file (`/Users/showhey/Repos/project/README.md`)", + "[Relative](topics/Other.md)", + "[Website](https://example.com/docs)", + "![Image](/Users/showhey/Repos/project/image.png)", + "`[Code link](/Users/showhey/Repos/project/README.md)`", + "```", + "[Code block link](/Users/showhey/Repos/project/README.md)", + "```", + ].join("\n"), + ); + }); + + it("normalizes exported thread markdown links when vault path is provided", () => { + const output = markdownFromThread( + thread({ + turns: [ + turn([ + assistantMessage( + "assistant-1", + "[Vault]()\n[External](/Users/showhey/Repos/project/README.md)", + ), + ]), + ], + }), + new Date(2026, 4, 18), + { vaultPath: "/Users/showhey/Vault" }, + ); + + 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"]);