🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add markdown transform and zip parsing

juprodh 0028bce7 ea4d1031

+715
+184
src/lib/import-export/markdown-transform.ts
··· 1 + import { basename } from "node:path"; 2 + 3 + interface ExportResult { 4 + content: string; 5 + blobRefs: { did: string; cid: string }[]; 6 + } 7 + 8 + /** 9 + * Rewrite Obsidian-style markdown to Lichen format. 10 + * - [[Note Title]] -> [[slug]] 11 + * - [[Note Title|display]] -> [[slug|display]] 12 + * - ![[image.png]] -> ![image](blobUrl) 13 + * - ![alt](relative/image.png) -> ![alt](blobUrl) (basename match) 14 + * - Absolute URLs and /blob/ refs left untouched 15 + */ 16 + export function rewriteForImport( 17 + content: string, 18 + slugMap: Map<string, string>, 19 + imageMap: Map<string, string>, 20 + ): string { 21 + // 1. Rewrite Obsidian image embeds: ![[image.png]] 22 + let result = content.replace( 23 + /!\[\[([^\]]+)\]\]/g, 24 + (_match, filename: string) => { 25 + const name = basename(filename.trim()); 26 + const url = findImageUrl(imageMap, name); 27 + if (url) { 28 + const alt = name.replace(/\.[^.]+$/, ""); 29 + return `![${alt}](${url})`; 30 + } 31 + return _match; // leave as-is if image not found 32 + }, 33 + ); 34 + 35 + // 2. Rewrite standard markdown images with relative paths 36 + result = result.replace( 37 + /!\[([^\]]*)\]\(([^)]+)\)/g, 38 + (_match, alt: string, href: string) => { 39 + if (isAbsoluteUrl(href) || href.startsWith("/blob/")) { 40 + return _match; 41 + } 42 + const name = basename(href.trim()); 43 + const url = findImageUrl(imageMap, name); 44 + if (url) { 45 + return `![${alt}](${url})`; 46 + } 47 + return _match; 48 + }, 49 + ); 50 + 51 + // 3. Rewrite wikilinks: [[Note Title]] and [[Note Title|display]] 52 + result = result.replace(/\[\[([^\]]+)\]\]/g, (_match, inner: string) => { 53 + const pipeIdx = inner.indexOf("|"); 54 + const title = pipeIdx >= 0 ? inner.slice(0, pipeIdx) : inner; 55 + const display = pipeIdx >= 0 ? inner.slice(pipeIdx + 1) : null; 56 + 57 + const slug = findSlug(slugMap, title.trim()); 58 + if (slug) { 59 + return display ? `[[${slug}|${display}]]` : `[[${slug}]]`; 60 + } 61 + return _match; // leave as-is if no matching note 62 + }); 63 + 64 + return result; 65 + } 66 + 67 + /** 68 + * Rewrite Lichen markdown to Obsidian-compatible format. 69 + * - [[slug]] -> [[Note Title]] 70 + * - [[slug|display]] -> [[Note Title|display]] 71 + * - ![alt](/blob/{did}/{cid}) -> ![alt](attachments/{cid}.ext), collects blob refs 72 + */ 73 + export function rewriteForExport( 74 + content: string, 75 + slugToTitle: Map<string, string>, 76 + ): ExportResult { 77 + const blobRefs: { did: string; cid: string }[] = []; 78 + const seenCids = new Set<string>(); 79 + 80 + // 1. Rewrite blob image refs to attachments/ 81 + let result = content.replace( 82 + /!\[([^\]]*)\]\(\/blob\/(did:[^/]+)\/([^)\s]+)\)/g, 83 + (_match, alt: string, did: string, cid: string) => { 84 + if (!seenCids.has(cid)) { 85 + seenCids.add(cid); 86 + blobRefs.push({ did, cid }); 87 + } 88 + // Use cid as filename — extension will be added when building the zip 89 + return `![${alt}](attachments/${cid})`; 90 + }, 91 + ); 92 + 93 + // 2. Rewrite wikilinks: [[slug]] and [[slug|display]] 94 + result = result.replace(/\[\[([^\]]+)\]\]/g, (_match, inner: string) => { 95 + const pipeIdx = inner.indexOf("|"); 96 + const slug = pipeIdx >= 0 ? inner.slice(0, pipeIdx) : inner; 97 + const display = pipeIdx >= 0 ? inner.slice(pipeIdx + 1) : null; 98 + 99 + const title = slugToTitle.get(slug.trim()); 100 + if (title) { 101 + return display ? `[[${title}|${display}]]` : `[[${title}]]`; 102 + } 103 + return _match; 104 + }); 105 + 106 + return { content: result, blobRefs }; 107 + } 108 + 109 + /** 110 + * Extract local image references from markdown content. 111 + * Finds ![[filename]] and ![...](relative-path) where path is not a URL or /blob/ ref. 112 + * Returns basenames only (deduplicated). 113 + */ 114 + export function extractLocalImageRefs(content: string): string[] { 115 + const refs = new Set<string>(); 116 + 117 + // Obsidian embeds: ![[filename]] 118 + for (const match of content.matchAll(/!\[\[([^\]]+)\]\]/g)) { 119 + const name = basename((match[1] as string).trim()); 120 + if (isImageFilename(name)) { 121 + refs.add(name.toLowerCase()); 122 + } 123 + } 124 + 125 + // Standard markdown images: ![alt](path) 126 + for (const match of content.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) { 127 + const href = (match[1] as string).trim(); 128 + if (!isAbsoluteUrl(href) && !href.startsWith("/blob/")) { 129 + const name = basename(href); 130 + if (isImageFilename(name)) { 131 + refs.add(name.toLowerCase()); 132 + } 133 + } 134 + } 135 + 136 + return [...refs]; 137 + } 138 + 139 + function isAbsoluteUrl(s: string): boolean { 140 + return ( 141 + s.startsWith("http://") || s.startsWith("https://") || s.startsWith("//") 142 + ); 143 + } 144 + 145 + const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]); 146 + 147 + function isImageFilename(name: string): boolean { 148 + const ext = name.slice(name.lastIndexOf(".")).toLowerCase(); 149 + return IMAGE_EXTENSIONS.has(ext); 150 + } 151 + 152 + /** 153 + * Case-insensitive lookup in imageMap by basename. 154 + */ 155 + function findImageUrl( 156 + imageMap: Map<string, string>, 157 + filename: string, 158 + ): string | undefined { 159 + // Try exact match first 160 + const exact = imageMap.get(filename); 161 + if (exact) return exact; 162 + // Case-insensitive fallback 163 + const lower = filename.toLowerCase(); 164 + for (const [key, value] of imageMap) { 165 + if (key.toLowerCase() === lower) return value; 166 + } 167 + return undefined; 168 + } 169 + 170 + /** 171 + * Look up slug by note title. Tries exact match, then case-insensitive. 172 + */ 173 + function findSlug( 174 + slugMap: Map<string, string>, 175 + title: string, 176 + ): string | undefined { 177 + const exact = slugMap.get(title); 178 + if (exact) return exact; 179 + const lower = title.toLowerCase(); 180 + for (const [key, value] of slugMap) { 181 + if (key.toLowerCase() === lower) return value; 182 + } 183 + return undefined; 184 + }
+18
src/lib/import-export/types.ts
··· 1 + export interface ImportedNote { 2 + filename: string; 3 + title: string; 4 + slug: string; 5 + content: string; 6 + } 7 + 8 + export interface ImportedImage { 9 + filename: string; 10 + data: Uint8Array; 11 + mimeType: string; 12 + } 13 + 14 + export interface ImportResult { 15 + notes: ImportedNote[]; 16 + images: ImportedImage[]; 17 + warnings: string[]; 18 + }
+158
src/lib/import-export/zip-parse.ts
··· 1 + import { basename } from "node:path"; 2 + import { unzipSync } from "fflate"; 3 + import { ImportError } from "../errors.ts"; 4 + import { isValidSlug, slugify } from "../slug.ts"; 5 + import { extractLocalImageRefs } from "./markdown-transform.ts"; 6 + import type { ImportedImage, ImportedNote, ImportResult } from "./types.ts"; 7 + 8 + const MAX_UNCOMPRESSED_BYTES = 50 * 1024 * 1024; // 50MB 9 + const MAX_ZIP_ENTRIES = 500; 10 + const MAX_MD_FILES = 100; 11 + 12 + const IMAGE_EXTENSIONS = new Set([".jpg", ".jpeg", ".png", ".gif", ".webp"]); 13 + const MIME_BY_EXT: Record<string, string> = { 14 + ".jpg": "image/jpeg", 15 + ".jpeg": "image/jpeg", 16 + ".png": "image/png", 17 + ".gif": "image/gif", 18 + ".webp": "image/webp", 19 + }; 20 + 21 + /** 22 + * Parse an import zip file, extracting markdown notes and referenced images. 23 + * Throws ImportError on validation failures. 24 + */ 25 + export function parseImportZip(buffer: ArrayBuffer): ImportResult { 26 + let entries: Record<string, Uint8Array>; 27 + try { 28 + entries = unzipSync(new Uint8Array(buffer)); 29 + } catch { 30 + throw new ImportError("Invalid or corrupt zip file."); 31 + } 32 + 33 + const paths = Object.keys(entries); 34 + if (paths.length === 0) { 35 + throw new ImportError("Zip file is empty."); 36 + } 37 + if (paths.length > MAX_ZIP_ENTRIES) { 38 + throw new ImportError( 39 + `Zip contains too many entries (${paths.length}, max ${MAX_ZIP_ENTRIES}).`, 40 + ); 41 + } 42 + 43 + // Check total uncompressed size 44 + let totalSize = 0; 45 + for (const data of Object.values(entries)) { 46 + totalSize += data.length; 47 + if (totalSize > MAX_UNCOMPRESSED_BYTES) { 48 + throw new ImportError("Zip content exceeds 50MB uncompressed limit."); 49 + } 50 + } 51 + 52 + const warnings: string[] = []; 53 + const mdFiles: { name: string; content: string }[] = []; 54 + const imageFiles: Map<string, Uint8Array> = new Map(); // lowercase basename -> data 55 + 56 + for (const [path, data] of Object.entries(entries)) { 57 + // Skip directories (entries ending with /) 58 + if (path.endsWith("/")) continue; 59 + 60 + const name = basename(path); 61 + 62 + // Skip hidden files/directories 63 + if ( 64 + name.startsWith(".") || 65 + path.split("/").some((p) => p.startsWith(".")) 66 + ) { 67 + continue; 68 + } 69 + 70 + const ext = name.slice(name.lastIndexOf(".")).toLowerCase(); 71 + 72 + if (ext === ".md" || ext === ".markdown") { 73 + mdFiles.push({ name, content: new TextDecoder().decode(data) }); 74 + } else if (IMAGE_EXTENSIONS.has(ext)) { 75 + imageFiles.set(name.toLowerCase(), data); 76 + } 77 + // All other files silently ignored 78 + } 79 + 80 + if (mdFiles.length === 0) { 81 + throw new ImportError("No markdown files found in zip."); 82 + } 83 + if (mdFiles.length > MAX_MD_FILES) { 84 + throw new ImportError( 85 + `Too many markdown files (${mdFiles.length}, max ${MAX_MD_FILES}).`, 86 + ); 87 + } 88 + 89 + // Collect all image references from all markdown files 90 + const referencedImages = new Set<string>(); 91 + for (const { content } of mdFiles) { 92 + for (const ref of extractLocalImageRefs(content)) { 93 + referencedImages.add(ref); // already lowercased by extractLocalImageRefs 94 + } 95 + } 96 + 97 + // Filter images to only referenced ones, preserving original filename casing 98 + const images: ImportedImage[] = []; 99 + for (const [lowerName, data] of imageFiles) { 100 + if (referencedImages.has(lowerName)) { 101 + const ext = lowerName.slice(lowerName.lastIndexOf(".")); 102 + images.push({ 103 + filename: lowerName, 104 + data, 105 + mimeType: MIME_BY_EXT[ext] ?? "application/octet-stream", 106 + }); 107 + } 108 + } 109 + 110 + // Build notes with slug deduplication 111 + const notes: ImportedNote[] = []; 112 + const usedSlugs = new Set<string>(); 113 + 114 + // Sort so that home.md/Home.md comes first 115 + mdFiles.sort((a, b) => { 116 + const aIsHome = isHomeName(a.name); 117 + const bIsHome = isHomeName(b.name); 118 + if (aIsHome && !bIsHome) return -1; 119 + if (!aIsHome && bIsHome) return 1; 120 + return a.name.localeCompare(b.name); 121 + }); 122 + 123 + for (const { name, content } of mdFiles) { 124 + const title = name.replace(/\.(md|markdown)$/i, ""); 125 + let slug: string; 126 + 127 + if (isHomeName(name) && !usedSlugs.has("home")) { 128 + slug = "home"; 129 + } else { 130 + slug = slugify(title); 131 + } 132 + 133 + if (!slug || !isValidSlug(slug)) { 134 + warnings.push(`Skipped "${name}": could not generate a valid slug.`); 135 + continue; 136 + } 137 + 138 + if (usedSlugs.has(slug)) { 139 + const original = slug; 140 + let counter = 2; 141 + while (usedSlugs.has(slug)) { 142 + slug = `${original}-${counter}`; 143 + counter++; 144 + } 145 + warnings.push(`Renamed "${name}" slug from "${original}" to "${slug}".`); 146 + } 147 + 148 + usedSlugs.add(slug); 149 + notes.push({ filename: name, title, slug, content }); 150 + } 151 + 152 + return { notes, images, warnings }; 153 + } 154 + 155 + function isHomeName(filename: string): boolean { 156 + const name = filename.replace(/\.(md|markdown)$/i, "").toLowerCase(); 157 + return name === "home"; 158 + }
+161
tests/lib/import-export/markdown-transform.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { 3 + extractLocalImageRefs, 4 + rewriteForExport, 5 + rewriteForImport, 6 + } from "../../../src/lib/import-export/markdown-transform.ts"; 7 + 8 + describe("rewriteForImport", () => { 9 + const slugMap = new Map([ 10 + ["Getting Started", "getting-started"], 11 + ["My Notes", "my-notes"], 12 + ["Home", "home"], 13 + ]); 14 + const imageMap = new Map([ 15 + ["photo.png", "/blob/did:plc:abc/bafk1"], 16 + ["diagram.jpg", "/blob/did:plc:abc/bafk2"], 17 + ]); 18 + 19 + test("rewrites Obsidian wikilinks to slugs", () => { 20 + const input = "See [[Getting Started]] for info."; 21 + const result = rewriteForImport(input, slugMap, imageMap); 22 + expect(result).toBe("See [[getting-started]] for info."); 23 + }); 24 + 25 + test("rewrites wikilinks with display text", () => { 26 + const input = "Check [[My Notes|my personal notes]] here."; 27 + const result = rewriteForImport(input, slugMap, imageMap); 28 + expect(result).toBe("Check [[my-notes|my personal notes]] here."); 29 + }); 30 + 31 + test("leaves unmatched wikilinks as-is", () => { 32 + const input = "See [[Unknown Page]] for info."; 33 + const result = rewriteForImport(input, slugMap, imageMap); 34 + expect(result).toBe("See [[Unknown Page]] for info."); 35 + }); 36 + 37 + test("rewrites Obsidian image embeds", () => { 38 + const input = "Here is ![[photo.png]] in text."; 39 + const result = rewriteForImport(input, slugMap, imageMap); 40 + expect(result).toBe("Here is ![photo](/blob/did:plc:abc/bafk1) in text."); 41 + }); 42 + 43 + test("rewrites relative image paths", () => { 44 + const input = "![my diagram](assets/diagram.jpg)"; 45 + const result = rewriteForImport(input, slugMap, imageMap); 46 + expect(result).toBe("![my diagram](/blob/did:plc:abc/bafk2)"); 47 + }); 48 + 49 + test("leaves absolute URLs untouched", () => { 50 + const input = "![ext](https://example.com/img.png)"; 51 + const result = rewriteForImport(input, slugMap, imageMap); 52 + expect(result).toBe("![ext](https://example.com/img.png)"); 53 + }); 54 + 55 + test("leaves /blob/ refs untouched", () => { 56 + const input = "![img](/blob/did:plc:xyz/bafkabc)"; 57 + const result = rewriteForImport(input, slugMap, imageMap); 58 + expect(result).toBe("![img](/blob/did:plc:xyz/bafkabc)"); 59 + }); 60 + 61 + test("leaves unmatched image embeds as-is", () => { 62 + const input = "![[missing.png]]"; 63 + const result = rewriteForImport(input, slugMap, imageMap); 64 + expect(result).toBe("![[missing.png]]"); 65 + }); 66 + 67 + test("handles case-insensitive image match", () => { 68 + const input = "![[Photo.PNG]]"; 69 + const result = rewriteForImport(input, slugMap, imageMap); 70 + expect(result).toBe("![Photo](/blob/did:plc:abc/bafk1)"); 71 + }); 72 + 73 + test("handles case-insensitive wikilink match", () => { 74 + const input = "See [[home]] page."; 75 + const result = rewriteForImport(input, slugMap, imageMap); 76 + expect(result).toBe("See [[home]] page."); 77 + }); 78 + }); 79 + 80 + describe("rewriteForExport", () => { 81 + const slugToTitle = new Map([ 82 + ["getting-started", "Getting Started"], 83 + ["my-notes", "My Notes"], 84 + ]); 85 + 86 + test("rewrites slug wikilinks to titles", () => { 87 + const input = "See [[getting-started]] for info."; 88 + const { content } = rewriteForExport(input, slugToTitle); 89 + expect(content).toBe("See [[Getting Started]] for info."); 90 + }); 91 + 92 + test("rewrites wikilinks with display text", () => { 93 + const input = "Check [[my-notes|notes]] here."; 94 + const { content } = rewriteForExport(input, slugToTitle); 95 + expect(content).toBe("Check [[My Notes|notes]] here."); 96 + }); 97 + 98 + test("leaves unmatched wikilinks as-is", () => { 99 + const input = "See [[unknown-slug]] for info."; 100 + const { content } = rewriteForExport(input, slugToTitle); 101 + expect(content).toBe("See [[unknown-slug]] for info."); 102 + }); 103 + 104 + test("rewrites blob refs to attachments and collects refs", () => { 105 + const input = "![photo](/blob/did:plc:abc/bafk123)"; 106 + const { content, blobRefs } = rewriteForExport(input, slugToTitle); 107 + expect(content).toBe("![photo](attachments/bafk123)"); 108 + expect(blobRefs).toEqual([{ did: "did:plc:abc", cid: "bafk123" }]); 109 + }); 110 + 111 + test("deduplicates blob refs", () => { 112 + const input = 113 + "![a](/blob/did:plc:abc/bafk1)\n![b](/blob/did:plc:abc/bafk1)"; 114 + const { blobRefs } = rewriteForExport(input, slugToTitle); 115 + expect(blobRefs).toHaveLength(1); 116 + }); 117 + 118 + test("collects multiple distinct blob refs", () => { 119 + const input = 120 + "![a](/blob/did:plc:abc/bafk1)\n![b](/blob/did:plc:xyz/bafk2)"; 121 + const { blobRefs } = rewriteForExport(input, slugToTitle); 122 + expect(blobRefs).toHaveLength(2); 123 + }); 124 + }); 125 + 126 + describe("extractLocalImageRefs", () => { 127 + test("finds Obsidian embed refs", () => { 128 + const refs = extractLocalImageRefs("text ![[photo.png]] more"); 129 + expect(refs).toEqual(["photo.png"]); 130 + }); 131 + 132 + test("finds standard markdown image refs", () => { 133 + const refs = extractLocalImageRefs("![alt](images/diagram.jpg)"); 134 + expect(refs).toEqual(["diagram.jpg"]); 135 + }); 136 + 137 + test("ignores absolute URLs", () => { 138 + const refs = extractLocalImageRefs("![alt](https://example.com/photo.png)"); 139 + expect(refs).toEqual([]); 140 + }); 141 + 142 + test("ignores /blob/ refs", () => { 143 + const refs = extractLocalImageRefs("![alt](/blob/did:plc:abc/bafk123)"); 144 + expect(refs).toEqual([]); 145 + }); 146 + 147 + test("ignores non-image files", () => { 148 + const refs = extractLocalImageRefs("![[document.pdf]]"); 149 + expect(refs).toEqual([]); 150 + }); 151 + 152 + test("deduplicates refs (case-insensitive)", () => { 153 + const refs = extractLocalImageRefs("![[Photo.PNG]] ![x](photo.png)"); 154 + expect(refs).toHaveLength(1); 155 + }); 156 + 157 + test("extracts basename from nested paths", () => { 158 + const refs = extractLocalImageRefs("![x](deep/nested/path/image.webp)"); 159 + expect(refs).toEqual(["image.webp"]); 160 + }); 161 + });
+194
tests/lib/import-export/zip-parse.test.ts
··· 1 + import { describe, expect, test } from "bun:test"; 2 + import { zipSync } from "fflate"; 3 + import { parseImportZip } from "../../../src/lib/import-export/zip-parse.ts"; 4 + 5 + function makeZip(files: Record<string, string | Uint8Array>): ArrayBuffer { 6 + const entries: Record<string, Uint8Array> = {}; 7 + for (const [name, content] of Object.entries(files)) { 8 + entries[name] = 9 + typeof content === "string" ? new TextEncoder().encode(content) : content; 10 + } 11 + return zipSync(entries).buffer as ArrayBuffer; 12 + } 13 + 14 + describe("parseImportZip", () => { 15 + test("parses valid zip with md files", () => { 16 + const zip = makeZip({ 17 + "Note One.md": "# Hello", 18 + "Note Two.md": "# World", 19 + }); 20 + const result = parseImportZip(zip); 21 + expect(result.notes).toHaveLength(2); 22 + expect(result.notes.map((n) => n.title)).toContain("Note One"); 23 + expect(result.notes.map((n) => n.title)).toContain("Note Two"); 24 + }); 25 + 26 + test("derives title from filename (strips .md)", () => { 27 + const zip = makeZip({ "My Great Note.md": "content" }); 28 + const result = parseImportZip(zip); 29 + expect(result.notes[0]?.title).toBe("My Great Note"); 30 + }); 31 + 32 + test("generates valid slugs from titles", () => { 33 + const zip = makeZip({ "My Great Note.md": "content" }); 34 + const result = parseImportZip(zip); 35 + expect(result.notes[0]?.slug).toBe("my-great-note"); 36 + }); 37 + 38 + test("flattens nested directories", () => { 39 + const zip = makeZip({ 40 + "folder/subfolder/deep.md": "content", 41 + }); 42 + const result = parseImportZip(zip); 43 + expect(result.notes).toHaveLength(1); 44 + expect(result.notes[0]?.title).toBe("deep"); 45 + }); 46 + 47 + test("deduplicates slugs with -2, -3 suffixes", () => { 48 + const zip = makeZip({ 49 + "Note.md": "first", 50 + "subdir/Note.md": "second", 51 + }); 52 + const result = parseImportZip(zip); 53 + expect(result.notes).toHaveLength(2); 54 + const slugs = result.notes.map((n) => n.slug); 55 + expect(slugs).toContain("note"); 56 + expect(slugs).toContain("note-2"); 57 + expect(result.warnings.length).toBeGreaterThan(0); 58 + }); 59 + 60 + test("home.md gets slug 'home'", () => { 61 + const zip = makeZip({ 62 + "Home.md": "Welcome", 63 + "Other.md": "stuff", 64 + }); 65 + const result = parseImportZip(zip); 66 + const homeNote = result.notes.find((n) => n.slug === "home"); 67 + expect(homeNote).toBeDefined(); 68 + expect(homeNote?.title).toBe("Home"); 69 + }); 70 + 71 + test("home.md comes first in notes array", () => { 72 + const zip = makeZip({ 73 + "Zebra.md": "z", 74 + "home.md": "Welcome", 75 + "Alpha.md": "a", 76 + }); 77 + const result = parseImportZip(zip); 78 + expect(result.notes[0]?.slug).toBe("home"); 79 + }); 80 + 81 + test("skips hidden files", () => { 82 + const zip = makeZip({ 83 + ".obsidian/config.json": "{}", 84 + ".DS_Store": "junk", 85 + "Real Note.md": "content", 86 + }); 87 + const result = parseImportZip(zip); 88 + expect(result.notes).toHaveLength(1); 89 + expect(result.notes[0]?.title).toBe("Real Note"); 90 + }); 91 + 92 + test("skips files in hidden directories", () => { 93 + const zip = makeZip({ 94 + ".obsidian/plugins/note.md": "hidden", 95 + "visible.md": "ok", 96 + }); 97 + const result = parseImportZip(zip); 98 + expect(result.notes).toHaveLength(1); 99 + }); 100 + 101 + test("only keeps referenced images", () => { 102 + const zip = makeZip({ 103 + "note.md": "![[photo.png]]", 104 + "photo.png": new Uint8Array([137, 80, 78, 71]), // PNG header 105 + "unused.jpg": new Uint8Array([255, 216, 255]), // JPEG header 106 + }); 107 + const result = parseImportZip(zip); 108 + expect(result.images).toHaveLength(1); 109 + expect(result.images[0]?.filename).toBe("photo.png"); 110 + }); 111 + 112 + test("ignores non-md text files", () => { 113 + const zip = makeZip({ 114 + "note.md": "content", 115 + "readme.txt": "text", 116 + "data.csv": "1,2,3", 117 + "config.json": "{}", 118 + }); 119 + const result = parseImportZip(zip); 120 + expect(result.notes).toHaveLength(1); 121 + }); 122 + 123 + test("handles .markdown extension", () => { 124 + const zip = makeZip({ "note.markdown": "content" }); 125 + const result = parseImportZip(zip); 126 + expect(result.notes).toHaveLength(1); 127 + expect(result.notes[0]?.title).toBe("note"); 128 + }); 129 + 130 + test("throws on empty zip", () => { 131 + const zip = makeZip({}); 132 + expect(() => parseImportZip(zip)).toThrow("empty"); 133 + }); 134 + 135 + test("throws on corrupt data", () => { 136 + const bad = new ArrayBuffer(100); 137 + expect(() => parseImportZip(bad)).toThrow("Invalid"); 138 + }); 139 + 140 + test("throws on no markdown files", () => { 141 + const zip = makeZip({ "image.png": new Uint8Array([1, 2, 3]) }); 142 + expect(() => parseImportZip(zip)).toThrow("No markdown"); 143 + }); 144 + 145 + test("throws on too many markdown files", () => { 146 + const files: Record<string, string> = {}; 147 + for (let i = 0; i < 101; i++) { 148 + files[`note-${i}.md`] = `content ${i}`; 149 + } 150 + const zip = makeZip(files); 151 + expect(() => parseImportZip(zip)).toThrow("Too many"); 152 + }); 153 + 154 + test("throws on too many total entries", () => { 155 + const files: Record<string, string> = {}; 156 + for (let i = 0; i < 501; i++) { 157 + files[`file-${i}.txt`] = "x"; 158 + } 159 + // Need at least 1 md file for the check to reach the entry count check 160 + // Actually entry count is checked before md count 161 + const zip = makeZip(files); 162 + expect(() => parseImportZip(zip)).toThrow("too many entries"); 163 + }); 164 + 165 + test("skips files that produce invalid slugs", () => { 166 + const zip = makeZip({ 167 + "!!!.md": "content", 168 + "valid.md": "ok", 169 + }); 170 + const result = parseImportZip(zip); 171 + expect(result.notes).toHaveLength(1); 172 + expect(result.notes[0]?.slug).toBe("valid"); 173 + expect(result.warnings.length).toBeGreaterThan(0); 174 + }); 175 + 176 + test("skips directory entries", () => { 177 + const zip = makeZip({ 178 + "folder/": "", 179 + "folder/note.md": "content", 180 + }); 181 + // fflate may or may not include directory entries, but our code handles it 182 + const result = parseImportZip(zip); 183 + expect(result.notes.length).toBeGreaterThanOrEqual(1); 184 + }); 185 + 186 + test("image refs are case-insensitive", () => { 187 + const zip = makeZip({ 188 + "note.md": "![[Photo.PNG]]", 189 + "photo.png": new Uint8Array([137, 80, 78, 71]), 190 + }); 191 + const result = parseImportZip(zip); 192 + expect(result.images).toHaveLength(1); 193 + }); 194 + });