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;
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { noteToMarkdown } from "../export/markdown";
|
||||
import { inlineNodes, markdownToNote } from "./markdownImport";
|
||||
import type { DocNode } from "../editor/content";
|
||||
|
||||
const doc = (md: string) => markdownToNote(md).doc;
|
||||
const first = (md: string) => doc(md).content![0]!;
|
||||
const plain = (n: DocNode): string => {
|
||||
if (n.text) return n.text;
|
||||
return (n.content ?? []).map(plain).join("");
|
||||
};
|
||||
|
||||
describe("blocks", () => {
|
||||
it("reads headings, capping them at the three levels the editor has", () => {
|
||||
expect(first("# Titel\n\nrumpf").type).toBe("paragraph"); // level 1 becomes the title
|
||||
expect(first("## Zwei")).toMatchObject({ type: "heading", attrs: { level: 2 } });
|
||||
expect(first("##### Fünf")).toMatchObject({ type: "heading", attrs: { level: 3 } });
|
||||
});
|
||||
|
||||
it("keeps a fenced code block verbatim, markup and all", () => {
|
||||
const block = first("```js\nconst a = **not bold**;\n```");
|
||||
expect(block).toMatchObject({ type: "codeBlock", attrs: { language: "js" } });
|
||||
expect(plain(block)).toBe("const a = **not bold**;");
|
||||
});
|
||||
|
||||
it("reads a horizontal rule", () => {
|
||||
expect(first("---").type).toBe("horizontalRule");
|
||||
});
|
||||
|
||||
it("reads a blockquote, including the blocks inside it", () => {
|
||||
const quote = first("> ## Kopf\n> Text");
|
||||
expect(quote.type).toBe("blockquote");
|
||||
expect(quote.content![0]!.type).toBe("heading");
|
||||
});
|
||||
|
||||
it("joins the lines of a paragraph", () => {
|
||||
expect(plain(first("erste Zeile\nzweite Zeile"))).toBe("erste Zeile zweite Zeile");
|
||||
});
|
||||
|
||||
it("stops a paragraph at the block that follows it", () => {
|
||||
const content = doc("Absatz\n## Überschrift").content!;
|
||||
expect(content.map((n) => n.type)).toEqual(["paragraph", "heading"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lists", () => {
|
||||
it("reads a bullet list", () => {
|
||||
const list = first("- eins\n- zwei");
|
||||
expect(list.type).toBe("bulletList");
|
||||
expect(list.content).toHaveLength(2);
|
||||
expect(plain(list.content![1]!)).toBe("zwei");
|
||||
});
|
||||
|
||||
it("reads an ordered list and keeps where it starts", () => {
|
||||
expect(first("3. drei\n4. vier")).toMatchObject({ type: "orderedList", attrs: { start: 3 } });
|
||||
});
|
||||
|
||||
it("reads a task list with its boxes", () => {
|
||||
const list = first("- [x] erledigt\n- [ ] offen");
|
||||
expect(list.type).toBe("taskList");
|
||||
expect(list.content![0]).toMatchObject({ type: "taskItem", attrs: { checked: true } });
|
||||
expect(list.content![1]).toMatchObject({ type: "taskItem", attrs: { checked: false } });
|
||||
expect(plain(list.content![0]!)).toBe("erledigt");
|
||||
});
|
||||
|
||||
it("splits where a list stops having checkboxes, rather than losing them", () => {
|
||||
// The editor has no node for "a list where some items have a box", so the
|
||||
// run ends and a second list begins. The alternative — one list with a
|
||||
// literal "[x]" in the text — throws away what the boxes meant.
|
||||
const content = doc("- [x] erledigt\n- einfach").content!;
|
||||
expect(content.map((n) => n.type)).toEqual(["taskList", "bulletList"]);
|
||||
});
|
||||
|
||||
it("nests by indentation", () => {
|
||||
const outer = first("- oben\n - innen");
|
||||
const inner = outer.content![0]!.content!.find((n) => n.type === "bulletList");
|
||||
expect(inner).toBeDefined();
|
||||
expect(plain(inner!)).toBe("innen");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tables", () => {
|
||||
it("reads a table with a header row", () => {
|
||||
const table = first("| A | B |\n| --- | --- |\n| 1 | 2 |");
|
||||
expect(table.type).toBe("table");
|
||||
expect(table.content).toHaveLength(2);
|
||||
expect(table.content![0]!.content![0]!.type).toBe("tableHeader");
|
||||
expect(plain(table.content![1]!.content![1]!)).toBe("2");
|
||||
});
|
||||
|
||||
it("recognises the empty header the exporter writes for a headerless table", () => {
|
||||
const table = first("| | |\n| --- | --- |\n| 1 | 2 |");
|
||||
expect(table.content).toHaveLength(1);
|
||||
expect(table.content![0]!.content![0]!.type).toBe("tableCell");
|
||||
});
|
||||
|
||||
it("pads a short row so every row has the same number of cells", () => {
|
||||
const table = first("| A | B |\n| --- | --- |\n| 1 |");
|
||||
expect(table.content![1]!.content).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("inline", () => {
|
||||
const marksOf = (md: string) => inlineNodes(md).map((n) => (n as any).marks?.map((m: any) => m.type) ?? []);
|
||||
|
||||
it("reads bold, italic, strikethrough and code", () => {
|
||||
expect(marksOf("**b**")).toEqual([["bold"]]);
|
||||
expect(marksOf("*i*")).toEqual([["italic"]]);
|
||||
expect(marksOf("~~s~~")).toEqual([["strike"]]);
|
||||
expect(marksOf("`c`")).toEqual([["code"]]);
|
||||
});
|
||||
|
||||
it("nests marks", () => {
|
||||
expect(marksOf("**_beides_**")).toEqual([["bold", "italic"]]);
|
||||
});
|
||||
|
||||
it("leaves underscores inside a word alone", () => {
|
||||
// snake_case_names are the most common thing a naive parser mangles.
|
||||
expect(plain({ content: inlineNodes("snake_case_name") } as DocNode)).toBe("snake_case_name");
|
||||
expect(marksOf("snake_case_name")).toEqual([[]]);
|
||||
});
|
||||
|
||||
it("does not read markup inside a code span", () => {
|
||||
expect(plain({ content: inlineNodes("`**roh**`") } as DocNode)).toBe("**roh**");
|
||||
});
|
||||
|
||||
it("reads a link", () => {
|
||||
const [node] = inlineNodes("[Beispiel](https://example.com)");
|
||||
expect((node as any).marks[0]).toMatchObject({ type: "link", attrs: { href: "https://example.com" } });
|
||||
});
|
||||
|
||||
it("reads an image and reports its path", () => {
|
||||
const parsed = markdownToNote("");
|
||||
expect(parsed.imagePaths).toEqual(["bilder/abc.png"]);
|
||||
});
|
||||
|
||||
it("reads the HTML underline the exporter falls back to", () => {
|
||||
expect(marksOf("<u>x</u>")).toEqual([["underline"]]);
|
||||
});
|
||||
|
||||
it("treats a lone asterisk as a character, not an unterminated mark", () => {
|
||||
expect(plain({ content: inlineNodes("2 * 3 = 6") } as DocNode)).toBe("2 * 3 = 6");
|
||||
});
|
||||
|
||||
it("undoes the exporter's escapes without touching a Windows path", () => {
|
||||
expect(plain({ content: inlineNodes("a \\* b") } as DocNode)).toBe("a * b");
|
||||
expect(plain({ content: inlineNodes("C:\\temp\\neu") } as DocNode)).toBe("C:\\temp\\neu");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the title", () => {
|
||||
it("comes from a leading level-one heading", () => {
|
||||
expect(markdownToNote("# Mein Titel\n\nText").title).toBe("Mein Titel");
|
||||
});
|
||||
|
||||
it("drops the timestamp line the exporter writes under it", () => {
|
||||
const parsed = markdownToNote("# T\n\n*07.08.2026, 12:00:00*\n\nText");
|
||||
expect(parsed.doc.content).toHaveLength(1);
|
||||
expect(plain(parsed.doc.content![0]!)).toBe("Text");
|
||||
});
|
||||
|
||||
it("is absent when the file does not start with one", () => {
|
||||
expect(markdownToNote("Nur Text").title).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a level-one heading further down as a heading", () => {
|
||||
const parsed = markdownToNote("Text\n\n# Später");
|
||||
expect(parsed.title).toBeNull();
|
||||
expect(parsed.doc.content!.map((n) => n.type)).toEqual(["paragraph", "heading"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an empty file", () => {
|
||||
it("still produces a document the editor can open", () => {
|
||||
expect(markdownToNote("").doc).toEqual({ type: "doc", content: [{ type: "paragraph" }] });
|
||||
});
|
||||
});
|
||||
|
||||
/* The pair matters more than either half: what leaves has to be able to come
|
||||
* back. These run a document through the exporter and read the result. */
|
||||
describe("round trip", () => {
|
||||
const roundTrip = (source: DocNode) => markdownToNote(noteToMarkdown(source)).doc;
|
||||
|
||||
it("brings back headings, lists, tasks, quotes, code and rules", () => {
|
||||
const source: DocNode = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "heading", attrs: { level: 2 }, content: [{ type: "text", text: "Kopf" }] },
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Ein Absatz." }] },
|
||||
{
|
||||
type: "bulletList",
|
||||
content: [
|
||||
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "eins" }] }] },
|
||||
{ type: "listItem", content: [{ type: "paragraph", content: [{ type: "text", text: "zwei" }] }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "taskList",
|
||||
content: [
|
||||
{
|
||||
type: "taskItem",
|
||||
attrs: { checked: true },
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "getan" }] }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "blockquote",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "zitiert" }] }],
|
||||
},
|
||||
{ type: "codeBlock", attrs: { language: "ts" }, content: [{ type: "text", text: "const a = 1;" }] },
|
||||
{ type: "horizontalRule" },
|
||||
],
|
||||
};
|
||||
|
||||
const back = roundTrip(source);
|
||||
expect(back.content!.map((n) => n.type)).toEqual([
|
||||
"heading",
|
||||
"paragraph",
|
||||
"bulletList",
|
||||
"taskList",
|
||||
"blockquote",
|
||||
"codeBlock",
|
||||
"horizontalRule",
|
||||
]);
|
||||
expect(plain(back)).toContain("getan");
|
||||
expect(back.content![5]!.content![0]!.text).toBe("const a = 1;");
|
||||
});
|
||||
|
||||
it("brings back the marks on a sentence", () => {
|
||||
const source: DocNode = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{ type: "text", text: "fett", marks: [{ type: "bold" }] } as DocNode,
|
||||
{ type: "text", text: " und " },
|
||||
{ type: "text", text: "kursiv", marks: [{ type: "italic" }] } as DocNode,
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const back = roundTrip(source);
|
||||
expect(plain(back)).toBe("fett und kursiv");
|
||||
const marks = back.content![0]!.content!.map((n) => (n as any).marks?.[0]?.type);
|
||||
expect(marks).toEqual(["bold", undefined, "italic"]);
|
||||
});
|
||||
|
||||
it("brings back a table with its header", () => {
|
||||
const cell = (type: string, text: string): DocNode => ({
|
||||
type,
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
|
||||
});
|
||||
const source: DocNode = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "table",
|
||||
content: [
|
||||
{ type: "tableRow", content: [cell("tableHeader", "A"), cell("tableHeader", "B")] },
|
||||
{ type: "tableRow", content: [cell("tableCell", "1"), cell("tableCell", "2")] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const back = roundTrip(source);
|
||||
const table = back.content![0]!;
|
||||
expect(table.type).toBe("table");
|
||||
expect(table.content![0]!.content![0]!.type).toBe("tableHeader");
|
||||
expect(plain(table)).toContain("2");
|
||||
});
|
||||
|
||||
it("keeps a title through the round trip instead of doubling it into the body", () => {
|
||||
const source: DocNode = {
|
||||
type: "doc",
|
||||
content: [{ type: "paragraph", content: [{ type: "text", text: "Inhalt" }] }],
|
||||
};
|
||||
const md = noteToMarkdown(source, { title: "Einkaufsliste", updatedAt: "2026-08-07T10:00:00Z" });
|
||||
const parsed = markdownToNote(md);
|
||||
expect(parsed.title).toBe("Einkaufsliste");
|
||||
expect(plain(parsed.doc)).toBe("Inhalt");
|
||||
});
|
||||
|
||||
it("keeps a picture's path so it can be matched back to the archive", () => {
|
||||
const source: DocNode = {
|
||||
type: "doc",
|
||||
content: [{ type: "image", attrs: { attachmentId: "abc123", alt: "Foto" } }],
|
||||
};
|
||||
const md = noteToMarkdown(source, { attachmentPath: (id) => `bilder/${id}.png` });
|
||||
expect(markdownToNote(md).imagePaths).toEqual(["bilder/abc123.png"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* Markdown → a note document.
|
||||
*
|
||||
* The counterpart to `export/markdown.ts`. Notes leave this app as Markdown so
|
||||
* they are not trapped in it; being able to bring them back is the other half of
|
||||
* that promise — and it is what makes an archive a backup rather than a souvenir.
|
||||
*
|
||||
* This is a small, deliberate subset: exactly the constructs the exporter emits,
|
||||
* plus the ones any hand-written note is likely to use. It is not CommonMark and
|
||||
* does not try to be. A full parser is thousands of lines and a permanent
|
||||
* maintenance surface, and the failure mode here is mild — an unrecognised
|
||||
* construct arrives as the literal text that was written, which is still the
|
||||
* user's words, in the user's order, and can be fixed in the editor in seconds.
|
||||
*
|
||||
* Kept free of the DOM and of TipTap so it can be unit tested as what it is: a
|
||||
* function from a string to a document.
|
||||
*/
|
||||
|
||||
import type { DocNode } from "../editor/content";
|
||||
|
||||
export interface ImportedNote {
|
||||
/** Taken from a leading `# Heading`, which is what the exporter writes. */
|
||||
title: string | null;
|
||||
doc: DocNode;
|
||||
/** Image sources found in the document, in the order they appear. */
|
||||
imagePaths: string[];
|
||||
}
|
||||
|
||||
/* ── inline ── */
|
||||
|
||||
interface Mark {
|
||||
type: string;
|
||||
attrs?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const text = (value: string, marks: Mark[] = []): DocNode =>
|
||||
marks.length ? ({ type: "text", text: value, marks } as DocNode) : { type: "text", text: value };
|
||||
|
||||
/**
|
||||
* Undoes `escapeText`/`escapeLineStart` from the exporter.
|
||||
*
|
||||
* Only backslash-escapes of characters Markdown actually gives meaning to are
|
||||
* removed. A Windows path such as `C:\temp\new` keeps its backslashes, because
|
||||
* `\t` and `\n` are not Markdown escapes and turning them into anything else
|
||||
* would corrupt the text.
|
||||
*/
|
||||
const unescape = (s: string) => s.replace(/\\([\\`*_[\]|~<>#+.-])/g, "$1");
|
||||
|
||||
/** One `[label](target)` or ``, respecting nested brackets in the label. */
|
||||
function matchLinkAt(src: string, at: number): { label: string; target: string; end: number } | null {
|
||||
let i = at;
|
||||
if (src[i] === "!") i++;
|
||||
if (src[i] !== "[") return null;
|
||||
i++;
|
||||
|
||||
let depth = 1;
|
||||
const start = i;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === "\\") {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (src[i] === "[") depth++;
|
||||
else if (src[i] === "]") {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
if (depth !== 0 || src[i + 1] !== "(") return null;
|
||||
const label = src.slice(start, i);
|
||||
|
||||
const close = src.indexOf(")", i + 2);
|
||||
if (close === -1) return null;
|
||||
return { label, target: src.slice(i + 2, close).trim(), end: close + 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Delimited inline spans, longest marker first so `**` is never read as two `*`.
|
||||
*
|
||||
* `_` is only honoured at a word boundary: `snake_case_name` is an identifier,
|
||||
* not emphasis, and treating it as emphasis is the single most common way a
|
||||
* naive Markdown parser mangles technical notes.
|
||||
*/
|
||||
const DELIMITERS: Array<{ open: string; mark: string; wordBoundary?: boolean }> = [
|
||||
{ open: "~~", mark: "strike" },
|
||||
{ open: "**", mark: "bold" },
|
||||
{ open: "__", mark: "bold", wordBoundary: true },
|
||||
{ open: "*", mark: "italic" },
|
||||
{ open: "_", mark: "italic", wordBoundary: true },
|
||||
];
|
||||
|
||||
const isWordChar = (c: string | undefined) => !!c && /[\p{L}\p{N}]/u.test(c);
|
||||
|
||||
export function inlineNodes(src: string, marks: Mark[] = [], images?: string[]): DocNode[] {
|
||||
const out: DocNode[] = [];
|
||||
let buffer = "";
|
||||
const flush = () => {
|
||||
if (buffer) {
|
||||
out.push(text(unescape(buffer), marks));
|
||||
buffer = "";
|
||||
}
|
||||
};
|
||||
|
||||
let i = 0;
|
||||
while (i < src.length) {
|
||||
const c = src[i]!;
|
||||
|
||||
if (c === "\\" && i + 1 < src.length) {
|
||||
buffer += c + src[i + 1];
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Code spans win over everything: their content is literal by definition.
|
||||
if (c === "`") {
|
||||
const end = src.indexOf("`", i + 1);
|
||||
if (end > i) {
|
||||
flush();
|
||||
out.push(text(src.slice(i + 1, end), [...marks, { type: "code" }]));
|
||||
i = end + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (c === "<") {
|
||||
const u = /^<u>([\s\S]*?)<\/u>/.exec(src.slice(i));
|
||||
if (u) {
|
||||
flush();
|
||||
out.push(...inlineNodes(u[1]!, [...marks, { type: "underline" }], images));
|
||||
i += u[0].length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (c === "!" || c === "[") {
|
||||
const link = matchLinkAt(src, i);
|
||||
if (link) {
|
||||
flush();
|
||||
if (c === "!") {
|
||||
images?.push(link.target);
|
||||
out.push({
|
||||
type: "image",
|
||||
attrs: { src: link.target, alt: unescape(link.label) || null, attachmentId: null },
|
||||
});
|
||||
} else {
|
||||
out.push(
|
||||
...inlineNodes(link.label, [...marks, { type: "link", attrs: { href: link.target } }], images)
|
||||
);
|
||||
}
|
||||
i = link.end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const delimiter = DELIMITERS.find((d) => {
|
||||
if (!src.startsWith(d.open, i)) return false;
|
||||
if (d.wordBoundary && isWordChar(src[i - 1])) return false;
|
||||
if (marks.some((m) => m.type === d.mark)) return false; // already inside one
|
||||
return true;
|
||||
});
|
||||
if (delimiter) {
|
||||
const end = src.indexOf(delimiter.open, i + delimiter.open.length);
|
||||
// An opener with no closer is just a character the user typed.
|
||||
if (end > i + delimiter.open.length) {
|
||||
flush();
|
||||
out.push(
|
||||
...inlineNodes(
|
||||
src.slice(i + delimiter.open.length, end),
|
||||
[...marks, { type: delimiter.mark }],
|
||||
images
|
||||
)
|
||||
);
|
||||
i = end + delimiter.open.length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
buffer += c;
|
||||
i++;
|
||||
}
|
||||
flush();
|
||||
return out;
|
||||
}
|
||||
|
||||
const paragraph = (line: string, images?: string[]): DocNode => {
|
||||
const content = inlineNodes(line, [], images);
|
||||
return content.length ? { type: "paragraph", content } : { type: "paragraph" };
|
||||
};
|
||||
|
||||
/* ── blocks ── */
|
||||
|
||||
const RULE = /^ {0,3}(-{3,}|\*{3,}|_{3,})\s*$/;
|
||||
const HEADING = /^ {0,3}(#{1,6})\s+(.*)$/;
|
||||
const FENCE = /^ {0,3}(`{3,}|~{3,})\s*(\S*)/;
|
||||
const BULLET = /^(\s*)([-*+])\s+(.*)$/;
|
||||
const ORDERED = /^(\s*)(\d+)[.)]\s+(.*)$/;
|
||||
const TASK = /^\[([ xX])\]\s*(.*)$/;
|
||||
const TABLE_DIVIDER = /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/;
|
||||
|
||||
const indentOf = (s: string) => s.replace(/\t/g, " ").match(/^ */)![0]!.length;
|
||||
|
||||
/** Splits a table row on unescaped pipes. */
|
||||
function tableCells(line: string): string[] {
|
||||
const cells: string[] = [];
|
||||
let current = "";
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
if (line[i] === "\\") {
|
||||
current += line[i]! + (line[i + 1] ?? "");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (line[i] === "|") {
|
||||
cells.push(current);
|
||||
current = "";
|
||||
continue;
|
||||
}
|
||||
current += line[i];
|
||||
}
|
||||
cells.push(current);
|
||||
// A row written `| a | b |` yields an empty cell at each end.
|
||||
if (cells[0]!.trim() === "") cells.shift();
|
||||
if (cells.length && cells[cells.length - 1]!.trim() === "") cells.pop();
|
||||
return cells.map((c) => c.trim());
|
||||
}
|
||||
|
||||
function parseBlocks(lines: string[], images: string[]): DocNode[] {
|
||||
const out: DocNode[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!;
|
||||
|
||||
if (!line.trim()) {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* fenced code — taken verbatim, including anything that looks like markup */
|
||||
const fence = FENCE.exec(line);
|
||||
if (fence) {
|
||||
const marker = fence[1]!;
|
||||
const language = fence[2] || null;
|
||||
const body: string[] = [];
|
||||
i++;
|
||||
while (i < lines.length && !lines[i]!.trimStart().startsWith(marker)) (body.push(lines[i]!), i++);
|
||||
i++; // closing fence, if there is one
|
||||
out.push({
|
||||
type: "codeBlock",
|
||||
attrs: { language },
|
||||
...(body.length ? { content: [{ type: "text", text: body.join("\n") }] } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RULE.test(line)) {
|
||||
out.push({ type: "horizontalRule" });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = HEADING.exec(line);
|
||||
if (heading) {
|
||||
// The editor only offers three levels; deeper ones would be dropped
|
||||
// entirely by the schema, so they are folded into the deepest it has.
|
||||
const level = Math.min(3, heading[1]!.length);
|
||||
out.push({ type: "heading", attrs: { level }, content: inlineNodes(heading[2]!.trim(), [], images) });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*>/.test(line)) {
|
||||
const inner: string[] = [];
|
||||
while (i < lines.length) {
|
||||
const current = lines[i]!;
|
||||
const marked = /^\s*>/.test(current);
|
||||
// Markdown lets a quoted paragraph run on without the `>`, but only as a
|
||||
// paragraph: a line that opens a block of its own is outside the quote.
|
||||
// Swallowing those used to pull the code fence and everything after it
|
||||
// into the blockquote.
|
||||
if (!marked && (!inner.length || !current.trim() || startsBlock(current, lines[i + 1]))) break;
|
||||
inner.push(current.replace(/^\s*>\s?/, ""));
|
||||
i++;
|
||||
}
|
||||
out.push({ type: "blockquote", content: parseBlocks(inner, images) });
|
||||
continue;
|
||||
}
|
||||
|
||||
/* table — a header row followed by the `|---|` divider */
|
||||
if (line.includes("|") && i + 1 < lines.length && TABLE_DIVIDER.test(lines[i + 1]!)) {
|
||||
const rows: string[][] = [tableCells(line)];
|
||||
i += 2;
|
||||
while (i < lines.length && lines[i]!.includes("|") && lines[i]!.trim()) {
|
||||
rows.push(tableCells(lines[i]!));
|
||||
i++;
|
||||
}
|
||||
const width = Math.max(...rows.map((r) => r.length));
|
||||
// The exporter writes an all-empty header row for a headerless table;
|
||||
// recognising that keeps a round trip from growing a header it never had.
|
||||
const headerIsReal = rows[0]!.some((c) => c !== "");
|
||||
const cell = (value: string, header: boolean): DocNode => ({
|
||||
type: header ? "tableHeader" : "tableCell",
|
||||
attrs: { colspan: 1, rowspan: 1, colwidth: null },
|
||||
content: [paragraph(value, images)],
|
||||
});
|
||||
const body = headerIsReal ? rows : rows.slice(1);
|
||||
out.push({
|
||||
type: "table",
|
||||
content: body.map((row, index) => ({
|
||||
type: "tableRow",
|
||||
content: Array.from({ length: width }, (_, c) => cell(row[c] ?? "", headerIsReal && index === 0)),
|
||||
})),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const bullet = BULLET.exec(line);
|
||||
const ordered = !bullet && ORDERED.exec(line);
|
||||
if (bullet || ordered) {
|
||||
const consumed = parseList(lines, i, images);
|
||||
out.push(consumed.node);
|
||||
i = consumed.next;
|
||||
continue;
|
||||
}
|
||||
|
||||
/* paragraph — runs to the next blank line or block opener */
|
||||
const buffer: string[] = [];
|
||||
while (i < lines.length && lines[i]!.trim() && !startsBlock(lines[i]!, lines[i + 1])) {
|
||||
buffer.push(lines[i]!.trim());
|
||||
i++;
|
||||
}
|
||||
if (buffer.length === 0) {
|
||||
// The line opens a block we did not consume above; keep it as text rather
|
||||
// than looping forever on it.
|
||||
buffer.push(lines[i]!.trim());
|
||||
i++;
|
||||
}
|
||||
// A trailing double space is Markdown's hard line break.
|
||||
out.push(paragraph(buffer.join(" "), images));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether a line begins a block, so a paragraph must stop before it. */
|
||||
function startsBlock(line: string, next: string | undefined): boolean {
|
||||
return (
|
||||
HEADING.test(line) ||
|
||||
FENCE.test(line) ||
|
||||
RULE.test(line) ||
|
||||
/^\s*>/.test(line) ||
|
||||
BULLET.test(line) ||
|
||||
ORDERED.test(line) ||
|
||||
(line.includes("|") && !!next && TABLE_DIVIDER.test(next))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One list and everything nested inside it.
|
||||
*
|
||||
* Nesting is by indentation, which is how the exporter writes it. Items marked
|
||||
* `- [ ]` make a task list, plain ones a bullet list, and a run that changes
|
||||
* from one to the other ends and a new list begins — which is exactly what the
|
||||
* editor holds, since those are two different nodes.
|
||||
*/
|
||||
function parseList(lines: string[], start: number, images: string[]): { node: DocNode; next: number } {
|
||||
const first = BULLET.exec(lines[start]!) || ORDERED.exec(lines[start]!);
|
||||
const baseIndent = indentOf(first![1]!);
|
||||
const isOrdered = !BULLET.test(lines[start]!);
|
||||
|
||||
const items: Array<{ body: string[]; task: RegExpExecArray | null }> = [];
|
||||
let i = start;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!;
|
||||
if (!line.trim()) {
|
||||
// A blank line ends the list unless the next line continues it.
|
||||
const following = lines[i + 1];
|
||||
if (!following || !following.trim() || indentOf(following) < baseIndent) break;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
const match = BULLET.exec(line) || ORDERED.exec(line);
|
||||
const indent = indentOf(line);
|
||||
|
||||
if (match && indent === baseIndent && BULLET.test(line) !== isOrdered) {
|
||||
const rest = match[3]!;
|
||||
const task = TASK.exec(rest);
|
||||
// A checkbox list and a plain one are different nodes in the editor, so a
|
||||
// run of items has to be all one or all the other. Without this, a bullet
|
||||
// list written directly above a task list absorbed it and the boxes came
|
||||
// back as literal "[x]" text.
|
||||
if (items.length && !!task !== !!items[0]!.task) break;
|
||||
items.push({ body: [rest], task });
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (indent > baseIndent && items.length) {
|
||||
// Deeper: part of the item above, with this list's indent removed.
|
||||
items[items.length - 1]!.body.push(line.slice(baseIndent));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Every item is the same kind by construction — the loop above stops where
|
||||
// that changes — so the first one decides.
|
||||
const allTasks = items.length > 0 && !!items[0]!.task;
|
||||
const itemType = allTasks ? "taskItem" : "listItem";
|
||||
|
||||
const node: DocNode = {
|
||||
type: allTasks ? "taskList" : isOrdered ? "orderedList" : "bulletList",
|
||||
...(isOrdered && !allTasks ? { attrs: { start: Number(first![2]) || 1 } } : {}),
|
||||
content: items.map((it) => {
|
||||
const body = [...it.body];
|
||||
if (allTasks) body[0] = it.task![2]!;
|
||||
return {
|
||||
type: itemType,
|
||||
...(allTasks ? { attrs: { checked: it.task![1]!.toLowerCase() === "x" } } : {}),
|
||||
content: parseBlocks(body, images),
|
||||
};
|
||||
}),
|
||||
};
|
||||
return { node, next: i };
|
||||
}
|
||||
|
||||
/* ── entry point ── */
|
||||
|
||||
/**
|
||||
* Reads a Markdown file as a note.
|
||||
*
|
||||
* A leading `# Heading` becomes the title — that is what the exporter writes, so
|
||||
* a note exported and imported again keeps its name instead of gaining a
|
||||
* duplicate heading in its body. The italic timestamp line the exporter adds
|
||||
* underneath is dropped for the same reason.
|
||||
*/
|
||||
export function markdownToNote(source: string): ImportedNote {
|
||||
const lines = source.replace(/\r\n?/g, "\n").split("\n");
|
||||
const images: string[] = [];
|
||||
|
||||
let title: string | null = null;
|
||||
let from = 0;
|
||||
while (from < lines.length && !lines[from]!.trim()) from++;
|
||||
|
||||
const heading = lines[from] ? HEADING.exec(lines[from]!) : null;
|
||||
if (heading && heading[1]!.length === 1) {
|
||||
title = inlineText(heading[2]!.trim());
|
||||
from++;
|
||||
let after = from;
|
||||
while (after < lines.length && !lines[after]!.trim()) after++;
|
||||
// The exporter's date line, e.g. `*07.08.2026, 12:00:00*`.
|
||||
if (lines[after] && /^\*[^*]+\*$/.test(lines[after]!.trim())) from = after + 1;
|
||||
}
|
||||
|
||||
const content = parseBlocks(lines.slice(from), images);
|
||||
return {
|
||||
title,
|
||||
doc: { type: "doc", content: content.length ? content : [{ type: "paragraph" }] },
|
||||
imagePaths: images,
|
||||
};
|
||||
}
|
||||
|
||||
/** Inline Markdown reduced to its plain text — for titles, which carry no marks. */
|
||||
export function inlineText(src: string): string {
|
||||
const parts: string[] = [];
|
||||
const walk = (nodes: DocNode[]) => {
|
||||
for (const n of nodes) {
|
||||
if (n.text) parts.push(n.text);
|
||||
else if (n.content) walk(n.content);
|
||||
}
|
||||
};
|
||||
walk(inlineNodes(src));
|
||||
return parts.join("").trim();
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createZipBytes } from "../export/zip";
|
||||
import { readZip } from "./zipRead";
|
||||
|
||||
const bytes = (u: Uint8Array) => {
|
||||
const copy = new Uint8Array(u.byteLength);
|
||||
copy.set(u);
|
||||
return copy.buffer;
|
||||
};
|
||||
const text = (u: Uint8Array) => new TextDecoder().decode(u);
|
||||
|
||||
describe("readZip", () => {
|
||||
it("reads back what the exporter wrote", async () => {
|
||||
const archive = createZipBytes([
|
||||
{ name: "Notiz.md", data: "# Notiz\n\nInhalt" },
|
||||
{ name: "bilder/abc.png", data: new Uint8Array([137, 80, 78, 71]) },
|
||||
]);
|
||||
|
||||
const files = await readZip(bytes(archive));
|
||||
expect(files.map((f) => f.name)).toEqual(["Notiz.md", "bilder/abc.png"]);
|
||||
expect(text(files[0]!.bytes)).toBe("# Notiz\n\nInhalt");
|
||||
expect([...files[1]!.bytes]).toEqual([137, 80, 78, 71]);
|
||||
});
|
||||
|
||||
it("keeps names with non-ASCII characters intact", async () => {
|
||||
const archive = createZipBytes([{ name: "Übung – Größe.md", data: "x" }]);
|
||||
const [file] = await readZip(bytes(archive));
|
||||
expect(file!.name).toBe("Übung – Größe.md");
|
||||
});
|
||||
|
||||
it("reads an empty file without confusing it for the end of the archive", async () => {
|
||||
const archive = createZipBytes([
|
||||
{ name: "leer.md", data: "" },
|
||||
{ name: "danach.md", data: "hier" },
|
||||
]);
|
||||
const files = await readZip(bytes(archive));
|
||||
expect(files).toHaveLength(2);
|
||||
expect(text(files[1]!.bytes)).toBe("hier");
|
||||
});
|
||||
|
||||
it("refuses something that is not a ZIP", async () => {
|
||||
await expect(readZip(new TextEncoder().encode("nur Text").buffer)).rejects.toThrow(/keine ZIP/);
|
||||
});
|
||||
|
||||
it("returns an empty list for an archive with no entries", async () => {
|
||||
expect(await readZip(bytes(createZipBytes([])))).toEqual([]);
|
||||
});
|
||||
|
||||
/* Extracted bytes must not be views onto the archive: handing one to a Blob
|
||||
* would carry the entire archive along with every picture. */
|
||||
it("hands back bytes of exactly the file's length", async () => {
|
||||
const archive = createZipBytes([
|
||||
{ name: "a.md", data: "aaaa" },
|
||||
{ name: "b.md", data: "bb" },
|
||||
]);
|
||||
const files = await readZip(bytes(archive));
|
||||
expect(files[0]!.bytes.length).toBe(4);
|
||||
expect(files[1]!.bytes.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* A minimal ZIP reader.
|
||||
*
|
||||
* The counterpart to `export/zip.ts`. Our own archives are stored (uncompressed),
|
||||
* but an archive that has been through a file manager, a mail client or a
|
||||
* re-zip on the way back is usually deflated, so both are handled. Inflating
|
||||
* uses the browser's own `DecompressionStream` rather than a bundled inflater —
|
||||
* it is in every engine this app targets, and it costs nothing to ship.
|
||||
*
|
||||
* The central directory is read rather than the local headers: a local header
|
||||
* may declare zero lengths and put the real ones in a data descriptor *after*
|
||||
* the data, which cannot be parsed forwards without decompressing blindly. The
|
||||
* directory always has the true values.
|
||||
*/
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
export interface ZipFile {
|
||||
/** Path inside the archive, with forward slashes. */
|
||||
name: string;
|
||||
bytes: Uint8Array;
|
||||
}
|
||||
|
||||
const EOCD = 0x06054b50;
|
||||
const CENTRAL = 0x02014b50;
|
||||
|
||||
/** Finds the end-of-central-directory record, scanning back over any comment. */
|
||||
function findEocd(view: DataView): number {
|
||||
const from = Math.max(0, view.byteLength - 22 - 0xffff);
|
||||
for (let i = view.byteLength - 22; i >= from; i--) {
|
||||
if (view.getUint32(i, true) === EOCD) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
async function inflateRaw(bytes: Uint8Array): Promise<Uint8Array> {
|
||||
if (typeof DecompressionStream === "undefined") {
|
||||
throw new Error("Dieses Archiv ist komprimiert; der Browser kann es nicht entpacken.");
|
||||
}
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
const stream = new Blob([copy.buffer]).stream().pipeThrough(new DecompressionStream("deflate-raw"));
|
||||
return new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
}
|
||||
|
||||
/**
|
||||
* Every file in the archive. Directory entries are skipped; encrypted and
|
||||
* otherwise unreadable members are skipped rather than failing the whole import,
|
||||
* because one bad member should not cost the user the other forty notes.
|
||||
*/
|
||||
export async function readZip(buffer: ArrayBuffer): Promise<ZipFile[]> {
|
||||
const view = new DataView(buffer);
|
||||
const all = new Uint8Array(buffer);
|
||||
|
||||
const eocd = findEocd(view);
|
||||
if (eocd < 0) throw new Error("Das ist keine ZIP-Datei.");
|
||||
|
||||
const count = view.getUint16(eocd + 10, true);
|
||||
let at = view.getUint32(eocd + 16, true);
|
||||
|
||||
const files: ZipFile[] = [];
|
||||
for (let n = 0; n < count && at + 46 <= view.byteLength; n++) {
|
||||
if (view.getUint32(at, true) !== CENTRAL) break;
|
||||
|
||||
const flags = view.getUint16(at + 8, true);
|
||||
const method = view.getUint16(at + 10, true);
|
||||
const compressedSize = view.getUint32(at + 20, true);
|
||||
const nameLength = view.getUint16(at + 28, true);
|
||||
const extraLength = view.getUint16(at + 30, true);
|
||||
const commentLength = view.getUint16(at + 32, true);
|
||||
const localAt = view.getUint32(at + 42, true);
|
||||
const name = decoder.decode(all.subarray(at + 46, at + 46 + nameLength));
|
||||
at += 46 + nameLength + extraLength + commentLength;
|
||||
|
||||
if (name.endsWith("/")) continue; // a folder, not a file
|
||||
if (flags & 0x0001) continue; // encrypted — nothing we can do with it
|
||||
|
||||
// The local header repeats the name and extra field, at its own lengths.
|
||||
const localNameLength = view.getUint16(localAt + 26, true);
|
||||
const localExtraLength = view.getUint16(localAt + 28, true);
|
||||
const from = localAt + 30 + localNameLength + localExtraLength;
|
||||
const raw = all.subarray(from, from + compressedSize);
|
||||
|
||||
try {
|
||||
if (method === 0) {
|
||||
files.push({ name, bytes: raw.slice() });
|
||||
} else if (method === 8) {
|
||||
files.push({ name, bytes: await inflateRaw(raw) });
|
||||
}
|
||||
} catch {
|
||||
/* an unreadable member is left out; the rest of the archive still imports */
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
Reference in new issue
Block a user