🌿 Collaborative wiki on ATProto lichen.wiki
atproto
14
fork

Configure Feed

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

Add import export functions

juprodh 696140e5 0028bce7

+718
+141
src/lib/import-export/export.ts
··· 1 + import { IdResolver } from "@atproto/identity"; 2 + import { zipSync } from "fflate"; 3 + import { getBlobsByCids } from "../../server/db/queries/blob.ts"; 4 + import { getCurrentNote, listNotes } from "../../server/db/queries/index.ts"; 5 + import { rewriteForExport } from "./markdown-transform.ts"; 6 + 7 + const idResolver = new IdResolver(); 8 + 9 + const MIME_TO_EXT: Record<string, string> = { 10 + "image/jpeg": "jpg", 11 + "image/png": "png", 12 + "image/gif": "gif", 13 + "image/webp": "webp", 14 + }; 15 + 16 + const INVALID_FILENAME_CHARS = /[/\\:*?"<>|]/g; 17 + 18 + /** 19 + * Export a wiki as a zip file containing .md files and an attachments/ folder. 20 + * Wikilinks are converted to Obsidian-compatible [[Note Title]] format. 21 + * Blob images are fetched and saved as attachments/{cid}.{ext}. 22 + */ 23 + export async function exportWikiZip(wikiSlug: string): Promise<Uint8Array> { 24 + const notes = listNotes(wikiSlug); 25 + const slugToTitle = new Map<string, string>(); 26 + const noteContents = new Map<string, string>(); 27 + 28 + for (const note of notes) { 29 + slugToTitle.set(note.slug, note.title); 30 + const current = getCurrentNote(wikiSlug, note.slug); 31 + if (current) { 32 + noteContents.set(note.slug, current.content); 33 + } 34 + } 35 + 36 + // Rewrite content and collect blob refs 37 + const allBlobRefs: { did: string; cid: string }[] = []; 38 + const rewrittenNotes = new Map<string, string>(); 39 + 40 + for (const [slug, content] of noteContents) { 41 + const { content: rewritten, blobRefs } = rewriteForExport( 42 + content, 43 + slugToTitle, 44 + ); 45 + rewrittenNotes.set(slug, rewritten); 46 + allBlobRefs.push(...blobRefs); 47 + } 48 + 49 + // Deduplicate blob refs 50 + const uniqueBlobs = new Map<string, { did: string; cid: string }>(); 51 + for (const ref of allBlobRefs) { 52 + if (!uniqueBlobs.has(ref.cid)) { 53 + uniqueBlobs.set(ref.cid, ref); 54 + } 55 + } 56 + 57 + // Look up blob mime types from DB 58 + const blobCids = [...uniqueBlobs.keys()]; 59 + const blobRows = getBlobsByCids(blobCids); 60 + const blobMimeMap = new Map<string, string>(); 61 + for (const row of blobRows) { 62 + blobMimeMap.set(row.cid, row.mime_type); 63 + } 64 + 65 + // Fetch blob data from PDS 66 + const blobData = new Map<string, Uint8Array>(); 67 + const warnings: string[] = []; 68 + 69 + for (const [cid, ref] of uniqueBlobs) { 70 + try { 71 + const data = await fetchBlobFromPds(ref.did, cid); 72 + if (data) { 73 + blobData.set(cid, data); 74 + } 75 + } catch { 76 + warnings.push(`Could not fetch blob ${cid}`); 77 + } 78 + } 79 + 80 + // Build zip entries 81 + const zipEntries: Record<string, Uint8Array> = {}; 82 + const usedFilenames = new Set<string>(); 83 + 84 + for (const note of notes) { 85 + const content = rewrittenNotes.get(note.slug) ?? ""; 86 + let filename = sanitizeFilename(note.title); 87 + filename = deduplicateFilename(filename, usedFilenames); 88 + usedFilenames.add(filename.toLowerCase()); 89 + zipEntries[`${filename}.md`] = new TextEncoder().encode(content); 90 + } 91 + 92 + // Add blob attachments 93 + for (const [cid, data] of blobData) { 94 + const mime = blobMimeMap.get(cid) ?? "application/octet-stream"; 95 + const ext = MIME_TO_EXT[mime] ?? "bin"; 96 + zipEntries[`attachments/${cid}.${ext}`] = data; 97 + } 98 + 99 + // Add warnings file if any 100 + if (warnings.length > 0) { 101 + zipEntries["_export_warnings.txt"] = new TextEncoder().encode( 102 + warnings.join("\n"), 103 + ); 104 + } 105 + 106 + return zipSync(zipEntries); 107 + } 108 + 109 + async function fetchBlobFromPds( 110 + did: string, 111 + cid: string, 112 + ): Promise<Uint8Array | null> { 113 + const didDoc = await idResolver.did.resolve(did); 114 + if (!didDoc) return null; 115 + 116 + const service = didDoc.service?.find( 117 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 118 + ); 119 + if (!service || typeof service.serviceEndpoint !== "string") return null; 120 + 121 + const url = `${service.serviceEndpoint}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${encodeURIComponent(cid)}`; 122 + const response = await fetch(url); 123 + if (!response.ok) return null; 124 + 125 + return new Uint8Array(await response.arrayBuffer()); 126 + } 127 + 128 + function sanitizeFilename(title: string): string { 129 + return ( 130 + title.replace(INVALID_FILENAME_CHARS, "").trim().slice(0, 200) || "untitled" 131 + ); 132 + } 133 + 134 + function deduplicateFilename(name: string, used: Set<string>): string { 135 + if (!used.has(name.toLowerCase())) return name; 136 + let counter = 2; 137 + while (used.has(`${name}-${counter}`.toLowerCase())) { 138 + counter++; 139 + } 140 + return `${name}-${counter}`; 141 + }
+128
src/lib/import-export/import.ts
··· 1 + import { writeNoteRecord, writeRevisionRecord } from "../../atproto/pds.ts"; 2 + import type { getAgent } from "../../atproto/session.ts"; 3 + import { createNote } from "../../server/db/queries/index.ts"; 4 + import type { RequestContext } from "../access.ts"; 5 + import { buildBlobsForContent, persistBlobs } from "../blob.ts"; 6 + import { createDiff } from "../diff.ts"; 7 + import type { Messages } from "../i18n/index.ts"; 8 + import { processImage } from "../image.ts"; 9 + import { withPdsError } from "../orchestrators/helpers.ts"; 10 + import { createWikiCore, type WikiFormFields } from "../orchestrators/wiki.ts"; 11 + import { generateTid } from "../tid.ts"; 12 + import { rewriteForImport } from "./markdown-transform.ts"; 13 + import type { ImportedImage } from "./types.ts"; 14 + import { parseImportZip } from "./zip-parse.ts"; 15 + 16 + interface ImportFields extends WikiFormFields { 17 + zipBuffer: ArrayBuffer; 18 + } 19 + 20 + /** 21 + * Full lifecycle for creating a wiki from an imported zip: 22 + * parse zip → create wiki → upload images → create notes. 23 + * Throws ImportError, ValidationError, PdsWriteError on failure. 24 + */ 25 + export async function importWikiAction( 26 + ctx: RequestContext, 27 + fields: ImportFields, 28 + msg: Messages, 29 + ): Promise<{ wikiSlug: string; noteCount: number; warnings: string[] }> { 30 + const { notes, images, warnings } = parseImportZip(fields.zipBuffer); 31 + 32 + const { wikiSlug, wikiAtUri, agent, did, now } = await createWikiCore( 33 + ctx, 34 + fields, 35 + msg, 36 + ); 37 + 38 + // Build slug map for wikilink rewriting: title -> slug 39 + const slugMap = new Map<string, string>(); 40 + for (const note of notes) { 41 + slugMap.set(note.title, note.slug); 42 + } 43 + 44 + // Upload images and build mapping: filename -> blob URL 45 + const imageMap = new Map<string, string>(); 46 + const blobMeta: Record<string, string> = {}; 47 + 48 + if (agent && images.length > 0) { 49 + await uploadImages(agent, did, images, imageMap, blobMeta); 50 + } 51 + 52 + // Create notes sequentially (PDS-first pattern) 53 + for (const note of notes) { 54 + const content = rewriteForImport(note.content, slugMap, imageMap); 55 + const blobs = buildBlobsForContent(content, blobMeta); 56 + 57 + const noteTid = generateTid(); 58 + const revisionTid = generateTid(); 59 + const noteAtUri = `at://${did}/wiki.lichen.note/${noteTid}`; 60 + const revisionAtUri = `at://${did}/wiki.lichen.noteRevision/${revisionTid}`; 61 + 62 + if (agent) { 63 + const diff = createDiff("", content); 64 + await withPdsError(`import note "${note.title}"`, async () => { 65 + await writeNoteRecord( 66 + agent, 67 + did, 68 + noteTid, 69 + note.slug, 70 + note.title, 71 + wikiAtUri, 72 + now, 73 + ); 74 + await writeRevisionRecord( 75 + agent, 76 + did, 77 + revisionTid, 78 + noteAtUri, 79 + null, 80 + diff, 81 + "Imported", 82 + now, 83 + blobs.length > 0 ? blobs : undefined, 84 + ); 85 + }); 86 + } 87 + 88 + createNote( 89 + noteAtUri, 90 + revisionAtUri, 91 + wikiSlug, 92 + note.slug, 93 + note.title, 94 + did, 95 + content, 96 + "Imported", 97 + ); 98 + persistBlobs(blobs, revisionAtUri); 99 + } 100 + 101 + return { wikiSlug, noteCount: notes.length, warnings }; 102 + } 103 + 104 + async function uploadImages( 105 + agent: Awaited<ReturnType<typeof getAgent>>, 106 + did: string, 107 + images: ImportedImage[], 108 + imageMap: Map<string, string>, 109 + blobMeta: Record<string, string>, 110 + ): Promise<void> { 111 + for (const image of images) { 112 + try { 113 + const processed = await processImage( 114 + Buffer.from(image.data), 115 + image.mimeType, 116 + ); 117 + const response = await agent.com.atproto.repo.uploadBlob(processed.data, { 118 + encoding: processed.mimeType, 119 + }); 120 + const cid = response.data.blob.ref.toString(); 121 + const blobUrl = `/blob/${did}/${cid}`; 122 + imageMap.set(image.filename, blobUrl); 123 + blobMeta[cid] = processed.mimeType; 124 + } catch { 125 + // Skip images that fail validation/upload — they'll remain as broken refs 126 + } 127 + } 128 + }
+198
tests/lib/import-export/export.test.ts
··· 1 + import { afterAll, describe, expect, mock, test } from "bun:test"; 2 + import { unzipSync } from "fflate"; 3 + import { getDb } from "../../../src/server/db/index.ts"; 4 + 5 + // Mock PDS + session for wiki creation 6 + const realPds = await import("../../../src/atproto/pds.ts"); 7 + const realSession = await import("../../../src/atproto/session.ts"); 8 + 9 + mock.module("../../../src/atproto/pds.ts", () => ({ 10 + ...realPds, 11 + writeWikiRecord: mock(async () => ({ 12 + uri: "at://did:plc:exporttest/wiki.lichen.wiki/test", 13 + cid: "bafyrei123", 14 + })), 15 + writeMembershipRecord: mock(async () => ({ 16 + uri: "at://did:plc:exporttest/wiki.lichen.membership/abc", 17 + cid: "bafyrei456", 18 + })), 19 + writeNoteRecord: mock(async () => ({ 20 + uri: "at://did:plc:exporttest/wiki.lichen.note/note1", 21 + cid: "bafyrei789", 22 + })), 23 + writeRevisionRecord: mock(async () => ({ 24 + uri: "at://did:plc:exporttest/wiki.lichen.noteRevision/rev1", 25 + cid: "bafyreirev", 26 + })), 27 + })); 28 + mock.module("../../../src/atproto/session.ts", () => ({ 29 + ...realSession, 30 + getAgent: mock(async () => ({}) as never), 31 + })); 32 + 33 + const { createWikiAction } = await import( 34 + "../../../src/lib/orchestrators/wiki.ts" 35 + ); 36 + const { exportWikiZip } = await import( 37 + "../../../src/lib/import-export/export.ts" 38 + ); 39 + const { createNote } = await import("../../../src/server/db/queries/index.ts"); 40 + 41 + const TEST_DID = "did:plc:exporttest"; 42 + const createdSlugs: string[] = []; 43 + 44 + afterAll(() => { 45 + const db = getDb(); 46 + for (const slug of createdSlugs) { 47 + db.run("DELETE FROM current_note WHERE note_at_uri LIKE ?", [ 48 + `at://${TEST_DID}/wiki.lichen.note/%`, 49 + ]); 50 + db.run("DELETE FROM revisions WHERE note_at_uri LIKE ?", [ 51 + `at://${TEST_DID}/wiki.lichen.note/%`, 52 + ]); 53 + db.run("DELETE FROM notes WHERE wiki_slug = ?", [slug]); 54 + db.run("DELETE FROM memberships WHERE wiki_slug = ?", [slug]); 55 + db.run("DELETE FROM wikis WHERE slug = ?", [slug]); 56 + } 57 + mock.module("../../../src/atproto/pds.ts", () => realPds); 58 + mock.module("../../../src/atproto/session.ts", () => realSession); 59 + }); 60 + 61 + function makeCtx() { 62 + return { 63 + session: null, 64 + wiki: null, 65 + effectiveDid: TEST_DID, 66 + access: "none" as const, 67 + locale: "en" as const, 68 + hasPendingRequest: false, 69 + }; 70 + } 71 + 72 + const dummyMsg = { 73 + error: { 74 + wikiNameRequired: "Name required", 75 + wikiLanguageRequired: "Language required", 76 + invalidSlug: "Invalid slug: {title}", 77 + wikiSlugExists: "Slug exists: {slug}", 78 + }, 79 + } as never; 80 + 81 + describe("exportWikiZip", () => { 82 + test("exports wiki with notes as .md files", async () => { 83 + const result = await createWikiAction( 84 + makeCtx(), 85 + { 86 + name: "Export Test Wiki", 87 + language: "en", 88 + visibility: "public", 89 + description: "", 90 + }, 91 + dummyMsg, 92 + ); 93 + createdSlugs.push(result.wikiSlug); 94 + 95 + // Add another note 96 + createNote( 97 + `at://${TEST_DID}/wiki.lichen.note/extra1`, 98 + `at://${TEST_DID}/wiki.lichen.noteRevision/extrarev1`, 99 + result.wikiSlug, 100 + "second-note", 101 + "Second Note", 102 + TEST_DID, 103 + "Content of second note with [[home]] link.", 104 + ); 105 + 106 + const zipBytes = await exportWikiZip(result.wikiSlug); 107 + const entries = unzipSync(zipBytes); 108 + const filenames = Object.keys(entries); 109 + 110 + // Should have Home.md and Second Note.md 111 + expect(filenames).toContain("Home.md"); 112 + expect(filenames).toContain("Second Note.md"); 113 + }); 114 + 115 + test("rewrites wikilinks to note titles", async () => { 116 + const result = await createWikiAction( 117 + makeCtx(), 118 + { 119 + name: "Export Link Test", 120 + language: "en", 121 + visibility: "public", 122 + description: "", 123 + }, 124 + dummyMsg, 125 + ); 126 + createdSlugs.push(result.wikiSlug); 127 + 128 + createNote( 129 + `at://${TEST_DID}/wiki.lichen.note/linktest1`, 130 + `at://${TEST_DID}/wiki.lichen.noteRevision/linkrev1`, 131 + result.wikiSlug, 132 + "page-two", 133 + "Page Two", 134 + TEST_DID, 135 + "Link back to [[home]].", 136 + ); 137 + 138 + const zipBytes = await exportWikiZip(result.wikiSlug); 139 + const entries = unzipSync(zipBytes); 140 + 141 + const pageTwoContent = new TextDecoder().decode(entries["Page Two.md"]); 142 + expect(pageTwoContent).toContain("[[Home]]"); 143 + expect(pageTwoContent).not.toContain("[[home]]"); 144 + }); 145 + 146 + test("handles empty wiki (only home note)", async () => { 147 + const result = await createWikiAction( 148 + makeCtx(), 149 + { 150 + name: "Export Empty Test", 151 + language: "en", 152 + visibility: "public", 153 + description: "", 154 + }, 155 + dummyMsg, 156 + ); 157 + createdSlugs.push(result.wikiSlug); 158 + 159 + const zipBytes = await exportWikiZip(result.wikiSlug); 160 + const entries = unzipSync(zipBytes); 161 + const filenames = Object.keys(entries); 162 + 163 + expect(filenames).toContain("Home.md"); 164 + expect(filenames).toHaveLength(1); 165 + }); 166 + 167 + test("deduplicates filenames", async () => { 168 + const result = await createWikiAction( 169 + makeCtx(), 170 + { 171 + name: "Export Dedup Test", 172 + language: "en", 173 + visibility: "public", 174 + description: "", 175 + }, 176 + dummyMsg, 177 + ); 178 + createdSlugs.push(result.wikiSlug); 179 + 180 + // Create two notes with the same title (different slugs) 181 + createNote( 182 + `at://${TEST_DID}/wiki.lichen.note/dedup1`, 183 + `at://${TEST_DID}/wiki.lichen.noteRevision/deduprev1`, 184 + result.wikiSlug, 185 + "home-2", 186 + "Home", 187 + TEST_DID, 188 + "Duplicate title note.", 189 + ); 190 + 191 + const zipBytes = await exportWikiZip(result.wikiSlug); 192 + const entries = unzipSync(zipBytes); 193 + const filenames = Object.keys(entries); 194 + 195 + expect(filenames).toContain("Home.md"); 196 + expect(filenames).toContain("Home-2.md"); 197 + }); 198 + });
+251
tests/lib/import-export/import.test.ts
··· 1 + import { afterAll, describe, expect, mock, test } from "bun:test"; 2 + import { zipSync } from "fflate"; 3 + import type { RequestContext } from "../../../src/lib/access.ts"; 4 + import { getDb } from "../../../src/server/db/index.ts"; 5 + 6 + // Mock PDS + session + image processing 7 + const realPds = await import("../../../src/atproto/pds.ts"); 8 + const realSession = await import("../../../src/atproto/session.ts"); 9 + const realImage = await import("../../../src/lib/image.ts"); 10 + 11 + const mockWriteWikiRecord = mock(async () => ({ 12 + uri: "at://did:plc:importtest/wiki.lichen.wiki/test", 13 + cid: "bafyrei123", 14 + })); 15 + const mockWriteMembershipRecord = mock(async () => ({ 16 + uri: "at://did:plc:importtest/wiki.lichen.membership/abc", 17 + cid: "bafyrei456", 18 + })); 19 + const mockWriteNoteRecord = mock(async () => ({ 20 + uri: "at://did:plc:importtest/wiki.lichen.note/note1", 21 + cid: "bafyrei789", 22 + })); 23 + const mockWriteRevisionRecord = mock(async () => ({ 24 + uri: "at://did:plc:importtest/wiki.lichen.noteRevision/rev1", 25 + cid: "bafyreirev", 26 + })); 27 + const mockGetAgent = mock(async () => ({}) as never); 28 + 29 + mock.module("../../../src/atproto/pds.ts", () => ({ 30 + ...realPds, 31 + writeWikiRecord: mockWriteWikiRecord, 32 + writeMembershipRecord: mockWriteMembershipRecord, 33 + writeNoteRecord: mockWriteNoteRecord, 34 + writeRevisionRecord: mockWriteRevisionRecord, 35 + })); 36 + mock.module("../../../src/atproto/session.ts", () => ({ 37 + ...realSession, 38 + getAgent: mockGetAgent, 39 + })); 40 + mock.module("../../../src/lib/image.ts", () => ({ 41 + ...realImage, 42 + processImage: mock(async (data: Buffer, mimeType: string) => ({ 43 + data, 44 + mimeType, 45 + })), 46 + })); 47 + 48 + const { importWikiAction } = await import( 49 + "../../../src/lib/import-export/import.ts" 50 + ); 51 + const { ImportError, ValidationError } = await import( 52 + "../../../src/lib/errors.ts" 53 + ); 54 + 55 + const dummyMsg = { 56 + error: { 57 + wikiNameRequired: "Name required", 58 + wikiLanguageRequired: "Language required", 59 + invalidSlug: "Invalid slug: {title}", 60 + wikiSlugExists: "Slug exists: {slug}", 61 + }, 62 + } as never; 63 + 64 + const TEST_DID = "did:plc:importtest"; 65 + 66 + function makeCtx(overrides: Partial<RequestContext> = {}): RequestContext { 67 + return { 68 + session: null, 69 + wiki: null, 70 + effectiveDid: TEST_DID, 71 + access: "none", 72 + locale: "en" as const, 73 + hasPendingRequest: false, 74 + ...overrides, 75 + }; 76 + } 77 + 78 + function makeZip(files: Record<string, string | Uint8Array>): ArrayBuffer { 79 + const entries: Record<string, Uint8Array> = {}; 80 + for (const [name, content] of Object.entries(files)) { 81 + entries[name] = 82 + typeof content === "string" ? new TextEncoder().encode(content) : content; 83 + } 84 + return zipSync(entries).buffer as ArrayBuffer; 85 + } 86 + 87 + const createdSlugs: string[] = []; 88 + 89 + afterAll(() => { 90 + const db = getDb(); 91 + for (const slug of createdSlugs) { 92 + db.run("DELETE FROM current_note WHERE note_at_uri LIKE ?", [ 93 + `at://${TEST_DID}/wiki.lichen.note/%`, 94 + ]); 95 + db.run("DELETE FROM revisions WHERE note_at_uri LIKE ?", [ 96 + `at://${TEST_DID}/wiki.lichen.note/%`, 97 + ]); 98 + db.run("DELETE FROM notes WHERE wiki_slug = ?", [slug]); 99 + db.run("DELETE FROM memberships WHERE wiki_slug = ?", [slug]); 100 + db.run("DELETE FROM wikis WHERE slug = ?", [slug]); 101 + } 102 + mock.module("../../../src/atproto/pds.ts", () => realPds); 103 + mock.module("../../../src/atproto/session.ts", () => realSession); 104 + mock.module("../../../src/lib/image.ts", () => realImage); 105 + }); 106 + 107 + describe("importWikiAction", () => { 108 + test("creates wiki with imported notes (no session)", async () => { 109 + const zip = makeZip({ 110 + "Home.md": "# Welcome\n\nHome page content", 111 + "Getting Started.md": "# Getting Started\n\nFirst steps", 112 + }); 113 + 114 + const result = await importWikiAction( 115 + makeCtx(), 116 + { 117 + name: "Import Test Wiki", 118 + language: "en", 119 + visibility: "public", 120 + description: "Imported wiki", 121 + zipBuffer: zip, 122 + }, 123 + dummyMsg, 124 + ); 125 + 126 + createdSlugs.push(result.wikiSlug); 127 + expect(result.wikiSlug).toBe("import-test-wiki"); 128 + expect(result.noteCount).toBe(2); 129 + 130 + const db = getDb(); 131 + const notes = db 132 + .query("SELECT * FROM notes WHERE wiki_slug = ? ORDER BY slug") 133 + .all(result.wikiSlug) as { slug: string; title: string }[]; 134 + expect(notes).toHaveLength(2); 135 + expect(notes.map((n) => n.slug)).toContain("home"); 136 + expect(notes.map((n) => n.slug)).toContain("getting-started"); 137 + }); 138 + 139 + test("rewrites wikilinks between imported notes", async () => { 140 + const zip = makeZip({ 141 + "Alpha.md": "See [[Beta]] for more.", 142 + "Beta.md": "Back to [[Alpha]].", 143 + }); 144 + 145 + const result = await importWikiAction( 146 + makeCtx(), 147 + { 148 + name: "Import Link Test", 149 + language: "en", 150 + visibility: "public", 151 + description: "", 152 + zipBuffer: zip, 153 + }, 154 + dummyMsg, 155 + ); 156 + 157 + createdSlugs.push(result.wikiSlug); 158 + 159 + const db = getDb(); 160 + const alphaContent = db 161 + .query( 162 + "SELECT content FROM current_note WHERE note_at_uri IN (SELECT at_uri FROM notes WHERE wiki_slug = ? AND slug = 'alpha')", 163 + ) 164 + .get(result.wikiSlug) as { content: string } | null; 165 + expect(alphaContent?.content).toContain("[[beta]]"); 166 + }); 167 + 168 + test("returns warnings for skipped/renamed files", async () => { 169 + const zip = makeZip({ 170 + "Note.md": "first", 171 + "subfolder/Note.md": "second", 172 + }); 173 + 174 + const result = await importWikiAction( 175 + makeCtx(), 176 + { 177 + name: "Import Warning Test", 178 + language: "en", 179 + visibility: "public", 180 + description: "", 181 + zipBuffer: zip, 182 + }, 183 + dummyMsg, 184 + ); 185 + 186 + createdSlugs.push(result.wikiSlug); 187 + expect(result.warnings.length).toBeGreaterThan(0); 188 + }); 189 + 190 + test("throws ImportError for empty zip", async () => { 191 + const zip = makeZip({}); 192 + expect( 193 + importWikiAction( 194 + makeCtx(), 195 + { 196 + name: "Import Empty Test", 197 + language: "en", 198 + visibility: "public", 199 + description: "", 200 + zipBuffer: zip, 201 + }, 202 + dummyMsg, 203 + ), 204 + ).rejects.toBeInstanceOf(ImportError); 205 + }); 206 + 207 + test("throws ValidationError for empty wiki name", async () => { 208 + const zip = makeZip({ "note.md": "content" }); 209 + expect( 210 + importWikiAction( 211 + makeCtx(), 212 + { 213 + name: "", 214 + language: "en", 215 + visibility: "public", 216 + description: "", 217 + zipBuffer: zip, 218 + }, 219 + dummyMsg, 220 + ), 221 + ).rejects.toBeInstanceOf(ValidationError); 222 + }); 223 + 224 + test("does not create home note automatically", async () => { 225 + const zip = makeZip({ 226 + "Alpha.md": "Just alpha", 227 + }); 228 + 229 + const result = await importWikiAction( 230 + makeCtx(), 231 + { 232 + name: "Import No Home Test", 233 + language: "en", 234 + visibility: "public", 235 + description: "", 236 + zipBuffer: zip, 237 + }, 238 + dummyMsg, 239 + ); 240 + 241 + createdSlugs.push(result.wikiSlug); 242 + 243 + const db = getDb(); 244 + const notes = db 245 + .query("SELECT slug FROM notes WHERE wiki_slug = ?") 246 + .all(result.wikiSlug) as { slug: string }[]; 247 + // Only the imported note, no auto-generated home note 248 + expect(notes).toHaveLength(1); 249 + expect(notes[0]?.slug).toBe("alpha"); 250 + }); 251 + });