/** * Bringing Markdown back in. * * Notes leave this app as `.md` files, and as a `.zip` when there are pictures * or drawings beside them. Both come back here. A `.zip` written by the exporter * makes the round trip nearly whole: the text, the tables, the task boxes, the * embedded images — re-uploaded as attachments so they are encrypted at rest * again like anything else — and the drawing, which returns as the PNG it was * exported as. * * The one thing that cannot come back as it left is a drawing's *strokes*. A * PNG is pixels; the vectors that produced it are not recoverable from it. The * drawing therefore returns as a picture in the note — visible, exportable, * printable, but no longer editable with the pen. That is stated in the result * rather than glossed over, because silently downgrading someone's data is the * kind of surprise that is only discovered months later. */ import * as sync from "../syncEngine"; import * as db from "../offlineStore"; import { serializeNoteContent, type DocNode } from "../editor/content"; import { markdownToNote } from "./markdownImport"; import { readZip, type ZipFile } from "./zipRead"; import type { NoteMeta } from "../types"; export interface ImportProgress { done: number; total: number; label: string; } /* How far to look for further pages of one note's drawing. A bound rather than * a limit anyone should reach: without one, a malformed archive would have this * loop asking for filenames forever. */ const MAX_DRAWING_PAGES = 200; export interface ImportResult { notes: NoteMeta[]; /** Notes whose drawing came back as a picture instead of editable strokes. */ flattenedDrawings: number; /** Images referenced by a note but not present in the archive. */ missingImages: number; /** Files that could not be read at all, by name. */ failed: string[]; } const MARKDOWN = /\.(md|markdown|txt)$/i; const IMAGE_EXT: Record = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", webp: "image/webp", gif: "image/gif", avif: "image/avif", }; const mimeOf = (name: string) => IMAGE_EXT[name.split(".").pop()?.toLowerCase() ?? ""] ?? null; const baseName = (path: string) => path .split("/") .pop()! .replace(/\.[^.]+$/, ""); /** Resolves `../bilder/x.png` against the folder the note file sits in. */ function resolvePath(from: string, target: string): string { if (/^[a-z]+:/i.test(target)) return target; // absolute URL — not an archive path const parts = from.split("/").slice(0, -1); for (const segment of decodeURIComponent(target).split("/")) { if (segment === "." || segment === "") continue; if (segment === "..") parts.pop(); else parts.push(segment); } return parts.join("/"); } /* ── documents ── */ /** Depth-first walk that may replace a node with another (or with nothing). */ function mapNodes(node: DocNode, fn: (n: DocNode) => DocNode | null): DocNode | null { const mapped = fn(node); if (!mapped) return null; if (Array.isArray(mapped.content)) { mapped.content = mapped.content.map((c) => mapNodes(c, fn)).filter((c): c is DocNode => c !== null); } return mapped; } /** The exporter's stand-in for a drawing, so it can be swapped for the real PNG. */ const isDrawingMarker = (n: DocNode) => n.type === "blockquote" && JSON.stringify(n.content ?? "").includes("Zeichnung"); /* ── one note ── */ interface Sources { /** Archive path → bytes, for resolving relative image references. */ files: Map; } async function uploadPicture(bytes: Uint8Array, name: string): Promise { const mime = mimeOf(name) ?? "image/png"; try { // A copy, because a view onto the archive buffer would carry the whole // archive into the Blob. const copy = new Uint8Array(bytes.byteLength); copy.set(bytes); const blob = new Blob([copy.buffer], { type: mime }); const dataUrl = await new Promise((resolve, reject) => { const r = new FileReader(); r.onload = () => resolve(String(r.result)); r.onerror = () => reject(r.error); r.readAsDataURL(blob); }); const { id } = await sync.uploadAttachment(dataUrl, name); // Seed the offline cache from bytes already in hand, so the picture shows // immediately instead of being fetched back from the server. await db.putAttachmentBlob(id, blob).catch(() => {}); return id; } catch { return null; } } async function importOne( markdownPath: string, source: string, folderId: string, sources: Sources, result: ImportResult ): Promise { const parsed = markdownToNote(source); const title = parsed.title || baseName(markdownPath) || "Importiert"; /* Pictures first: the document has to name attachment ids, not archive paths. */ const uploaded = new Map(); for (const reference of new Set(parsed.imagePaths)) { const path = resolvePath(markdownPath, reference); const bytes = sources.files.get(path); if (!bytes) { result.missingImages++; continue; } const id = await uploadPicture(bytes, path); if (id) uploaded.set(reference, id); else result.missingImages++; } /* The drawing, if the archive carried one. * * Four layouts exist. A notebook export files it under `drawings/`, a * single-note export writes it beside the file as `-drawing.png`, and * both had German names — `zeichnungen/`, `-zeichnung` — before the interface * was put into English. All four are read: an archive written last year is * exactly the archive someone reaches for. * * Only the first two were being looked for until now, which meant a drawing * exported by this very app came back as nothing at all. * * A drawing may run over several pages. Page one keeps the plain name and the * rest are numbered after it, so they come back in order. */ const base = baseName(markdownPath); const drawingIds: string[] = []; for (const suffix of ["", ...Array.from({ length: MAX_DRAWING_PAGES - 1 }, (_, i) => `-${i + 2}`)]) { const path = [ `drawings/${base}${suffix}.png`, `${base}-drawing${suffix}.png`, `zeichnungen/${base}${suffix}.png`, `${base}-zeichnung${suffix}.png`, ].find((p) => sources.files.has(p)); const bytes = path ? sources.files.get(path) : null; if (!path || !bytes) break; // pages are consecutive; the first gap is the end const id = await uploadPicture(bytes, path); if (id) drawingIds.push(id); } if (drawingIds.length) result.flattenedDrawings++; const asPicture = (id: string) => ({ type: "image", attrs: { attachmentId: id, alt: "Drawing", src: null }, }); let replaced = false; let doc = mapNodes(parsed.doc, (n) => { if (n.type === "image") { const id = n.attrs?.src ? uploaded.get(n.attrs.src) : null; // An image whose file is not in the archive keeps its address: if it was a // web URL it still works, and if it was a missing file the note at least // records what used to be there. return id ? { ...n, attrs: { ...n.attrs, attachmentId: id, src: null } } : n; } // The exporter leaves a note saying the drawing lies beside the file. Now it // is back inside the note, so the note about it would be wrong. const first = drawingIds[0]; if (first && isDrawingMarker(n)) { replaced = true; return asPicture(first); } return n; })!; // The marker stands for the drawing as a whole, so it can only become the // first page; the others follow it at the end. With no marker at all (a // hand-written archive, or an older export) every page goes there. const trailing = replaced ? drawingIds.slice(1) : drawingIds; if (trailing.length) { doc = { ...doc, content: [...(doc.content ?? []), ...trailing.map(asPicture)] }; } const note = await sync.createNote(folderId); await sync.updateNoteTitle(note.id, title); await sync.updateNoteContent( note.id, serializeNoteContent(doc), [], [...uploaded.values(), ...drawingIds] ); return { ...note, title }; } /* ── entry point ── */ const textOf = (bytes: Uint8Array) => new TextDecoder().decode(bytes); /** * Imports the chosen files into a notebook. * * Each `.md` becomes one note; each `.zip` is unpacked and every Markdown file * inside becomes one. A file that cannot be read is named in the result rather * than aborting the rest — importing forty notes should not fail because the * thirty-first is damaged. */ export async function importFiles( files: File[], folderId: string, onProgress?: (p: ImportProgress) => void ): Promise { const result: ImportResult = { notes: [], flattenedDrawings: 0, missingImages: 0, failed: [] }; /* Everything is unpacked first, so the total is honest: a single ZIP can hold forty notes, and a progress bar that only counts files chosen would sit at "1 of 1" for a minute. */ const jobs: Array<{ path: string; source: string; sources: Sources }> = []; for (const file of files) { try { if (/\.zip$/i.test(file.name)) { const members: ZipFile[] = await readZip(await file.arrayBuffer()); const map = new Map(members.map((m) => [m.name, m.bytes])); const sources: Sources = { files: map }; const markdown = members.filter((m) => MARKDOWN.test(m.name)); for (const m of markdown) { // Our own archives carry an index file. It is a table of contents, not // a note, and importing it would add a phantom note linking to files // that no longer exist. A lone README is taken at face value, since // then it is the only thing in there to import. if (markdown.length > 1 && /^readme\.md$/i.test(m.name)) continue; jobs.push({ path: m.name, source: textOf(m.bytes), sources }); } } else if (MARKDOWN.test(file.name)) { jobs.push({ path: file.name, source: await file.text(), sources: { files: new Map() } }); } else { result.failed.push(file.name); } } catch (err) { console.error(`[import] ${file.name}:`, err); result.failed.push(file.name); } } for (const [i, job] of jobs.entries()) { onProgress?.({ done: i, total: jobs.length, label: baseName(job.path) }); try { const note = await importOne(job.path, job.source, folderId, job.sources, result); if (note) result.notes.push(note); } catch (err) { console.error(`[import] ${job.path}:`, err); result.failed.push(job.path); } } onProgress?.({ done: jobs.length, total: jobs.length, label: "" }); return result; }