The ink was stuck on page one while the writing carried on without it, which stopped making sense the moment a note could run to four pages. Every page is exported anyway, so every page can be drawn on. Pages are numbered the way the PDF reader numbers them, and only pages that carry ink are stored, so the keys are the drawn pages. While drawing there is always one blank page past the end: pages otherwise come from how far the writing runs, and to draw on page three you would first have had to write three pages. A drawing that lives entirely on page one is still written in the old single-page shape. Nothing requires that — it means an ordinary note is stored exactly as it always was, and a device running an older build still shows it. The bin clears the page being looked at. Undo and redo work on the drawing as a whole, because one Undo button that means four different things depending on where you scrolled is not a button anyone can use. And a real bug found on the way out: the exporter has written drawings/<note>.png since the interface was put into English, while the importer was still looking for zeichnungen/<note>.png. A drawing exported by this very app came back from its own archive as nothing at all. Both names are read now, and so are the further pages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
285 lines
10 KiB
TypeScript
285 lines
10 KiB
TypeScript
/**
|
|
* 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<string, string> = {
|
|
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<string, Uint8Array>;
|
|
}
|
|
|
|
async function uploadPicture(bytes: Uint8Array, name: string): Promise<string | null> {
|
|
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<string>((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<NoteMeta | null> {
|
|
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<string, string>();
|
|
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 `<name>-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<ImportResult> {
|
|
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;
|
|
}
|