The interface had drifted into two languages — the frame in English, everything added later in German — which is worse than either, since the same idea appeared under two names depending on when that part was written. Sorting by title still uses the German collation, so Übung 2 comes before Übung 10: that is about the notes, not the chrome. Two archive folders were renamed with it (bilder/ → images/, zeichnungen/ → drawings/). The importer reads both spellings, and both the old and new marker line for a drawing — archives exported before this are on disk, and an import that quietly dropped their drawings would be the worst kind of tidy-up. Making a note, uploading a PDF and importing Markdown were three buttons side by side, two of them icons you had to hover to identify. They are the same act — putting something new in this notebook — so they are now one + menu. The search box moves out of the sidebar and into the note list, labelled with the notebook it narrows. Nothing changed about what it does: it has always searched only the notebook you are in, but from the sidebar it read as searching everything. A separator no longer carries a sentence explaining the order the sort menu already names. Renaming joins removing on the right-click menu — a left-click on the label started an edit, so the row could not be touched without falling into one. 218 unit tests, 238 browser checks across eleven suites. Two of those caught real breakage from this change rather than needing a translation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
219 lines
7.9 KiB
TypeScript
219 lines
7.9 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
|
|
import { noteToMarkdown, safeFilename } from "./markdown";
|
|
import type { DocNode } from "../editor/content";
|
|
|
|
const doc = (...content: DocNode[]): DocNode => ({ type: "doc", content });
|
|
const para = (...content: DocNode[]): DocNode => ({ type: "paragraph", content });
|
|
const text = (t: string, marks?: { type: string; attrs?: Record<string, unknown> }[]): DocNode =>
|
|
({ type: "text", text: t, ...(marks ? { marks } : {}) }) as DocNode;
|
|
const md = (d: DocNode, o = {}) => noteToMarkdown(d, o);
|
|
|
|
describe("text and marks", () => {
|
|
it("writes a plain paragraph", () => {
|
|
expect(md(doc(para(text("Hallo Welt"))))).toBe("Hallo Welt");
|
|
});
|
|
|
|
it("renders bold, italic and strikethrough", () => {
|
|
expect(md(doc(para(text("fett", [{ type: "bold" }]))))).toBe("**fett**");
|
|
expect(md(doc(para(text("kursiv", [{ type: "italic" }]))))).toBe("*kursiv*");
|
|
expect(md(doc(para(text("weg", [{ type: "strike" }]))))).toBe("~~weg~~");
|
|
});
|
|
|
|
it("falls back to HTML for underline, which Markdown lacks", () => {
|
|
expect(md(doc(para(text("unter", [{ type: "underline" }]))))).toBe("<u>unter</u>");
|
|
});
|
|
|
|
it("keeps a web link", () => {
|
|
const out = md(doc(para(text("hier", [{ type: "link", attrs: { href: "https://example.com" } }]))));
|
|
expect(out).toBe("[hier](https://example.com)");
|
|
});
|
|
|
|
it("escapes characters that would otherwise become syntax mid-line", () => {
|
|
expect(md(doc(para(text("2 * 3 _und_ [mehr]"))))).toBe("2 \\* 3 \\_und\\_ \\[mehr\\]");
|
|
});
|
|
|
|
it("leaves ordinary punctuation alone, so the result stays readable", () => {
|
|
expect(md(doc(para(text("Hallo. Alles gut! Preis: 5-10 EUR (ca.)"))))).toBe(
|
|
"Hallo. Alles gut! Preis: 5-10 EUR (ca.)"
|
|
);
|
|
});
|
|
|
|
it("escapes a leading marker so a paragraph does not become a list", () => {
|
|
expect(md(doc(para(text("- kein Listenpunkt"))))).toBe("\\- kein Listenpunkt");
|
|
expect(md(doc(para(text("1. kein Punkt"))))).toBe("\\1. kein Punkt");
|
|
expect(md(doc(para(text("# keine Ueberschrift"))))).toBe("\\# keine Ueberschrift");
|
|
});
|
|
|
|
it("does not escape inside a code span, where backslashes are literal", () => {
|
|
expect(md(doc(para(text("a_b", [{ type: "code" }]))))).toBe("`a_b`");
|
|
});
|
|
|
|
it("turns a hard break into a Markdown line break", () => {
|
|
expect(md(doc(para(text("eins"), { type: "hardBreak" }, text("zwei"))))).toBe("eins \nzwei");
|
|
});
|
|
});
|
|
|
|
describe("blocks", () => {
|
|
it("writes headings at their level", () => {
|
|
expect(md(doc({ type: "heading", attrs: { level: 2 }, content: [text("Titel")] }))).toBe("## Titel");
|
|
});
|
|
|
|
it("clamps an out-of-range heading level instead of emitting nonsense", () => {
|
|
expect(md(doc({ type: "heading", attrs: { level: 99 }, content: [text("X")] }))).toBe("###### X");
|
|
});
|
|
|
|
it("writes a bullet list", () => {
|
|
const list = {
|
|
type: "bulletList",
|
|
content: [
|
|
{ type: "listItem", content: [para(text("eins"))] },
|
|
{ type: "listItem", content: [para(text("zwei"))] },
|
|
],
|
|
};
|
|
expect(md(doc(list))).toBe("- eins\n- zwei");
|
|
});
|
|
|
|
it("numbers an ordered list from its start attribute", () => {
|
|
const list = {
|
|
type: "orderedList",
|
|
attrs: { start: 3 },
|
|
content: [
|
|
{ type: "listItem", content: [para(text("drei"))] },
|
|
{ type: "listItem", content: [para(text("vier"))] },
|
|
],
|
|
};
|
|
expect(md(doc(list))).toBe("3. drei\n4. vier");
|
|
});
|
|
|
|
it("writes task state as a checkbox, so it survives the trip", () => {
|
|
const list = {
|
|
type: "taskList",
|
|
content: [
|
|
{ type: "taskItem", attrs: { checked: true }, content: [para(text("erledigt"))] },
|
|
{ type: "taskItem", attrs: { checked: false }, content: [para(text("offen"))] },
|
|
],
|
|
};
|
|
expect(md(doc(list))).toBe("- [x] erledigt\n- [ ] offen");
|
|
});
|
|
|
|
it("writes a quote with every line prefixed", () => {
|
|
const quote = { type: "blockquote", content: [para(text("zitiert"))] };
|
|
expect(md(doc(quote))).toBe("> zitiert");
|
|
});
|
|
|
|
it("fences a code block and keeps its language", () => {
|
|
const code = { type: "codeBlock", attrs: { language: "js" }, content: [text("const a = 1;")] };
|
|
expect(md(doc(code))).toBe("```js\nconst a = 1;\n```");
|
|
});
|
|
|
|
it("writes a horizontal rule", () => {
|
|
expect(md(doc({ type: "horizontalRule" }))).toBe("---");
|
|
});
|
|
});
|
|
|
|
describe("tables", () => {
|
|
const cell = (t: string, type = "tableCell"): DocNode => ({ type, content: [para(text(t))] });
|
|
|
|
it("writes a header row and separator", () => {
|
|
const table = {
|
|
type: "table",
|
|
content: [
|
|
{ type: "tableRow", content: [cell("A", "tableHeader"), cell("B", "tableHeader")] },
|
|
{ type: "tableRow", content: [cell("1"), cell("2")] },
|
|
],
|
|
};
|
|
expect(md(doc(table))).toBe("| A | B |\n| --- | --- |\n| 1 | 2 |");
|
|
});
|
|
|
|
it("gives a headerless table an empty header, since Markdown has no other form", () => {
|
|
const table = {
|
|
type: "table",
|
|
content: [{ type: "tableRow", content: [cell("1"), cell("2")] }],
|
|
};
|
|
const out = md(doc(table)).split("\n");
|
|
expect(out[1]).toBe("| --- | --- |");
|
|
expect(out[2]).toBe("| 1 | 2 |");
|
|
});
|
|
|
|
it("escapes a pipe inside a cell so the table does not break apart", () => {
|
|
const table = {
|
|
type: "table",
|
|
content: [{ type: "tableRow", content: [cell("a|b", "tableHeader")] }],
|
|
};
|
|
expect(md(doc(table))).toContain("a\\|b");
|
|
expect(md(doc(table))).not.toContain("a\\\\|b");
|
|
});
|
|
|
|
it("pads a short row so the columns stay aligned", () => {
|
|
const table = {
|
|
type: "table",
|
|
content: [
|
|
{ type: "tableRow", content: [cell("A", "tableHeader"), cell("B", "tableHeader")] },
|
|
{ type: "tableRow", content: [cell("1")] },
|
|
],
|
|
};
|
|
expect(md(doc(table))).toContain("| 1 | |");
|
|
});
|
|
});
|
|
|
|
describe("images and note links", () => {
|
|
it("points an attachment at its file in the archive", () => {
|
|
const img = { type: "image", attrs: { attachmentId: "abc", alt: "Bild" } };
|
|
const out = md(doc(img), { attachmentPath: (id: string) => `images/${id}.png` });
|
|
expect(out).toBe("");
|
|
});
|
|
|
|
it("links to another exported note", () => {
|
|
const link = para({ type: "noteLink", attrs: { noteId: "n1", label: "Andere Notiz" } });
|
|
const out = md(doc(link), { notePath: (id: string) => `${id}.md` });
|
|
expect(out).toBe("[Andere Notiz](n1.md)");
|
|
});
|
|
|
|
it("degrades a link to a note that was not exported, rather than leaving a dead file link", () => {
|
|
const link = para({ type: "noteLink", attrs: { noteId: "n1", label: "Andere Notiz" } });
|
|
expect(md(doc(link), { notePath: () => null })).toBe("Andere Notiz");
|
|
});
|
|
});
|
|
|
|
describe("document framing", () => {
|
|
it("puts the title in a heading", () => {
|
|
expect(md(doc(para(text("Text"))), { title: "Meine Notiz" })).toBe("# Meine Notiz\n\nText");
|
|
});
|
|
|
|
it("says when a drawing could not come along, instead of dropping it silently", () => {
|
|
const out = md(doc(para(text("Text"))), { hasDrawing: true });
|
|
expect(out).toContain("drawing");
|
|
});
|
|
|
|
it("collapses the blank lines the block rules emit generously", () => {
|
|
const out = md(doc(para(text("a")), para(), para(), para(text("b"))));
|
|
expect(out).not.toMatch(/\n{3,}/);
|
|
});
|
|
|
|
it("survives an empty document", () => {
|
|
expect(md(doc())).toBe("");
|
|
expect(md({ type: "doc" })).toBe("");
|
|
});
|
|
|
|
it("keeps the children of a node type it does not know", () => {
|
|
const weird = { type: "somethingNew", content: [para(text("trotzdem da"))] };
|
|
expect(md(doc(weird))).toBe("trotzdem da");
|
|
});
|
|
});
|
|
|
|
describe("safeFilename", () => {
|
|
it("strips characters a filesystem would refuse", () => {
|
|
expect(safeFilename('a/b\\c:d*e?f"g<h>i|j')).toBe("a-b-c-d-e-f-g-h-i-j");
|
|
});
|
|
|
|
it("falls back when nothing usable is left", () => {
|
|
expect(safeFilename("")).toBe("Note");
|
|
expect(safeFilename("///")).toBe("---");
|
|
});
|
|
|
|
it("caps the length", () => {
|
|
expect(safeFilename("x".repeat(200)).length).toBe(80);
|
|
});
|
|
});
|