// The import wizard (docs/import.md): the pure half (shared/importPlan.ts), // each converter against a hand-written export of its kind under // tests/fixtures/import, and the whole preview → commit → undo loop over a // throwaway vault — collisions resolved out loud, attachments moved into the // vault's attachments folder with the links that named them rewritten, and no // imported note public. import assert from "node:assert/strict"; import { after, before, describe, it } from "node:test"; import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { cleanName, frontmatterBlock, linksToWikilinks, normaliseFields, notionDate, notionProperties, renameEmbeds, resolveTargets, stripNotionId } from "../shared/importPlan.ts"; import { zipSync } from "../shared/zip.ts"; import { dropCommonRoot, unzipExport, type ExportFile } from "../server/import/common.ts"; import { convertEvernote } from "../server/import/evernote.ts"; import { convertNotion } from "../server/import/notion.ts"; import { convertObsidian } from "../server/import/obsidian.ts"; import { commitImport, planImport, undoImport } from "../server/import/plan.ts"; import { initIndexer, isNotePublished, noteTitle } from "../server/indexer.ts"; import { initSite } from "../server/site.ts"; import { initVault } from "../server/vault.ts"; import { makeDir, makeVault, removeVault } from "./helpers/vault.ts"; const FIX = path.join(path.dirname(fileURLToPath(import.meta.url)), "fixtures", "import"); /** Every file under a fixture folder, as the export's own paths. */ function readTree(root: string, prefix = ""): ExportFile[] { const out: ExportFile[] = []; for (const name of readdirSync(path.join(root, prefix))) { const rel = prefix === "" ? name : `${prefix}/${name}`; if (statSync(path.join(root, rel)).isDirectory()) out.push(...readTree(root, rel)); else out.push({ path: rel, bytes: new Uint8Array(readFileSync(path.join(root, rel))) }); } return out; } /** The fixture folder as the zip its app would have written. */ function zipOf(dir: string): Uint8Array { return zipSync(readTree(dir).map((f) => ({ name: f.path, data: f.bytes }))); } const notionFiles = (): ExportFile[] => unzipExport(zipOf(path.join(FIX, "notion"))); const enexFiles = (): ExportFile[] => [{ path: "Reading notes.enex", bytes: new Uint8Array(readFileSync(path.join(FIX, "evernote", "Reading notes.enex"))) }]; const obsidianFiles = (): ExportFile[] => dropCommonRoot(readTree(path.join(FIX, "obsidian"))); describe("the pure half (shared/importPlan.ts)", () => { it("strips Notion's ids and cleans names", () => { assert.equal(stripNotionId("Reading list 0f8e2b7c4d1a4e6b9c3d2e1f0a9b8c7d.md"), "Reading list.md"); assert.equal(stripNotionId("Books 3a4b5c6d7e8f40a1b2c3d4e5f6a7b8c9"), "Books"); assert.equal(stripNotionId("Plain name"), "Plain name"); assert.equal(cleanName(" a/b: [c] #d? "), "a b c d"); assert.equal(cleanName("..."), "Untitled"); }); it("resolves collisions against the vault and within the export, never overwriting", () => { const { targets, collisions } = resolveTargets( [ { source: "a", path: "In/Notes.md" }, { source: "b", path: "In/notes.md" }, { source: "c", path: "In/Welcome.md" }, ], (p) => p === "In/Welcome.md", ); assert.deepEqual([...targets.values()], ["In/Notes.md", "In/notes 2.md", "In/Welcome 2.md"]); assert.deepEqual(collisions.map((c) => [c.source, c.reason]), [["b", "duplicate"], ["c", "exists"]]); }); it("reads Notion's property lines, and leaves a sentence with a colon in the body", () => { const p = notionProperties("# Book\n\nAuthor: Someone\nTags: a, b c\nStarted: September 10, 2026\n\nBody: prose.\n"); assert.equal(p.title, "Book"); assert.deepEqual(p.fields, [["Author", "Someone"], ["Tags", ["a", "b-c"]], ["Started", "2026-09-10"]]); assert.equal(p.body, "Body: prose.\n"); const q = notionProperties("# Page\n\nNote: this is a sentence\nand it goes on.\n"); assert.deepEqual(q.fields, []); assert.match(q.body, /^Note: this is a sentence/); assert.equal(notionDate("September 9, 2026 7:15 PM"), "2026-09-09"); }); it("normalises keys, drops publish, and writes frontmatter YAML reads back", () => { const f = normaliseFields([["Tags", "x, y"], ["Created", "2026-01-02"], ["publish", true], ["Status", "Reading: now"], ["Alias", "Z"]]); assert.deepEqual(f, [["tags", ["x", "y"]], ["created", "2026-01-02"], ["Status", "Reading: now"], ["aliases", "Z"]]); assert.equal(frontmatterBlock(f), '---\ntags: [x, y]\ncreated: 2026-01-02\nStatus: "Reading: now"\naliases: Z\n---\n'); }); it("rewrites links between imported notes into wikilinks, and renames embeds", () => { const { text, count } = linksToWikilinks("See [the list](../Reading%20list%20abc.md#Top) and [web](https://x.org) and ![i](a.png).", "Sub", (p) => (p === "Reading list abc.md" ? "In/Reading list.md" : null)); assert.equal(text, "See [[Reading list#Top|the list]] and [web](https://x.org) and ![i](a.png)."); assert.equal(count, 1); const r = renameEmbeds("![[a.png]] ![[Attachments/b.png|200]] [[Note]]", new Map([["a.png", "a 2.png"], ["Attachments/b.png", "b.png"]])); assert.equal(r.text, "![[a 2.png]] ![[b.png|200]] [[Note]]"); assert.equal(r.count, 2); }); }); describe("the converters, each against a real-shaped export", () => { it("Notion: ids gone, properties to frontmatter, the database a table, rows given the CSV's cells", () => { const c = convertNotion(notionFiles()); const by = new Map(c.notes.map((n) => [n.want, n])); assert.deepEqual(c.notes.map((n) => n.want).sort(), [ "Books.md", "Books/Invisible Cities.md", "Books/The Muqaddimah.md", "Colophon.md", "Notes.md", "Notes.md", "Reading list.md", "Reading list/Why read slowly.md", ].sort()); const muq = by.get("Books/The Muqaddimah.md")!; assert.deepEqual(muq.fields, [["Author", "Ibn Khaldun"], ["Status", "Reading"], ["tags", ["history", "sociology"]], ["Started", "2026-09-10"], ["created", "2026-09-09"]]); assert.match(muq.body, /^Asabiyya: the group feeling/); // A row page with no property lines borrows them from the database. assert.deepEqual(by.get("Books/Invisible Cities.md")!.fields, [["Author", "Italo Calvino"], ["Status", "Done"], ["tags", ["fiction"]], ["Started", "2026-08-02"]]); assert.match(by.get("Books.md")!.body, /^\| Name \| Author \| Status \| Tags \| Started \|\n\| --- /); // The HTML page: its property table, its body only. const col = by.get("Colophon.md")!; assert.deepEqual(col.fields, [["tags", ["meta", "site"]], ["created", "2026-09-01"]]); assert.match(col.body, /^Set in \*\*Lora\*\*\. Built from \[the reading list\]\(\/Reading%20list/); assert.deepEqual(c.attachments.map((a) => a.name), ["cover.svg"]); }); it("Evernote: title, tags, created and source to frontmatter; resources to attachments where they stood; to-dos to tasks", () => { const c = convertEvernote(enexFiles()); assert.deepEqual(c.notes.map((n) => n.want), ["Muqaddimah — chapter one.md", "Welcome.md", "ملاحظة عربية.md"]); const [first, , ar] = c.notes; assert.deepEqual(first.fields, [["tags", ["history", "ibn-khaldun"]], ["created", "2026-09-10"], ["updated", "2026-09-12"], ["source", "https://example.org/muqaddimah/1"]]); assert.match(first.body, /Asabiyya is the \*\*group feeling\*\* that founds a dynasty\./); assert.match(first.body, /- \[x\] Read the prolegomena/); assert.match(first.body, /- \[ \] Compare with Machiavelli/); assert.match(first.body, /!\[\[map\.png\]\]/); assert.match(first.body, /The scan: !\[\[e4a37a595b9d\.pdf\]\]/); assert.deepEqual(c.attachments.map((a) => [a.name, a.bytes.length > 0]), [["map.png", true], ["e4a37a595b9d.pdf", true]]); assert.deepEqual(ar.fields, [["tags", ["عربي"]], ["created", "2026-09-15"]]); }); it("Obsidian: notes as they are, publish taken out, .obsidian and the unknown left behind", () => { const c = convertObsidian(obsidianFiles()); assert.deepEqual(c.notes.map((n) => n.want).sort(), ["Index.md", "Projects/Plan.md", "Seedlings.md"]); const index = c.notes.find((n) => n.want === "Index.md")!; assert.match(index.body, /^---\naliases: \[Home, Start here\]\ntags: \[garden, index\]\n---\n/); assert.doesNotMatch(index.body, /publish/); assert.equal(c.stats.publishCleared, 1); assert.equal(c.stats.aliases, 1); assert.deepEqual(c.attachments.map((a) => a.source), ["Attachments/diagram.svg"]); assert.deepEqual(c.skipped.map((s) => [s.path, s.reason]).sort(), [[".obsidian/app.json", "system"], ["Attachments/Welcome.png.txt", "unsupported"]]); }); }); describe("preview → commit → undo, over a vault", () => { const data = makeDir(); let root = ""; before(async () => { root = makeVault({ "Welcome.md": "# Welcome\n", "Attachments/diagram.svg": "\n", "Garden/Seedlings.md": "# Already here\n", }); initSite({ ASTROLABE_DATA: data }); initVault(root); await initIndexer(); }); after(() => { removeVault(root); removeVault(data); }); it("Notion: the preview counts and writes nothing; the commit writes the plan; links become wikilinks", async () => { const preview = planImport("notion", notionFiles(), "Imported"); assert.equal(preview.notes, 8); assert.equal(preview.attachments, 1); assert.equal(preview.attachmentsFolder, "Attachments"); assert.deepEqual(preview.collisions.map((c) => [c.wanted, c.target, c.reason]), [["Imported/Notes.md", "Imported/Notes 2.md", "duplicate"]]); assert.ok(preview.links >= 6, `links rewritten: ${preview.links}`); assert.ok(preview.frontmatter.properties >= 9); assert.ok(!existsSync(path.join(root, "Imported")), "the preview wrote nothing"); const lines: string[] = []; const done = await commitImport(preview.planId, (p) => lines.push(p.type)); assert.equal(done.notes.length, 8); assert.equal(lines.filter((l) => l === "progress").length, 9); assert.equal(lines.at(-1), "done"); const read = (p: string): string => readFileSync(path.join(root, p), "utf8"); const list = read("Imported/Reading list.md"); assert.match(list, /^# Reading list\n\nWhat I am reading, and why\.\n/); assert.match(list, /- \[\[Why read slowly\]\]/); assert.match(list, /The shelf: \[\[Books\]\]/); assert.match(list, /\[a web page\]\(https:\/\/example\.org\/reading\)/); // The image re-resolved from the note's folder to the attachments folder. assert.match(list, /!\[cover\.svg\]\(\.\.\/Attachments\/cover\.svg\)/); assert.match(read("Imported/Reading list/Why read slowly.md"), /See \[\[Reading list\|the list\]\] and \[\[The Muqaddimah\]\]\./); assert.match(read("Imported/Books/The Muqaddimah.md"), /^---\nAuthor: Ibn Khaldun\nStatus: Reading\ntags: \[history, sociology\]\nStarted: 2026-09-10\ncreated: 2026-09-09\n---\n\n# The Muqaddimah\n/); assert.match(read("Imported/Books.md"), /\| \[\[The Muqaddimah\]\] \| Ibn Khaldun \|/); assert.match(read("Imported/Colophon.md"), /Built from \[\[Reading list\|the reading list\]\]/); assert.ok(existsSync(path.join(root, "Attachments/cover.svg"))); assert.equal(noteTitle("Imported/Notes 2.md"), "Notes 2"); for (const p of done.notes) assert.equal(isNotePublished(p), false, `${p} must not be public`); const undone = await undoImport(done.undoId); assert.equal(undone.removed.length, 9); assert.deepEqual(undone.kept, []); assert.ok(!existsSync(path.join(root, "Imported")), "the folders the import made are gone"); assert.ok(!existsSync(path.join(root, "Attachments/cover.svg"))); assert.ok(existsSync(path.join(root, "Attachments/diagram.svg")), "the vault's own file is untouched"); await assert.rejects(undoImport(done.undoId), /can no longer be taken back/); }); it("Evernote at the vault root: a note the vault has becomes Welcome 2, and nothing is public", async () => { const preview = planImport("evernote", enexFiles(), ""); assert.deepEqual(preview.collisions.map((c) => [c.wanted, c.target, c.reason]), [["Welcome.md", "Welcome 2.md", "exists"]]); const done = await commitImport(preview.planId); assert.equal(readFileSync(path.join(root, "Welcome.md"), "utf8"), "# Welcome\n", "never overwritten"); const first = readFileSync(path.join(root, "Muqaddimah — chapter one.md"), "utf8"); assert.match(first, /^---\ntags: \[history, ibn-khaldun\]\ncreated: 2026-09-10\nupdated: 2026-09-12\nsource: "https:\/\/example\.org\/muqaddimah\/1"\n---\n\n# Muqaddimah — chapter one\n/); assert.ok(existsSync(path.join(root, "Attachments/map.png"))); for (const p of done.notes) assert.equal(isNotePublished(p), false); // An edit after the import survives the undo, and is reported. writeFileSync(path.join(root, "Welcome 2.md"), "# Mine now\n"); const undone = await undoImport(done.undoId); assert.deepEqual(undone.kept, ["Welcome 2.md"]); assert.equal(readFileSync(path.join(root, "Welcome 2.md"), "utf8"), "# Mine now\n"); assert.ok(!existsSync(path.join(root, "Attachments/map.png"))); }); it("Obsidian: attachments into the attachments folder, renamed on a clash, embeds and paths rewritten; publish never lands", async () => { const preview = planImport("obsidian", obsidianFiles(), "Garden"); assert.deepEqual( preview.collisions.map((c) => [c.wanted, c.target, c.reason]), [ ["Garden/Seedlings.md", "Garden/Seedlings 2.md", "exists"], ["Attachments/diagram.svg", "Attachments/diagram 2.svg", "exists"], ], ); assert.equal(preview.frontmatter.publishCleared, 1); const done = await commitImport(preview.planId); const index = readFileSync(path.join(root, "Garden/Index.md"), "utf8"); assert.match(index, /^---\naliases: \[Home, Start here\]\ntags: \[garden, index\]\n---\n/); assert.match(index, /Projects: \[\[Garden\/Projects\/Plan\]\] and \[\[Seedlings 2\]\]\./); assert.match(index, /\n!\[\[diagram 2\.svg\]\]\n/); assert.match(index, /A path-form embed: !\[\[diagram 2\.svg\|200\]\]/); assert.match(index, /A Markdown image: !\[the diagram\]\(\.\.\/Attachments\/diagram%202\.svg\)/); assert.match(readFileSync(path.join(root, "Garden/Projects/Plan.md"), "utf8"), /See \[\[Index#The garden\]\] and \[\[Seedlings 2\|the seedlings\]\]\./); assert.match(readFileSync(path.join(root, "Garden/Seedlings 2.md"), "utf8"), /Back to \[\[Index\|the index\]\]\./); assert.equal(readFileSync(path.join(root, "Garden/Seedlings.md"), "utf8"), "# Already here\n"); for (const p of done.notes) assert.equal(isNotePublished(p), false, `${p} must not be public`); const undone = await undoImport(done.undoId); assert.equal(undone.kept.length, 0); assert.ok(existsSync(path.join(root, "Garden/Seedlings.md")), "the folder that was there stays, with its note"); assert.ok(!existsSync(path.join(root, "Garden/Projects"))); }); it("an expired or unknown plan is refused, and an export with no notes is refused at preview", async () => { await assert.rejects(commitImport("nope"), /expired/); assert.throws(() => planImport("obsidian", [{ path: "a.bin", bytes: new Uint8Array([1]) }], "X"), /no notes/); }); });