From c13765e13a66f658f4e4b503fe846635e90b9bd4 Mon Sep 17 00:00:00 2001 From: juprodh Date: Fri, 21 Aug 2026 12:35:40 +0800 Subject: [PATCH] Report vault files the importer skips Signed-off-by: juprodh --- README.md | 11 ++--- src/lib/import-export/zip-parse.ts | 53 ++++++++++++++++++++++- tests/lib/import-export/zip-parse.test.ts | 45 +++++++++++++++++++ 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 520c19e..b96eaab 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,12 @@ Mermaid block from a vault is preserved as a code block, not executed. ### Bases and Canvas Obsidian's Bases (`.base`) and Canvas (`.canvas`) files are YAML and JSON, not markdown. -Importing a vault keeps every `.md` note and its attachments and skips those files, so a -vault organised around Bases views arrives as its underlying notes without the views. The -note data itself is not lost — a Base is a query over note properties, and the properties -live in each note's frontmatter, which imports intact even though Lichen does not yet read -it as data. +Importing a vault keeps every `.md` note and its attachments and skips those files, but no +longer in silence: the import warnings note counts what was skipped, by extension. A vault +organised around Bases views arrives as its underlying notes without the views, and says +so. The note data itself is not lost — a Base is a query over note properties, and the +properties live in each note's frontmatter, which imports intact even though Lichen does +not yet read it as data. ## How it works diff --git a/src/lib/import-export/zip-parse.ts b/src/lib/import-export/zip-parse.ts index 60163dd..a0c327d 100644 --- a/src/lib/import-export/zip-parse.ts +++ b/src/lib/import-export/zip-parse.ts @@ -31,6 +31,7 @@ export function parseImportZip(buffer: ArrayBuffer): ImportResult { const warnings: string[] = []; const mdFiles: { name: string; content: string }[] = []; const imageFiles: Map = new Map(); // lowercase basename -> data + const skipped: Map = new Map(); // extension -> count for (const [path, data] of Object.entries(entries)) { if (path.endsWith("/")) continue; @@ -45,7 +46,8 @@ export function parseImportZip(buffer: ArrayBuffer): ImportResult { continue; } - const ext = name.slice(name.lastIndexOf(".")).toLowerCase(); + const dot = name.lastIndexOf("."); + const ext = dot > 0 ? name.slice(dot).toLowerCase() : ""; if (ext === ".md" || ext === ".markdown") { mdFiles.push({ @@ -54,10 +56,13 @@ export function parseImportZip(buffer: ArrayBuffer): ImportResult { }); } else if (IMAGE_EXTENSIONS.has(ext)) { imageFiles.set(name.toLowerCase(), data); + } else { + skipped.set(ext, (skipped.get(ext) ?? 0) + 1); } - // All other files silently ignored } + warnings.push(...describeSkipped(skipped)); + if (mdFiles.length === 0) { throw new ImportError("No markdown files found in zip.", { i18nKey: "noMarkdownFiles", @@ -155,6 +160,50 @@ export function parseImportZip(buffer: ArrayBuffer): ImportResult { return { notes, images, warnings }; } +const SKIP_NOTES: Record = { + ".base": + "A Base is a saved query over note properties, not markdown; the notes it queries imported normally.", + ".canvas": + "Canvas files are JSON, not markdown, and carry their own authored content.", +}; + +const MAX_LISTED_EXTENSIONS = 6; + +// A 2026 vault keeps authored content outside markdown, so a silent skip reads as a clean +// import: the count is reported instead, with the two formats that hold content named. +function describeSkipped(skipped: Map): string[] { + const lines: string[] = []; + const otherExtensions: string[] = []; + let otherCount = 0; + + for (const [ext, count] of [...skipped].sort()) { + const note = SKIP_NOTES[ext]; + if (note) { + lines.push(`Skipped ${count} ${ext} ${plural("file", count)}. ${note}`); + } else { + otherCount += count; + otherExtensions.push(ext || "no extension"); + } + } + + if (otherCount > 0) { + const listed = otherExtensions.slice(0, MAX_LISTED_EXTENSIONS).join(", "); + const more = + otherExtensions.length > MAX_LISTED_EXTENSIONS + ? `, and ${otherExtensions.length - MAX_LISTED_EXTENSIONS} more` + : ""; + lines.push( + `Skipped ${otherCount} ${plural("attachment", otherCount)} Lichen cannot store (${listed}${more}). Images import; other attachments do not.`, + ); + } + + return lines; +} + +function plural(word: string, count: number): string { + return count === 1 ? word : `${word}s`; +} + function isHomeName(filename: string): boolean { const name = filename.replace(/\.(md|markdown)$/i, "").toLowerCase(); return name === "home"; diff --git a/tests/lib/import-export/zip-parse.test.ts b/tests/lib/import-export/zip-parse.test.ts index b629df0..5594a6a 100644 --- a/tests/lib/import-export/zip-parse.test.ts +++ b/tests/lib/import-export/zip-parse.test.ts @@ -231,6 +231,51 @@ describe("parseImportZip", () => { }); }); +describe("skipped vault files", () => { + // Each format that carries content gets its own line to explain what was lost, rather + // than being counted in with the attachments. + test("explains each skipped vault format separately", () => { + const zip = makeZip({ + "Note.md": "x", + "Tasks.base": "filters:\n - x", + "Board.canvas": "{}", + "scan.pdf": "x", + }); + const lines = parseImportZip(zip).warnings; + const base = lines.find((w) => w.includes(".base")); + const canvas = lines.find((w) => w.includes(".canvas")); + expect(base).toBeDefined(); + expect(canvas).toBeDefined(); + expect(canvas).not.toBe(base); + expect(base).not.toContain(".pdf"); + }); + + test("counts other attachments into a single line", () => { + const zip = makeZip({ + "Note.md": "x", + "a.pdf": "x", + "b.pdf": "x", + "c.ogg": "x", + }); + const lines = parseImportZip(zip).warnings.filter((w) => + w.includes("attachment"), + ); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain("3"); + expect(lines[0]).toContain(".pdf"); + expect(lines[0]).toContain(".ogg"); + }); + + test("says nothing about vault config or unreferenced images", () => { + const zip = makeZip({ + "Note.md": "x", + ".obsidian/app.json": "{}", + "unused.png": new Uint8Array([1, 2, 3]), + }); + expect(parseImportZip(zip).warnings).toHaveLength(0); + }); +}); + describe("unzipWithLimits (zip-bomb guard)", () => { test("returns entries for a valid zip within caps", () => { const zip = new Uint8Array( -- 2.51.2