Sort a notebook, import Markdown back, and stop the page shrinking in Draw
The paper measurably shrank on switching to Draw — 733px to 460px on the same screen. The editor was a flex item at its default `flex: 0 1 auto`, so its width was its widest row's: the toolbar. Swapping a dozen formatting buttons for six drawing ones collapsed it. It now fills the pane, so the width is a property of the window. Each notebook remembers how its notes are ordered, on the server rather than per device. The manual order has to survive being looked away from, so `reindex` renumbers every note in the notebook rather than the filtered view, and dragging is switched off while a search narrows the list. Reordering has its own route: `PUT /notes/:id` would bump `updated_at` and snapshot a version, so a drag would move a note to the top of "last changed" and spend a slot of its history. Markdown and our own ZIP archives come back as notes, with their images re-uploaded as attachments. A drawing returns as the PNG it was exported as — vectors are not recoverable from pixels — and the import says so rather than leaving it to be discovered. Writing the parser turned up a real bug in the exporter: blocks were emitted with no blank line between them, so a list after a list became one list and a quote after an item was read as part of it. Creating or importing from "All Notes" now asks which notebook instead of silently taking the first one. The web-link prompt is a real dialog: a bare domain becomes https, javascript: is refused with a reason. The PDF viewer's hide-drawing control is a labelled button rather than a filled-or-hollow dot, and the export icon points down into a tray like every other download. 186 unit tests, 179 browser checks across eleven suites, including a new `offline` suite that loads the app cold with the network off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3acb8b66a4
commit
0a0864c58b
27 files changed
+2962
-86
No files matched your search
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
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. Two layouts exist: a notebook
|
||||
* export puts it in `zeichnungen/`, a single-note export writes it beside the
|
||||
* file as `<name>-zeichnung.png`. */
|
||||
const base = baseName(markdownPath);
|
||||
const drawingPath = [`zeichnungen/${base}.png`, `${base}-zeichnung.png`].find((p) => sources.files.has(p));
|
||||
const drawingId = drawingPath ? await uploadPicture(sources.files.get(drawingPath)!, drawingPath) : null;
|
||||
if (drawingId) result.flattenedDrawings++;
|
||||
|
||||
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.
|
||||
if (drawingId && isDrawingMarker(n)) {
|
||||
return { type: "image", attrs: { attachmentId: drawingId, alt: "Zeichnung", src: null } };
|
||||
}
|
||||
return n;
|
||||
})!;
|
||||
|
||||
// No marker to replace (a hand-written archive, or an older export) — the
|
||||
// drawing still belongs in the note, so it goes at the end.
|
||||
if (drawingId && !JSON.stringify(doc).includes(drawingId)) {
|
||||
doc = {
|
||||
...doc,
|
||||
content: [
|
||||
...(doc.content ?? []),
|
||||
{ type: "image", attrs: { attachmentId: drawingId, alt: "Zeichnung", src: null } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const note = await sync.createNote(folderId);
|
||||
await sync.updateNoteTitle(note.id, title);
|
||||
await sync.updateNoteContent(
|
||||
note.id,
|
||||
serializeNoteContent(doc),
|
||||
[],
|
||||
[...uploaded.values(), ...(drawingId ? [drawingId] : [])]
|
||||
);
|
||||
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;
|
||||
}
|
||||
Reference in new issue
Block a user