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>
295 lines
11 KiB
TypeScript
295 lines
11 KiB
TypeScript
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"]);
|
|
});
|
|
});
|