import { basename } from "node:path"; import { IMAGE_EXTENSIONS } from "../image-types.ts"; // The frontmatter keys the export writes; the import strips exactly these back off. export const OWNED_FRONTMATTER_KEYS = ["title"]; interface ExportResult { content: string; blobRefs: { did: string; cid: string }[]; } interface ImportRewriteResult { content: string; // Hrefs of `.md` links that matched no note. An unresolved [[wikilink]] is a normal // Obsidian state (the note may not exist yet); a markdown link to a missing file is // a dead link, so only those are reported. unresolvedLinks: string[]; } // Obsidian → Lichen: note-title wikilinks become slugs, local image refs become blob URLs. export function rewriteForImport( content: string, slugMap: Map, imageMap: Map, ): ImportRewriteResult { const unresolvedLinks: string[] = []; // 1. Rewrite Obsidian image embeds: ![[image.png]], ![[image.png|300]] let result = content.replace( /!\[\[([^\]]+)\]\]/g, (_match, filename: string) => { const { name, suffix } = splitEmbed(filename); const url = findImageUrl(imageMap, name); if (url) { const base = name.replace(/\.[^.]+$/, ""); // A numeric suffix is a size, which our image plugin reads back off the // alt; anything else is Obsidian's alt text. const alt = isEmbedSize(suffix) ? `${base}|${suffix}` : suffix || base; return `![${alt}](${url})`; } return _match; // leave as-is if image not found }, ); // 2. Rewrite standard markdown images with relative paths result = result.replace( /!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt: string, href: string) => { if (isAbsoluteUrl(href) || href.startsWith("/blob/")) { return _match; } const name = basename(href.trim()); const url = findImageUrl(imageMap, name); if (url) { return `![${alt}](${url})`; } return _match; }, ); // 3. Rewrite wikilinks: [[Note Title]], [[Note Title#Section]], [[Note Title|display]] result = result.replace(/\[\[([^\]]+)\]\]/g, (_match, inner: string) => { const { target, section, display } = splitWikilink(inner); if (target.includes("/")) { return _match; } const slug = findSlug(slugMap, target.trim()); if (slug) { return `[[${slug}${section}${display ? `|${display}` : ""}]]`; } return _match; // leave as-is if no matching note }); // 4. Rewrite markdown-style internal links: [text](Some%20Note.md), the other format // Obsidian writes when "Use [[Wikilinks]]" is off. The lookbehind keeps image embeds // (handled in step 2) out of this. result = result.replace( /(? { const trimmed = href.trim(); if (isAbsoluteUrl(trimmed) || trimmed.startsWith("/")) return _match; const hashIdx = trimmed.indexOf("#"); const path = hashIdx >= 0 ? trimmed.slice(0, hashIdx) : trimmed; const section = hashIdx >= 0 ? trimmed.slice(hashIdx) : ""; if (!/\.(md|markdown)$/i.test(path)) return _match; const title = basename(decodePath(path)).replace(/\.(md|markdown)$/i, ""); const slug = findSlug(slugMap, title); if (!slug) { unresolvedLinks.push(trimmed); return _match; } return `[[${slug}${section}${text ? `|${text}` : ""}]]`; }, ); return { content: result, unresolvedLinks }; } // A vault path is percent-encoded by Obsidian but may also contain a literal `%`, // which makes decodeURIComponent throw rather than round-trip. function decodePath(path: string): string { try { return decodeURIComponent(path); } catch { return path; } } // Lichen → Obsidian: slug wikilinks become note titles, /blob/ refs become attachments/ paths. export function rewriteForExport( content: string, slugToTitle: Map, ): ExportResult { const blobRefs: { did: string; cid: string }[] = []; const seenCids = new Set(); // 1. Rewrite blob image refs to attachments/ let result = content.replace( /!\[([^\]]*)\]\(\/blob\/(did:[^/]+)\/([^)\s]+)\)/g, (_match, alt: string, did: string, cid: string) => { if (!seenCids.has(cid)) { seenCids.add(cid); blobRefs.push({ did, cid }); } // Use cid as filename — extension will be added when building the zip return `![${alt}](attachments/${cid})`; }, ); // 2. Rewrite wikilinks: [[slug]], [[slug#section]], [[slug|display]] result = result.replace(/\[\[([^\]]+)\]\]/g, (_match, inner: string) => { const { target, section, display } = splitWikilink(inner); if (target.includes("/")) { return _match; } const title = slugToTitle.get(target.trim()); if (title) { return `[[${title}${section}${display ? `|${display}` : ""}]]`; } return _match; }); return { content: result, blobRefs }; } // Returns deduplicated basenames of local (non-URL, non-/blob/) image refs. export function extractLocalImageRefs(content: string): string[] { const refs = new Set(); // Obsidian embeds: ![[filename]], ![[filename|300]] for (const match of content.matchAll(/!\[\[([^\]]+)\]\]/g)) { const { name } = splitEmbed(match[1] as string); if (isImageFilename(name)) { refs.add(name.toLowerCase()); } } // Standard markdown images: ![alt](path) for (const match of content.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) { const href = (match[1] as string).trim(); if (!isAbsoluteUrl(href) && !href.startsWith("/blob/")) { const name = basename(href); if (isImageFilename(name)) { refs.add(name.toLowerCase()); } } } return [...refs]; } // Splits [[target#section|display]]; the "#section" keeps its hash so it can be // concatenated back verbatim — a heading or a ^block-id, neither of which we rewrite. function splitWikilink(inner: string): { target: string; section: string; display: string | null; } { const pipeIdx = inner.indexOf("|"); const beforePipe = pipeIdx >= 0 ? inner.slice(0, pipeIdx) : inner; const display = pipeIdx >= 0 ? inner.slice(pipeIdx + 1) : null; const hashIdx = beforePipe.indexOf("#"); return { target: hashIdx >= 0 ? beforePipe.slice(0, hashIdx) : beforePipe, section: hashIdx >= 0 ? beforePipe.slice(hashIdx) : "", display, }; } // An embed's `|` separates the filename from a size or an alt: ![[photo.png|300]]. function splitEmbed(inner: string): { name: string; suffix: string } { const pipeIdx = inner.indexOf("|"); const path = pipeIdx >= 0 ? inner.slice(0, pipeIdx) : inner; return { name: basename(path.trim()), suffix: pipeIdx >= 0 ? inner.slice(pipeIdx + 1).trim() : "", }; } function isEmbedSize(suffix: string): boolean { return /^\d+(x\d+)?$/.test(suffix); } function isAbsoluteUrl(s: string): boolean { return ( s.startsWith("http://") || s.startsWith("https://") || s.startsWith("//") ); } function isImageFilename(name: string): boolean { const ext = name.slice(name.lastIndexOf(".")).toLowerCase(); return IMAGE_EXTENSIONS.has(ext); } // Exact match, then case-insensitive fallback. function findImageUrl( imageMap: Map, filename: string, ): string | undefined { const exact = imageMap.get(filename); if (exact) return exact; const lower = filename.toLowerCase(); for (const [key, value] of imageMap) { if (key.toLowerCase() === lower) return value; } return undefined; } // Exact match, then case-insensitive, then without the extension — Obsidian accepts // `[[Note.md]]` as a link to the same note as `[[Note]]`. function findSlug( slugMap: Map, title: string, ): string | undefined { const exact = slugMap.get(title); if (exact) return exact; const lower = title.toLowerCase(); for (const [key, value] of slugMap) { if (key.toLowerCase() === lower) return value; } const withoutExtension = title.replace(/\.(md|markdown)$/i, ""); return withoutExtension === title ? undefined : findSlug(slugMap, withoutExtension); }