mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Normalize exported markdown file links
This commit is contained in:
parent
d3dce6dab9
commit
fd9078c3cd
4 changed files with 179 additions and 3 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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](</Users/showhey/Vault/topics/My Note.md>)",
|
||||
"[Vault note with parens](</Users/showhey/Vault/topics/My (Note).md>)",
|
||||
"[External file](/Users/showhey/Repos/project/README.md)",
|
||||
"[Relative](topics/Other.md)",
|
||||
"[Website](https://example.com/docs)",
|
||||
"",
|
||||
"`[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](<topics/My Note.md>)",
|
||||
"[Vault note with parens](<topics/My (Note).md>)",
|
||||
"External file (`/Users/showhey/Repos/project/README.md`)",
|
||||
"[Relative](topics/Other.md)",
|
||||
"[Website](https://example.com/docs)",
|
||||
"",
|
||||
"`[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](</Users/showhey/Vault/topics/Alpha.md>)\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"]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue