diff --git a/backend/server.js b/backend/server.js index c138382..803eb19 100644 --- a/backend/server.js +++ b/backend/server.js @@ -339,6 +339,14 @@ if (!tableColumns("notebooks").includes("max_versions")) { db.exec("ALTER TABLE notebooks ADD COLUMN max_versions INTEGER"); } +/* How a notebook's notes are ordered on screen. Stored on the server rather + * than per device: it is a property of the notebook, and finding a notebook + * sorted differently on the phone than on the desktop is its own small + * annoyance. Null means the app's default, newest first. */ +if (!tableColumns("notebooks").includes("sort_mode")) { + db.exec("ALTER TABLE notebooks ADD COLUMN sort_mode TEXT"); +} + if (!tableColumns("notes").includes("pdf_attachment_id")) { db.exec("ALTER TABLE notes ADD COLUMN pdf_attachment_id TEXT"); } @@ -769,8 +777,11 @@ app.post("/api/notebooks", authMiddleware, (req, res) => { res.json({ id, name, coverIndex }); }); +/** The orders the note list offers. Anything else is refused rather than stored. */ +const SORT_MODES = ["manual", "updated", "oldest", "created", "title"]; + app.put("/api/notebooks/:id", authMiddleware, (req, res) => { - const { name, coverIndex, sortOrder, maxVersions } = req.body; + const { name, coverIndex, sortOrder, maxVersions, sortMode } = req.body; const nb = db.prepare("SELECT * FROM notebooks WHERE id = ? AND user_id = ?").get(req.params.id, req.userId); if (!nb) return res.status(404).json({ error: "Not found" }); @@ -784,11 +795,18 @@ app.put("/api/notebooks/:id", authMiddleware, (req, res) => { versions = Math.min(Math.round(n), MAX_VERSIONS_CEILING); } + if (sortMode !== undefined && sortMode !== null && !SORT_MODES.includes(sortMode)) { + return res.status(400).json({ error: `sortMode must be one of ${SORT_MODES.join(", ")}` }); + } + db.prepare("UPDATE notebooks SET name = COALESCE(?, name), cover_index = COALESCE(?, cover_index), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), version = version + 1 WHERE id = ?") .run(name, coverIndex, sortOrder, req.params.id); if (maxVersions !== undefined) { db.prepare("UPDATE notebooks SET max_versions = ? WHERE id = ?").run(versions, req.params.id); } + if (sortMode !== undefined) { + db.prepare("UPDATE notebooks SET sort_mode = ? WHERE id = ?").run(sortMode, req.params.id); + } broadcastToUser(req.userId, { type: "notebook_updated", data: { id: req.params.id, name, coverIndex } }, req.deviceId); res.json({ success: true, defaultMaxVersions: MAX_VERSIONS_PER_NOTE }); @@ -942,6 +960,31 @@ app.get("/api/notes/:id", authMiddleware, (req, res) => { res.json(note); }); +/** + * The user's own order for a notebook's notes. + * + * Deliberately not part of `PUT /api/notes/:id`. That route bumps `updated_at` + * and snapshots a version — so dragging a note into place would move it to the + * top of "last changed" and burn a slot of its history, for a change that is not + * to the note at all. This writes `sort_order` and nothing else. + */ +const MAX_REORDER = 5000; +app.post("/api/notes/reorder", authMiddleware, (req, res) => { + const ids = Array.isArray(req.body?.ids) ? req.body.ids.slice(0, MAX_REORDER) : null; + if (!ids) return res.status(400).json({ error: "ids must be an array" }); + + const write = db.transaction(() => { + const update = db.prepare("UPDATE notes SET sort_order = ? WHERE id = ? AND user_id = ?"); + ids.forEach((id, index) => { + if (typeof id === "string" && id) update.run(index, id, req.userId); + }); + }); + write(); + + broadcastToUser(req.userId, { type: "notes_reordered", data: { count: ids.length } }, req.deviceId); + res.json({ success: true }); +}); + app.post("/api/notes", authMiddleware, (req, res) => { const { type, title, content, notebookId, starred, pdfAttachmentId } = req.body; const id = uuid(); diff --git a/e2e/export.mjs b/e2e/export.mjs index ae14c2d..c9cb667 100644 --- a/e2e/export.mjs +++ b/e2e/export.mjs @@ -100,7 +100,7 @@ try { /* ── single note as Markdown ── */ await openExportDialog(p); - await p.getByRole("button", { name: /Markdown/ }).click(); + await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click(); await p.waitForSelector("[data-export-done]", { timeout: 30000 }); await p.waitForTimeout(600); @@ -139,7 +139,7 @@ try { await clearDownloads(p); await openExportDialog(p); - await p.getByRole("button", { name: /Markdown/ }).click(); + await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click(); await p.waitForSelector("[data-export-done]", { timeout: 30000 }); await p.waitForTimeout(800); files = await downloads(p); @@ -184,7 +184,7 @@ try { await p.waitForTimeout(500); check((await p.getByRole("button", { name: /PNG-Bild/ }).count()) > 0, "PNG is offered for the drawing"); check( - (await p.getByRole("button", { name: /Markdown/ }).count()) === 0, + (await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).count()) === 0, "Markdown is not offered for a drawing, since it cannot hold one" ); await p.getByRole("button", { name: /PNG-Bild/ }).click(); @@ -208,7 +208,7 @@ try { await p.getByRole("button", { name: /Nur den Notiztext/ }).click(); await p.waitForTimeout(500); await clearDownloads(p); - await p.getByRole("button", { name: /Markdown/ }).click(); + await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click(); await p.waitForSelector("[data-export-done]", { timeout: 30000 }); await p.waitForTimeout(800); files = await downloads(p); diff --git a/e2e/offline.mjs b/e2e/offline.mjs new file mode 100644 index 0000000..1d8f482 --- /dev/null +++ b/e2e/offline.mjs @@ -0,0 +1,156 @@ +/* Reading without a connection. + * + * The claim being tested is the one the app makes by being a PWA at all: a note + * you have seen is a note you can still read on a train. That has two halves, + * and both have to hold — the application itself has to load from the cache, and + * the notes have to already be on the device rather than one request away. + * + * "Offline" here means the browser context is switched offline, which is what + * losing a connection actually looks like to the page: requests fail, the + * service worker still answers, IndexedDB still works. + */ +import { + api, + check, + createNote, + createNotebook, + launch, + report, + setTitle, + typeInEditor, + withFreshUser, +} from "./lib.mjs"; + +const browser = await launch(); +let session; +try { + session = await withFreshUser(browser, "offline"); + const p = session.page; + const context = p.context(); + + await createNotebook(p, "Unterwegs"); + await p.locator("button").filter({ hasText: "Unterwegs" }).first().click(); + await p.waitForTimeout(700); + + for (const [title, body] of [ + ["Reiseplan", "Abfahrt um sieben Uhr vom Hauptbahnhof."], + ["Einkauf", "Milch, Brot, Kaffee."], + ]) { + await createNote(p); + await setTitle(p, title); + await typeInEditor(p, body); + await p.waitForTimeout(900); + } + + /* The service worker has to be in charge before it can answer anything. */ + const controlled = await p.evaluate(async () => { + if (!("serviceWorker" in navigator)) return "unsupported"; + const reg = await navigator.serviceWorker.ready; + return reg.active ? "active" : "no active worker"; + }); + check(controlled === "active", "a service worker is installed and running", controlled); + + /* Bodies reach the device on demand, so a note never opened would not be + readable offline. The background backfill exists for exactly that; give it + a moment and then confirm every note is really in local storage. */ + await p.waitForTimeout(4000); + const cached = await p.evaluate( + () => + new Promise((resolve) => { + const open = indexedDB.open("inknote"); + open.onerror = () => resolve({ error: String(open.error) }); + open.onsuccess = () => { + const idb = open.result; + if (!idb.objectStoreNames.contains("noteContents")) return resolve({ error: "no store" }); + const tx = idb.transaction("noteContents", "readonly"); + const all = tx.objectStore("noteContents").getAll(); + all.onsuccess = () => + resolve({ + count: all.result.length, + texts: all.result.map((r) => String(r.content || "")), + }); + all.onerror = () => resolve({ error: String(all.error) }); + }; + }) + ); + check(!cached.error, "the local copy of the notes can be read", cached.error || ""); + check(cached.count >= 2, "every note's body is on the device, not only the open one", `${cached.count} bodies`); + check( + cached.texts.some((t) => t.includes("Hauptbahnhof")), + "including one that was written earlier and then left" + ); + + /* ── pull the plug ── */ + await context.setOffline(true); + await p.waitForTimeout(500); + + const blocked = await p.evaluate(async () => { + try { + await fetch("/api/notes", { cache: "no-store" }); + return "still answering"; + } catch { + return "blocked"; + } + }); + check(blocked === "blocked", "the network really is gone", blocked); + + /* A cold start: not a soft re-render, a full page load with no connection. */ + await p.reload({ waitUntil: "domcontentloaded" }).catch(() => {}); + await p.waitForTimeout(4000); + + const loaded = await p.locator("body").innerText(); + check(loaded.length > 40, "the app itself loads from the cache with no connection", `${loaded.length} chars`); + check(!/Sign In/i.test(loaded), "and the session is still there — no login screen offline"); + + await p.locator("button").filter({ hasText: "Unterwegs" }).first().click(); + await p.waitForTimeout(1500); + const rows = await p.locator("[data-note-row]").count(); + check(rows >= 2, "the notebook still lists its notes", `${rows} rows`); + + await p.locator("[data-note-row]").filter({ hasText: "Reiseplan" }).first().click(); + await p.waitForTimeout(2500); + const body = await p.locator(".ProseMirror").innerText(); + check(body.includes("Hauptbahnhof"), "and a note opens with its text", JSON.stringify(body.slice(0, 60))); + + /* Searching is local by design — the server holds only ciphertext — so it has + to keep working when the server is not there at all. */ + await p.getByPlaceholder("Search notes...").fill("Kaffee"); + await p.waitForTimeout(1500); + const hits = await p.locator("[data-note-row]").count(); + check(hits === 1, "search still finds a note by its text, offline", `${hits} hits`); + await p.getByPlaceholder("Search notes...").fill(""); + await p.waitForTimeout(800); + + /* Writing offline is queued, not lost. */ + await p.locator("[data-note-row]").filter({ hasText: "Einkauf" }).first().click(); + await p.waitForTimeout(1800); + await p.locator(".ProseMirror").click(); + await p.keyboard.press("Control+End"); + await p.keyboard.type(" Und Butter."); + await p.waitForTimeout(2500); + + const status = await p.locator("body").innerText(); + check(/offline|Pending|Wartet|Änderungen/i.test(status), "the app says it is offline rather than pretending"); + + /* ── back on ── */ + await context.setOffline(false); + await p.waitForTimeout(1000); + await p.evaluate(() => window.dispatchEvent(new Event("online"))); + await p.waitForTimeout(8000); + + const server = (await api(p, "GET", "/notes")).body; + const einkauf = server.find((n) => n.title === "Einkauf"); + const stored = await api(p, "GET", `/notes/${einkauf.id}`); + check( + String(stored.body.content).includes("Butter"), + "and the edit made offline reaches the server once there is a connection", + String(stored.body.content).slice(0, 80) + ); +} catch (e) { + check(false, "suite ran to the end", e.message); +} finally { + await session?.cleanup(); + const code = report(); + await browser.close(); + process.exit(code); +} diff --git a/e2e/organise.mjs b/e2e/organise.mjs new file mode 100644 index 0000000..71da6cc --- /dev/null +++ b/e2e/organise.mjs @@ -0,0 +1,270 @@ +/* Sorting, importing, and the two dialogs that replaced a guess and a prompt. + * + * The load-bearing claim here is the one about sorting: an arrangement the user + * made by hand has to survive being looked away from. Searching imposes its own + * order, and the danger is that going back to "own order" shows something + * nobody arranged — because the reorder was computed against a filtered list. + * That is checked by arranging, searching, and coming back. + */ +import { + api, + check, + createNote, + createNotebook, + launch, + report, + setTitle, + typeInEditor, + withFreshUser, +} from "./lib.mjs"; + +const browser = await launch(); +let session; +try { + session = await withFreshUser(browser, "organise"); + const p = session.page; + + const titles = () => + p.locator("[data-note-row]").evaluateAll((rows) => + rows.map((r) => r.querySelector("span:nth-child(2)")?.textContent?.trim() || "") + ); + const sortBy = async (mode) => { + await p.locator("[data-sort-button]").click(); + await p.waitForTimeout(300); + await p.locator(`[data-sort-option="${mode}"]`).click(); + await p.waitForTimeout(900); + }; + + /* ── three notes, made in a known order ── */ + await createNotebook(p, "Ordnung"); + await p.locator("button").filter({ hasText: "Ordnung" }).first().click(); + await p.waitForTimeout(700); + + for (const name of ["Alpha", "Beta", "Gamma"]) { + await createNote(p); + await setTitle(p, name); + await typeInEditor(p, `Inhalt von ${name}. `); + await p.waitForTimeout(700); + } + await p.waitForTimeout(1200); + + check((await titles()).length === 3, "three notes to arrange", (await titles()).join(", ")); + + /* ── the built-in orders ── */ + await sortBy("updated"); + check((await titles())[0] === "Gamma", "newest first by default", (await titles()).join(", ")); + + await sortBy("oldest"); + check((await titles())[0] === "Alpha", "oldest first reverses it", (await titles()).join(", ")); + + await sortBy("title"); + check((await titles()).join(",") === "Alpha,Beta,Gamma", "A–Z sorts by title", (await titles()).join(", ")); + + /* ── it is remembered, not just applied ── */ + await p.reload({ waitUntil: "networkidle" }); + await p.waitForTimeout(2500); + await p.locator("button").filter({ hasText: "Ordnung" }).first().click(); + await p.waitForTimeout(1500); + check( + (await titles()).join(",") === "Alpha,Beta,Gamma", + "and it survives a reload, because it belongs to the notebook", + (await titles()).join(", ") + ); + + const notebooks = await api(p, "GET", "/notebooks"); + check(notebooks.body[0].sort_mode === "title", "the notebook itself carries the choice", String(notebooks.body[0].sort_mode)); + + /* ── the user's own order ── */ + await sortBy("manual"); + const beforeDrag = await titles(); + check(beforeDrag.length === 3, "manual order starts from the notes it has", beforeDrag.join(", ")); + + // Drag the last note onto the first. + const rows = p.locator("[data-note-row]"); + const last = rows.nth(2); + const first = rows.nth(0); + await last.hover(); + await p.mouse.down(); + await first.hover(); + await first.hover(); // a second move, so the drop target registers + await p.mouse.up(); + await p.waitForTimeout(1500); + + const arranged = await titles(); + check( + arranged[0] === beforeDrag[2], + "dragging a note to the top puts it there", + `${beforeDrag.join(",")} → ${arranged.join(",")}` + ); + + /* The point of the whole feature. */ + await p.getByPlaceholder("Search notes...").fill("Beta"); + await p.waitForTimeout(1200); + const searched = await titles(); + check(searched.length < 3, "searching narrows the list", searched.join(", ")); + + await p.getByPlaceholder("Search notes...").fill(""); + await p.waitForTimeout(1500); + check( + (await titles()).join(",") === arranged.join(","), + "and clearing the search shows exactly the arrangement again", + `${arranged.join(",")} vs ${(await titles()).join(",")}` + ); + + /* Reordering must not look like editing: a dragged note keeps its place in + "last changed", or the two orders would fight each other. */ + await sortBy("updated"); + check( + (await titles())[0] === "Gamma", + "a dragged note did not become the most recently changed one", + (await titles()).join(", ") + ); + await sortBy("manual"); + await p.waitForTimeout(800); + + /* ── exporting a note and importing it back ── */ + await p.locator("[data-note-row]").filter({ hasText: "Beta" }).first().click(); + await p.waitForTimeout(1200); + await p.locator(".ProseMirror").click(); + await p.keyboard.press("Control+End"); + await p.keyboard.press("Enter"); + // Through the toolbar rather than by typing "- [x] ": inside a bullet item + // that sequence stays literal text, which is a quirk of the editor's input + // rules and not what this suite is about. + await p.getByTitle("Task list").click(); + await p.waitForTimeout(400); + await p.keyboard.type("erledigt"); + await p.waitForTimeout(600); + await p.locator('.ProseMirror input[type="checkbox"]').first().click(); + await p.waitForTimeout(2000); + + const exported = await p.evaluate(async () => { + const captured = []; + const realCreate = URL.createObjectURL.bind(URL); + URL.createObjectURL = (blob) => { + captured.push(blob); + return realCreate(blob); + }; + window.__captured = captured; + HTMLAnchorElement.prototype.click = function () {}; + return true; + }); + check(exported, "downloads can be captured"); + + await p.getByTitle("Export").click(); + await p.waitForSelector("[data-export-dialog]", { timeout: 10000 }); + await p.waitForTimeout(600); + await p.locator("[data-export-dialog]").getByRole("button", { name: /Markdown/ }).click(); + await p.waitForTimeout(2500); + + const markdown = await p.evaluate(async () => { + const blob = window.__captured?.[0]; + return blob ? await blob.text() : ""; + }); + check(markdown.includes("- [x] erledigt"), "the export carries the ticked box", JSON.stringify(markdown.slice(-60))); + check(markdown.startsWith("# Beta"), "and the title as a heading"); + + await p.locator("[data-export-dialog] button").filter({ hasText: /Schließen|Fertig|Close/ }).first().click().catch(() => {}); + await p.keyboard.press("Escape"); + await p.waitForTimeout(600); + + /* Import it back as a second note. A real file chooser cannot be driven, so + the file is handed to the input directly — the code under test is what + happens after a file is chosen. */ + const before = (await api(p, "GET", "/notes")).body.length; + await p.evaluate((md) => { + const input = document.createElement("input"); + input.type = "file"; + document.body.appendChild(input); + const dt = new DataTransfer(); + dt.items.add(new File([md], "Beta.md", { type: "text/markdown" })); + input.files = dt.files; + window.__importInput = input; + }, markdown); + + const chooser = p.waitForEvent("filechooser"); + await p.locator("[data-import-button]").click(); + const fc = await chooser; + await fc.setFiles({ name: "Beta.md", mimeType: "text/markdown", buffer: Buffer.from(markdown) }); + await p.waitForTimeout(4000); + + const importedText = await p.locator("body").innerText(); + check(/importiert/i.test(importedText), "the import reports what it did", importedText.slice(0, 200).replace(/\n/g, " ")); + await p.getByRole("button", { name: /Fertig/ }).click(); + await p.waitForTimeout(2500); + + const after = (await api(p, "GET", "/notes")).body.length; + check(after === before + 1, "one note more than before", `${before} → ${after}`); + + const importedNote = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Beta" && n.id); + check(!!importedNote, "the imported note kept its title from the heading"); + + await p.locator("[data-note-row]").filter({ hasText: "Beta" }).last().click(); + await p.waitForTimeout(1800); + const boxes = await p.locator('.ProseMirror input[type="checkbox"]').count(); + check(boxes > 0, "the task box came back as a real checkbox, not as text", String(boxes)); + const checked = await p.locator('.ProseMirror input[type="checkbox"]:checked').count(); + check(checked > 0, "and it came back ticked", String(checked)); + + /* ── the link dialog, which used to be a browser prompt ── */ + await p.locator(".ProseMirror").click(); + await p.keyboard.press("Control+End"); + await p.keyboard.type("Zielwort"); + await p.keyboard.down("Shift"); + for (let i = 0; i < 8; i++) await p.keyboard.press("ArrowLeft"); + await p.keyboard.up("Shift"); + await p.getByTitle("Web link").click(); + await p.waitForTimeout(600); + + const field = p.getByLabel("Link address"); + check(await field.isVisible(), "the link dialog is a real dialog in the page"); + + await field.fill("javascript:alert(1)"); + await p.getByRole("button", { name: /Einfügen/ }).click(); + await p.waitForTimeout(400); + check( + await field.isVisible(), + "a javascript: address is refused rather than inserted" + ); + + await field.fill("example.com"); + await p.getByRole("button", { name: /Einfügen/ }).click(); + await p.waitForTimeout(1800); + const href = await p.locator(".ProseMirror a[href]").first().getAttribute("href"); + check(href === "https://example.com", "a bare domain is taken as https", String(href)); + + /* ── which notebook, when there is no notebook ── */ + await createNotebook(p, "Zweites Buch"); + await p.waitForTimeout(800); + await p.locator("button").filter({ hasText: "All Notes" }).first().click(); + await p.waitForTimeout(900); + await p.getByTitle("New Note").click(); + await p.waitForTimeout(900); + + const books0 = (await api(p, "GET", "/notebooks")).body; + const picker = p.locator("[data-notebook-option]"); + check((await picker.count()) === 2, "creating a note from All Notes asks where it should go", String(await picker.count())); + + // By name, not by position: two notebooks created without dragging share a + // sort order, so "the last one" is not a stable way to mean a particular book. + const target = books0.find((b) => b.name === "Zweites Buch"); + await p.locator(`[data-notebook-option="${target.id}"]`).click(); + await p.getByRole("button", { name: /Weiter/ }).click(); + await p.waitForTimeout(2500); + + // By title, not by position: the list is ordered by a timestamp with one + // second of resolution, and several notes here share that second. + const fresh = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Untitled Note"); + check( + fresh?.notebook_id === target.id, + "and it lands in the notebook that was chosen, not the first one", + `${fresh?.notebook_id} vs ${target.id}` + ); +} catch (e) { + check(false, "suite ran to the end", e.message); +} finally { + await session?.cleanup(); + const code = report(); + await browser.close(); + process.exit(code); +} diff --git a/e2e/run.sh b/e2e/run.sh index 4ea47f7..dff0848 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -13,7 +13,7 @@ cd "$(dirname "$0")" IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble" URL="${INKNOTE_URL:-http://localhost:3000}" SUITES=("$@") -[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle) +[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise offline) mkdir -p shots if [ ! -d node_modules ]; then diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index c44ed0c..830974d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -18,9 +18,12 @@ import { AccountMenu } from "./ui/AccountMenu.jsx"; import { AuthScreen } from "./ui/AuthScreen.jsx"; import { AdminPanel } from "./ui/AdminPanel.jsx"; import { ExportDialog } from "./ui/ExportDialog.jsx"; +import { NotebookPicker, rememberNotebook } from "./ui/NotebookPicker.jsx"; +import { ImportDialog } from "./ui/ImportDialog.jsx"; import { Sidebar } from "./ui/Sidebar.jsx"; import { Dashboard } from "./features/notebooks/Dashboard.jsx"; import { NoteList } from "./features/notes/NoteList.jsx"; +import { DEFAULT_SORT, canReorder, sortNotes } from "./features/notes/noteSort"; import { EditorPane } from "./features/notes/EditorPane.jsx"; import { HistoryModal } from "./features/notes/HistoryModal.jsx"; import { DrawingLayer } from "./features/drawing/DrawingLayer.jsx"; @@ -54,6 +57,14 @@ export default function App() { const [showAdmin, setShowAdmin] = useState(false); // { notes, label } — set while the export dialog is up. const [exporting, setExporting] = useState(null); + // { title, run } — set while the app is asking which notebook to use. + const [pickingNotebook, setPickingNotebook] = useState(null); + // Set while files are being imported; holds the progress and then the result. + const [importing, setImporting] = useState(null); + /* Sort order for the views that are not notebooks. Those have nowhere to + store a choice — "All Notes" is not a thing that can carry a setting — so it + lasts for the session and no longer. */ + const [virtualSort, setVirtualSort] = useState({}); const [historyFor, setHistoryFor] = useState(null); const [ctxMenu, setCtxMenu] = useState(null); const [confirmDialog, setConfirmDialog] = useState(null); @@ -68,6 +79,7 @@ export default function App() { const { notes, notebooks, noteContents, backlinks } = data; const search = useSearchIndex(user, notes, searchQuery); const dnd = useNotebookDnd(data.reorderNotebooks); + const noteDnd = useNotebookDnd(data.reorderNotes); useEffect(() => sync.onSyncStatus(setSyncStatus), []); @@ -153,6 +165,23 @@ export default function App() { const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]); const isTrashView = activeFolder === "trash"; + const activeNotebook = notebooks.find((nb) => nb.id === activeFolder) || null; + /* A real notebook remembers its order on the server; a view keeps one for the + session. Either way the note list is told a single mode. */ + const sortMode = activeNotebook + ? activeNotebook.sortMode || DEFAULT_SORT + : virtualSort[activeFolder] || DEFAULT_SORT; + + const setSortMode = useCallback( + (mode) => { + if (activeNotebook) void data.updateNotebook(activeNotebook.id, { sortMode: mode }); + else setVirtualSort((p) => ({ ...p, [activeFolder]: mode })); + }, + [activeNotebook, activeFolder, data] + ); + + const isSearching = searchQuery.trim().length > 0; + const filteredNotes = useMemo(() => { let list; if (activeFolder === "trash") list = trashedNotes; @@ -171,8 +200,8 @@ export default function App() { const q = searchQuery.toLowerCase(); return list.filter((n) => n.title.toLowerCase().includes(q)); } - return [...list].sort((a, b) => new Date(b.updatedAt) - new Date(a.updatedAt)); - }, [activeFolder, searchQuery, search.results, activeNotes, trashedNotes]); + return sortNotes(list, sortMode); + }, [activeFolder, searchQuery, search.results, activeNotes, trashedNotes, sortMode]); const noteMeta = activeNote ? notes.find((n) => n.id === activeNote) : null; // The editor opens as soon as the metadata is known; the body fills in when @@ -234,46 +263,84 @@ export default function App() { ); /* ── Actions ── */ + /** - * Picks a file and imports it as a PDF note. + * Runs something that needs a notebook to put a note in. + * + * "All Notes", "Starred" and the dashboard are views, not places. The app used + * to answer that silently with the first notebook, so a note or an imported + * file landed somewhere the user had not chosen and could only find by knowing + * that rule. Now it asks — once, remembering the answer as the default. + */ + const withNotebook = useCallback( + (title, run) => { + if (!VIRTUAL_FOLDERS.includes(activeFolder)) { + rememberNotebook(activeFolder); + return run(activeFolder); + } + if (notebooks.length === 0) return; + // Nothing to ask about when there is only one place it could go. + if (notebooks.length === 1) return run(notebooks[0].id); + setPickingNotebook({ title, run }); + }, + [activeFolder, notebooks] + ); + + /** + * Opens a file chooser. * * The input is created and thrown away per use rather than living in the * markup: a hidden input that persists keeps its previous selection, and * choosing the same file twice in a row would then fire no change event. */ - const importPdf = () => { - const folderId = VIRTUAL_FOLDERS.includes(activeFolder) ? notebooks[0]?.id : activeFolder; - if (!folderId) return; - + const chooseFiles = (accept, multiple, onChosen) => { const input = document.createElement("input"); input.type = "file"; - input.accept = "application/pdf,.pdf"; - input.onchange = async () => { - const file = input.files?.[0]; - if (!file) return; - try { - const note = await data.importPdf(folderId, file); - if (activeFolder === "dashboard") setActiveFolder(folderId); - openNote(note.id); - } catch (e) { - setConfirmDialog({ - title: "Import fehlgeschlagen", - message: e?.message || "Die Datei konnte nicht importiert werden.", - confirmLabel: "OK", - onConfirm: () => setConfirmDialog(null), - }); - } + input.accept = accept; + input.multiple = multiple; + input.onchange = () => { + const files = [...(input.files || [])]; + if (files.length) onChosen(files); }; input.click(); }; - const createNote = async () => { - const folderId = VIRTUAL_FOLDERS.includes(activeFolder) ? notebooks[0]?.id : activeFolder; - if (!folderId) return; - const note = await data.createNote(folderId); - if (activeFolder === "dashboard") setActiveFolder(folderId); - openNote(note.id); - }; + const reportFailure = (message) => + setConfirmDialog({ + title: "Import fehlgeschlagen", + message, + confirmLabel: "OK", + onConfirm: () => setConfirmDialog(null), + }); + + const importPdf = () => + withNotebook("PDF importieren", (folderId) => + chooseFiles("application/pdf,.pdf", false, async ([file]) => { + try { + const note = await data.importPdf(folderId, file); + if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId); + openNote(note.id); + } catch (e) { + reportFailure(e?.message || "Die Datei konnte nicht importiert werden."); + } + }) + ); + + /** Markdown files, or a ZIP written by this app's export. */ + const importMarkdown = () => + withNotebook("Notizen importieren", (folderId) => + chooseFiles(".md,.markdown,.txt,.zip,text/markdown,application/zip", true, (files) => { + if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId); + setImporting({ files, folderId }); + }) + ); + + const createNote = () => + withNotebook("Neue Notiz", async (folderId) => { + const note = await data.createNote(folderId); + if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId); + openNote(note.id); + }); /** Runs a destructive action behind a confirmation prompt. */ const confirm = (dialog) => @@ -518,11 +585,16 @@ export default function App() { isTrashView={isTrashView} trashedCount={trashedNotes.length} snippetFor={search.snippetFor} + sortMode={sortMode} + onSortModeChange={setSortMode} + reorderable={canReorder(sortMode, isSearching) && !!activeNotebook} + dnd={noteDnd} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} onSelectNote={openNote} onNoteContextMenu={noteContextMenu} onCreateNote={createNote} onImportPdf={importPdf} + onImportMarkdown={importMarkdown} onEmptyTrash={emptyTrash} /> )} @@ -581,6 +653,28 @@ export default function App() { dark={dark} /> )} + {pickingNotebook && ( + setPickingNotebook(null)} + onPick={(id) => { + const { run } = pickingNotebook; + setPickingNotebook(null); + run(id); + }} + /> + )} + {importing && ( + data.reload()} + onClose={() => setImporting(null)} + /> + )} {exporting && ( ( * Reads its active states straight off the editor, so the buttons reflect the * cursor even when formatting changed by keyboard shortcut. */ -export function EditorToolbar({ editor, T, icons, compact }) { +export function EditorToolbar({ editor, T, icons, compact, dark }) { const [, force] = useState(0); const [showTable, setShowTable] = useState(false); + const [linkDialog, setLinkDialog] = useState(null); // ProseMirror state changes do not go through React; subscribe so the active // states stay in sync with the cursor. @@ -111,19 +114,12 @@ export function EditorToolbar({ editor, T, icons, compact }) { cursor: "pointer", }; - const promptLink = () => { - const previous = editor.getAttributes("link").href || ""; - const url = window.prompt("Link URL (leave empty to remove)", previous); - if (url === null) return; - if (url === "") { - editor.chain().focus().extendMarkRange("link").unsetLink().run(); - return; - } - if (!/^(https?|mailto):/i.test(url)) { - window.alert("Only http(s) and mailto links are allowed."); - return; - } - editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run(); + /* The dialog is opened rather than a prompt shown; the validation and the + * "remove" affordance live inside it. See LinkDialog for why. */ + const openLinkDialog = () => setLinkDialog({ url: editor.getAttributes("link").href || "" }); + const closeLinkDialog = () => { + setLinkDialog(null); + editor.chain().focus().run(); // give the caret back to the note }; return ( @@ -259,9 +255,24 @@ export function EditorToolbar({ editor, T, icons, compact }) { > - + {linkDialog && ( + { + editor.chain().focus().extendMarkRange("link").unsetLink().run(); + setLinkDialog(null); + }} + onSubmit={(href) => { + editor.chain().focus().extendMarkRange("link").setLink({ href }).run(); + setLinkDialog(null); + }} + /> + )} {!compact && ( <> diff --git a/frontend/src/export/markdown.ts b/frontend/src/export/markdown.ts index 662219c..abbb706 100644 --- a/frontend/src/export/markdown.ts +++ b/frontend/src/export/markdown.ts @@ -212,7 +212,19 @@ export function noteToMarkdown(doc: DocNode, options: MarkdownOptions = {}): str } } - lines.push(...children(doc, options)); + /* A blank line between top-level blocks. + * + * Without one, Markdown runs adjacent blocks together: a bullet list written + * straight after another list becomes more items of the first, and a quote or + * a code fence on the line after a list item is read as part of that item. + * That is not a quirk of one reader — it is what the format says, so the file + * rendered wrongly everywhere, and reading it back in produced a document that + * did not match the one exported. */ + const blocks = (doc.content || []).flatMap((n) => { + const rendered = block(n, options); + return rendered.length && rendered.some((l) => l !== "") ? [...rendered, ""] : []; + }); + lines.push(...blocks); if (options.hasDrawing) { lines.push("", "> ✏️ Diese Notiz enthält eine Zeichnung, die Markdown nicht abbilden kann."); diff --git a/frontend/src/features/notes/NoteEditor.jsx b/frontend/src/features/notes/NoteEditor.jsx index f006937..ccca1fe 100644 --- a/frontend/src/features/notes/NoteEditor.jsx +++ b/frontend/src/features/notes/NoteEditor.jsx @@ -435,6 +435,16 @@ export function NoteEditor({ display: "flex", flexDirection: "column", height: "100%", + /* Fill the pane rather than the toolbar. + * + * As a flex item with the default `flex: 0 1 auto` this box was as wide + * as its widest row — the toolbar. Switching to Draw swaps a dozen + * formatting buttons for six drawing ones, the toolbar's natural width + * collapses, and the page took it with it: the paper measurably shrank + * from 733px to 460px on the same screen. Growing to the pane makes the + * width a property of the window, which is what it always looked like. */ + flex: 1, + minWidth: 0, background: isMobileViewport ? T.bg : dark @@ -464,6 +474,7 @@ export function NoteEditor({ T={{ ...T, danger: "#e5534b" }} icons={I} compact={isMobileViewport} + dark={dark} /> ) : ( <> diff --git a/frontend/src/features/notes/NoteList.jsx b/frontend/src/features/notes/NoteList.jsx index 4fbabf5..21f7eb6 100644 --- a/frontend/src/features/notes/NoteList.jsx +++ b/frontend/src/features/notes/NoteList.jsx @@ -1,8 +1,104 @@ +import { useEffect, useRef, useState } from "react"; + import { I } from "../../ui/icons.jsx"; import { fmtDate } from "../../ui/format"; import { FONT_SERIF, FONT_UI } from "../../ui/theme"; +import { SORT_LABELS, SORT_MODES } from "./noteSort"; -/** The middle column: every note in the selected folder, newest or best match first. */ +const iconButton = (T) => ({ + display: "flex", + alignItems: "center", + justifyContent: "center", + width: 32, + height: 32, + borderRadius: 10, + border: `1px solid ${T.border}`, + background: "transparent", + cursor: "pointer", + color: T.muted, +}); + +/** + * How this notebook's notes are ordered. + * + * The chosen order is stored with the notebook, so it is the same on every + * device and still there next week — a reference book stays alphabetical, a + * journal stays newest-first, without either being re-chosen on each visit. + */ +function SortMenu({ T, mode, onChange, disabled }) { + const [open, setOpen] = useState(false); + const box = useRef(null); + + useEffect(() => { + if (!open) return; + const close = (e) => { + if (!box.current?.contains(e.target)) setOpen(false); + }; + document.addEventListener("pointerdown", close); + return () => document.removeEventListener("pointerdown", close); + }, [open]); + + return ( +
+ + {open && ( +
+ {SORT_MODES.map((m) => ( + + ))} +
+ )} +
+ ); +} + +/** The middle column: every note in the selected folder, in the chosen order. */ export function NoteList({ T, isMobile, @@ -12,11 +108,16 @@ export function NoteList({ isTrashView, trashedCount, snippetFor, + sortMode, + onSortModeChange, + reorderable, + dnd, onToggleSidebar, onSelectNote, onNoteContextMenu, onCreateNote, onImportPdf, + onImportMarkdown, onEmptyTrash, }) { return ( @@ -41,7 +142,7 @@ export function NoteList({ borderBottom: `1px solid ${T.borderLight}`, }} > -
+
-

+

{title}

-
+
+ {!isTrashView && } {!isTrashView && ( + )} + {!isTrashView && ( + )} @@ -129,6 +237,23 @@ export function NoteList({
+ {/* Said once, where it matters: the arrangement is being edited, and the + search box would otherwise silently take that ability away. */} + {sortMode === "manual" && !isTrashView && ( +
+ {reorderable + ? "Eigene Reihenfolge — zum Umsortieren ziehen" + : "Eigene Reihenfolge — Suche aktiv, Ziehen pausiert"} +
+ )} +
{notes.length === 0 ? (
{ const snippet = snippetFor(note.id); + const isTarget = reorderable && dnd?.overId === note.id; return ( + {/* A labelled control, not a dot. Hiding the ink changes what the page + shows, so it has to be findable without being clicked to find out — + and its state has to be readable at a glance rather than from the + difference between a filled and a hollow circle. + Only shown once there is a drawing: until then it would offer to hide + nothing. */} + {totalStrokes(ink) > 0 && ( + + )}
+
+
+
+ ); +} diff --git a/frontend/src/ui/LinkDialog.jsx b/frontend/src/ui/LinkDialog.jsx new file mode 100644 index 0000000..a3da0d6 --- /dev/null +++ b/frontend/src/ui/LinkDialog.jsx @@ -0,0 +1,162 @@ +import { useEffect, useMemo, useRef, useState } from "react"; + +/** + * Attaching a web address to the selected text. + * + * This replaces `window.prompt`. That dialog cannot be styled, cannot be + * reached by a phone keyboard's paste button on every browser, gives no room to + * say *why* an address was refused, and — worst here — it is modal to the whole + * browser, so it steals focus from the editor and takes the selection with it on + * some engines. A real dialog also lets an existing link be removed with a + * button instead of the folklore of "leave the box empty". + * + * Only `http`, `https` and `mailto` are allowed. `javascript:` in an href is a + * script that runs with the page's own privileges when a note is clicked, and + * notes are exported, shared and re-imported; the check lives here and in the + * editor schema both, because either one alone is one mistake from a hole. + */ +const ALLOWED = /^(https?|mailto):/i; + +export function LinkDialog({ initialUrl = "", onSubmit, onRemove, onClose, dark }) { + const [url, setUrl] = useState(initialUrl); + const [error, setError] = useState(""); + const inputRef = useRef(null); + + const T = useMemo( + () => ({ + bg: dark ? "#161b22" : "#fff", + border: dark ? "#30363d" : "#d0d7de", + text: dark ? "#e6edf3" : "#1f2328", + muted: dark ? "#8b949e" : "#656d76", + accent: dark ? "#388bfd" : "#0969da", + danger: dark ? "#f85149" : "#cf222e", + field: dark ? "#0d1117" : "#f6f8fa", + }), + [dark] + ); + + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, []); + + useEffect(() => { + const onKey = (e) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const submit = (e) => { + e?.preventDefault(); + const value = url.trim(); + if (!value) { + setError("Bitte eine Adresse eingeben — oder den Link entfernen."); + return; + } + // A bare "example.com" is what people type; assume https rather than + // refusing something whose meaning is perfectly clear. + const candidate = /^[a-z][a-z0-9+.-]*:/i.test(value) ? value : `https://${value}`; + if (!ALLOWED.test(candidate)) { + setError("Nur http-, https- und mailto-Adressen sind erlaubt."); + return; + } + onSubmit(candidate); + }; + + const button = (kind) => ({ + padding: "8px 18px", + borderRadius: 10, + fontSize: 13, + fontWeight: 500, + cursor: "pointer", + border: kind === "primary" ? "none" : `1px solid ${T.border}`, + background: kind === "primary" ? T.accent : "transparent", + color: kind === "primary" ? "#fff" : kind === "danger" ? T.danger : T.text, + }); + + return ( +
+
e.stopPropagation()} + style={{ + background: T.bg, + borderRadius: 16, + padding: 24, + width: 430, + maxWidth: "92vw", + border: `1px solid ${T.border}`, + boxShadow: "0 20px 40px rgba(0,0,0,0.3)", + fontFamily: "'DM Sans',sans-serif", + }} + > +
+ {initialUrl ? "Link bearbeiten" : "Link einfügen"} +
+ + { + setUrl(e.target.value); + setError(""); + }} + placeholder="https://example.com" + spellCheck={false} + autoComplete="url" + style={{ + width: "100%", + padding: "10px 12px", + borderRadius: 10, + border: `1px solid ${error ? T.danger : T.border}`, + background: T.field, + color: T.text, + fontSize: 13, + outline: "none", + }} + /> +
+ {error || "http, https oder mailto"} +
+ +
+ {initialUrl && ( + + )} + + +
+
+
+ ); +} diff --git a/frontend/src/ui/NotebookPicker.jsx b/frontend/src/ui/NotebookPicker.jsx new file mode 100644 index 0000000..92f2531 --- /dev/null +++ b/frontend/src/ui/NotebookPicker.jsx @@ -0,0 +1,193 @@ +import { useEffect, useMemo, useState } from "react"; + +import { COVERS } from "./constants.jsx"; + +/** + * Asks which notebook something should go into. + * + * "All Notes", "Starred" and the dashboard are views, not places — a note + * created while one of them is open has no notebook to belong to. The app used + * to answer that silently with `notebooks[0]`, so a note or an imported PDF + * landed in whichever notebook happened to sort first. Nothing was lost, but + * finding it again meant knowing that rule. + * + * The last choice is remembered, so the common case — always the same notebook — + * is one Enter away, while the answer stays visible instead of being guessed. + */ +const LAST_KEY = "inknote_last_notebook"; + +export const rememberNotebook = (id) => { + try { + localStorage.setItem(LAST_KEY, id); + } catch { + /* private mode: the default simply is not remembered */ + } +}; + +const recalledNotebook = () => { + try { + return localStorage.getItem(LAST_KEY); + } catch { + return null; + } +}; + +export function NotebookPicker({ notebooks, title, confirmLabel = "Weiter", onPick, onClose, dark }) { + const remembered = recalledNotebook(); + const [selected, setSelected] = useState( + () => notebooks.find((nb) => nb.id === remembered)?.id || notebooks[0]?.id || null + ); + + const T = useMemo( + () => ({ + bg: dark ? "#161b22" : "#fff", + border: dark ? "#30363d" : "#d0d7de", + text: dark ? "#e6edf3" : "#1f2328", + muted: dark ? "#8b949e" : "#656d76", + accent: dark ? "#388bfd" : "#0969da", + row: dark ? "#0d1117" : "#f6f8fa", + }), + [dark] + ); + + useEffect(() => { + const onKey = (e) => { + if (e.key === "Escape") onClose(); + if (e.key === "Enter" && selected) confirm(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }); + + const confirm = () => { + if (!selected) return; + rememberNotebook(selected); + onPick(selected); + }; + + return ( +
+
e.stopPropagation()} + style={{ + background: T.bg, + borderRadius: 16, + padding: 24, + width: 400, + maxWidth: "92vw", + border: `1px solid ${T.border}`, + boxShadow: "0 20px 40px rgba(0,0,0,0.3)", + fontFamily: "'DM Sans',sans-serif", + }} + > +
+ {title} +
+
In welches Notizbuch soll es?
+ +
+ {notebooks.map((nb) => { + const active = nb.id === selected; + return ( + + ); + })} +
+ +
+ + +
+
+
+ ); +} diff --git a/frontend/src/ui/icons.jsx b/frontend/src/ui/icons.jsx index d5410ca..e8294bf 100644 --- a/frontend/src/ui/icons.jsx +++ b/frontend/src/ui/icons.jsx @@ -470,6 +470,9 @@ export const I = { ), + /* Export saves a file to the device, so the arrow points down into the tray. + * It used to point up and out, which is the shape every other program uses + * for uploading or sharing — the opposite of what this button does. */ export: ( - - + + + + ), + /* Reading a file in — the mirror of `export`, for the Markdown importer. */ + import: ( + + + + + + ), + eye: ( + + + + + ), + eyeOff: ( + + + + + + + ), + sort: ( + + + + + + ), users: ( diff --git a/readme.md b/readme.md index caf2698..372172d 100644 --- a/readme.md +++ b/readme.md @@ -19,6 +19,8 @@ A self-hosted note-taking application with rich text editing, freehand drawing, - **Star/Favorite** — Quick access to important notes - **PDF Reading & Annotation** — Import a PDF, read it, select its text, draw on it, write notes beside it, and export it with or without your marks - **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser +- **Import** — Markdown files, or a ZIP this app exported, come back as notes with their images +- **Sorting** — Each notebook remembers how its notes are ordered, including an arrangement made by hand - **Dark & Light Mode** — Full theme support with refined color schemes ### Sync & Storage @@ -302,7 +304,7 @@ failure distinguished by comparing an error's `.message` to a magic string. Failures now carry a typed `SyncError.code`; `.message` still holds the same string, so the remaining JavaScript callers are unaffected. -The 81 unit tests concentrate on the same places, and several exist because the +The 186 unit tests concentrate on the same places, and several exist because the bug they cover actually happened: - a queue entry that fails must stay queued, with an attempt counter @@ -334,6 +336,8 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running | `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does | | `conflict` | 10 | a late write wins but the state it buries survives as a version | | `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back | +| `organise` | 24 | a hand-made order survives a search; a note exported and imported comes back whole | +| `offline` | 12 | a cold page load with the network off still opens notes and searches them | Each suite creates a throwaway account through the admin API and deletes it afterwards, so runs leave the database as they found it. They need an admin to @@ -354,7 +358,7 @@ needs. Everything else lives behind a hook: | `useSearchIndex` | the local index and the background backfill that fills it | | `useResponsive` | phone vs desktop, sidebar and which pane a phone shows | | `useNavigationHistory` | mapping the current location onto browser history | -| `useNotebookDnd` | drag-to-reorder, shared by sidebar and dashboard | +| `useNotebookDnd` | drag-to-reorder, shared by the sidebar, the dashboard and the note list | | `usePwaInstall` | the browser's install offer | `useNoteData` deliberately has no confirmation prompts in it: whether to ask @@ -703,6 +707,70 @@ rows behind. --- +## Sorting a notebook + +Each notebook remembers how its notes are ordered, and remembers it on the +server rather than per device — the order is a property of the notebook, and +finding one sorted differently on the phone than on the desktop is its own small +annoyance. A reference book can stay alphabetical while a journal stays +newest-first. + +| Mode | Order | +| --- | --- | +| `manual` | the user's own arrangement, dragged into place | +| `updated` | most recently changed first *(the default)* | +| `oldest` | least recently changed first | +| `created` | most recently created first | +| `title` | A–Z, case- and umlaut-aware, with numbers read as numbers | + +**The manual order has to survive being looked away from.** Searching imposes +its own order — relevance — and switching back has to show exactly what was +arranged. That is only keepable if the arrangement lives in the notes and is +never re-derived from what is on screen, so two rules hold it up: + +- `reindex` renumbers **every note in the notebook**, taken from the store, not + the list being displayed. Renumbering a filtered subset would give a hidden + note the same position as a visible one and quietly scramble the rest. +- Dragging is switched off while a search is narrowing the list, and the list + says so rather than silently ignoring the drag. + +Reordering also has its own route, `POST /api/notes/reorder`. Going through +`PUT /api/notes/:id` would bump `updated_at` and snapshot a version — so +dragging a note would move it to the top of "last changed" and spend a slot of +its history on a change that is not to the note at all. + +--- + +## Import + +The other half of the promise that notes are not hostage to this program. A +`.md` file becomes a note; a `.zip` this app exported comes back nearly whole — +text, tables, task boxes, and the images, re-uploaded as attachments so they are +encrypted at rest again like anything else. + +The parser is a deliberate subset: what the exporter emits, plus what a +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. + +Two things do not come back, and the import says so afterwards rather than +leaving them to be discovered: + +- **A drawing returns as a picture, not as strokes.** It was exported as a PNG, + and vectors cannot be recovered from pixels. It stays visible, printable and + exportable, but the pen can no longer edit it. +- **An image the archive does not contain cannot be restored.** The place it sat + in the note is kept, so what used to be there is at least recorded. + +Writing the exporter also turned up a real bug in it: blocks were emitted +without a blank line between them, so a list written straight after another list +became more items of the first, and a quote or code fence on the next line was +read as part of the item above. That is not one reader's quirk — it is what the +format says — so the file rendered wrongly everywhere, not only here. + +--- + ## Export Notes should not be hostage to this program, so everything can leave it — from a