Something went wrong. Try again.
🌿 Collaborative wiki on ATProto forked from juprodh.me/lichen.wiki
Something went wrong. Try again.
7.9 kB · 248 lines
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249import { 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<string, string>, imageMap: Map<string, string>,): 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 ``; } 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 ``; } 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( /(?<!!)\[([^\]]*)\]\(([^)]+)\)/g, (_match, text: string, href: string) => { 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<string, string>,): ExportResult { const blobRefs: { did: string; cid: string }[] = []; const seenCids = new Set<string>();
// 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 ``; }, );
// 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<string>();
// 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:  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<string, string>, 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<string, string>, 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);}