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:
marcandClaude Opus 5 committed 2026-08-09 01:38:03 +02:00
1 parent 3acb8b66a4
commit 0a0864c58b
27 files changed
+2962 -86

No files matched your search

+44 -1
View File
@@ -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();
+4 -4
View File
@@ -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);
+156
View File
@@ -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);
}
+270
View File
@@ -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);
}
+1 -1
View File
@@ -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
+124 -30
View File
@@ -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 && (
<NotebookPicker
notebooks={notebooks}
title={pickingNotebook.title}
dark={dark}
onClose={() => setPickingNotebook(null)}
onPick={(id) => {
const { run } = pickingNotebook;
setPickingNotebook(null);
run(id);
}}
/>
)}
{importing && (
<ImportDialog
files={importing.files}
folderId={importing.folderId}
dark={dark}
onDone={() => data.reload()}
onClose={() => setImporting(null)}
/>
)}
{exporting && (
<ExportDialog
notes={exporting.notes}
+26 -15
View File
@@ -1,5 +1,7 @@
import { useEffect, useState } from "react";
import { LinkDialog } from "../ui/LinkDialog.jsx";
/* Icons the main app does not already provide, drawn to match its stroke style. */
const strokeProps = {
width: 15,
@@ -51,9 +53,10 @@ const AtIcon = () => (
* 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 }) {
>
<AtIcon />
</button>
<button style={btn(editor.isActive("link"))} title="Web link" onClick={promptLink}>
<button style={btn(editor.isActive("link"))} title="Web link" data-link-button onClick={openLinkDialog}>
<LinkIcon />
</button>
{linkDialog && (
<LinkDialog
initialUrl={linkDialog.url}
dark={dark}
onClose={closeLinkDialog}
onRemove={() => {
editor.chain().focus().extendMarkRange("link").unsetLink().run();
setLinkDialog(null);
}}
onSubmit={(href) => {
editor.chain().focus().extendMarkRange("link").setLink({ href }).run();
setLinkDialog(null);
}}
/>
)}
{!compact && (
<>
+13 -1
View File
@@ -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.");
@@ -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}
/>
) : (
<>
+154 -20
View File
@@ -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 (
<div style={{ position: "relative" }} ref={box}>
<button
onClick={() => setOpen((v) => !v)}
disabled={disabled}
title={disabled ? "Sortierung" : `Sortierung: ${SORT_LABELS[mode]}`}
aria-label="Sort notes"
data-sort-button={mode}
style={{ ...iconButton(T), opacity: disabled ? 0.4 : 1, color: open ? T.accent : T.muted }}
>
{I.sort}
</button>
{open && (
<div
style={{
position: "absolute",
top: 38,
right: 0,
minWidth: 190,
background: T.card,
border: `1px solid ${T.border}`,
borderRadius: 12,
padding: 6,
zIndex: 300,
boxShadow: "0 10px 26px rgba(0,0,0,.28)",
}}
>
{SORT_MODES.map((m) => (
<button
key={m}
data-sort-option={m}
onClick={() => {
onChange(m);
setOpen(false);
}}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "7px 10px",
borderRadius: 8,
border: "none",
background: m === mode ? T.accentSoft : "transparent",
color: m === mode ? T.accent : T.text,
cursor: "pointer",
fontSize: 12,
textAlign: "left",
}}
>
<span style={{ width: 12 }}>{m === mode ? "✓" : ""}</span>
{SORT_LABELS[m]}
</button>
))}
</div>
)}
</div>
);
}
/** 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}`,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0 }}>
<button
onClick={onToggleSidebar}
style={{
@@ -59,28 +160,35 @@ export function NoteList({
>
{I.menu}
</button>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 600, color: T.text, fontFamily: FONT_SERIF }}>
<h2
style={{
margin: 0,
fontSize: 16,
fontWeight: 600,
color: T.text,
fontFamily: FONT_SERIF,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{title}
</h2>
</div>
<div style={{ display: "flex", gap: 4 }}>
<div style={{ display: "flex", gap: 4, flexShrink: 0 }}>
{!isTrashView && <SortMenu T={T} mode={sortMode} onChange={onSortModeChange} />}
{!isTrashView && (
<button
onClick={onImportPdf}
title="Import PDF"
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: 32,
height: 32,
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
cursor: "pointer",
color: T.muted,
}}
onClick={onImportMarkdown}
title="Notizen importieren (Markdown oder ZIP)"
data-import-button
style={iconButton(T)}
>
{I.import}
</button>
)}
{!isTrashView && (
<button onClick={onImportPdf} title="Import PDF" style={iconButton(T)}>
{I.pdf}
</button>
)}
@@ -129,6 +237,23 @@ export function NoteList({
</div>
</div>
{/* Said once, where it matters: the arrangement is being edited, and the
search box would otherwise silently take that ability away. */}
{sortMode === "manual" && !isTrashView && (
<div
style={{
padding: "5px 16px",
fontSize: 10.5,
color: T.faint,
borderBottom: `1px solid ${T.borderLight}`,
}}
>
{reorderable
? "Eigene Reihenfolge — zum Umsortieren ziehen"
: "Eigene Reihenfolge — Suche aktiv, Ziehen pausiert"}
</div>
)}
<div style={{ flex: 1, overflowY: "auto", padding: 6, WebkitOverflowScrolling: "touch" }}>
{notes.length === 0 ? (
<div
@@ -172,10 +297,16 @@ export function NoteList({
) : (
notes.map((note) => {
const snippet = snippetFor(note.id);
const isTarget = reorderable && dnd?.overId === note.id;
return (
<button
key={note.id}
data-note-row={note.id}
draggable={reorderable}
onDragStart={reorderable ? (e) => dnd.onDragStart(e, note.id) : undefined}
onDragOver={reorderable ? (e) => dnd.onDragOver(e, note.id) : undefined}
onDrop={reorderable ? (e) => dnd.onDrop(e, note.id) : undefined}
onDragEnd={reorderable ? dnd.onDragEnd : undefined}
onClick={() => onSelectNote(note.id)}
onContextMenu={(e) => onNoteContextMenu(e, note)}
style={{
@@ -185,8 +316,9 @@ export function NoteList({
padding: "12px 14px",
borderRadius: 10,
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
borderTop: isTarget ? `2px solid ${T.accent}` : undefined,
background: activeNote === note.id ? T.accentSoft : "transparent",
cursor: "pointer",
cursor: reorderable ? "grab" : "pointer",
marginBottom: 2,
textAlign: "left",
}}
@@ -194,7 +326,9 @@ export function NoteList({
<div
style={{ display: "flex", alignItems: "center", gap: 7, marginBottom: 3, width: "100%" }}
>
<span style={{ fontSize: 13 }}>{note.trashed ? "🗑️" : "📝"}</span>
<span style={{ fontSize: 13 }}>
{note.trashed ? "🗑️" : note.type === "pdf" ? "📄" : "📝"}
</span>
<span
style={{
fontSize: 13,
@@ -0,0 +1,131 @@
import { describe, expect, it } from "vitest";
import { canReorder, reindex, sortNotes } from "./noteSort";
import type { NoteMeta } from "../../types";
const note = (id: string, over: Partial<NoteMeta> = {}): NoteMeta => ({
id,
title: id,
folderId: "nb",
starred: false,
trashed: false,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...over,
});
const ids = (list: NoteMeta[]) => list.map((n) => n.id);
describe("sortNotes", () => {
const notes = [
note("b", { updatedAt: "2026-05-02T00:00:00Z", createdAt: "2026-01-03T00:00:00Z", sortOrder: 2 }),
note("a", { updatedAt: "2026-05-03T00:00:00Z", createdAt: "2026-01-01T00:00:00Z", sortOrder: 1 }),
note("c", { updatedAt: "2026-05-01T00:00:00Z", createdAt: "2026-01-02T00:00:00Z", sortOrder: 0 }),
];
it("puts the most recently changed first", () => {
expect(ids(sortNotes(notes, "updated"))).toEqual(["a", "b", "c"]);
});
it("reverses that for oldest", () => {
expect(ids(sortNotes(notes, "oldest"))).toEqual(["c", "b", "a"]);
});
it("sorts by creation date, which is not the same as by change date", () => {
expect(ids(sortNotes(notes, "created"))).toEqual(["b", "c", "a"]);
});
it("follows the stored positions when manual", () => {
expect(ids(sortNotes(notes, "manual"))).toEqual(["c", "a", "b"]);
});
it("does not mutate the list it was given", () => {
const original = [...notes];
sortNotes(notes, "title");
expect(notes).toEqual(original);
});
it("sorts titles the way German expects, ignoring case and numbering", () => {
const list = [
note("3", { title: "Übung 10" }),
note("1", { title: "übung 2" }),
note("2", { title: "Alpha" }),
];
expect(sortNotes(list, "title").map((n) => n.title)).toEqual(["Alpha", "übung 2", "Übung 10"]);
});
it("orders notes that have never been dragged newest first, not arbitrarily", () => {
// Everything imported at once carries position 0; without a tie-break the
// list would come out in whatever order the store happened to return.
const fresh = [
note("old", { updatedAt: "2026-01-01T00:00:00Z" }),
note("new", { updatedAt: "2026-06-01T00:00:00Z" }),
note("mid", { updatedAt: "2026-03-01T00:00:00Z" }),
];
expect(ids(sortNotes(fresh, "manual"))).toEqual(["new", "mid", "old"]);
});
it("is stable across repeated sorts of an entirely tied list", () => {
const tied = [note("z"), note("m"), note("a")];
const once = ids(sortNotes(tied, "manual"));
expect(ids(sortNotes([...tied].reverse(), "manual"))).toEqual(once);
});
});
describe("reindex", () => {
const all = [
note("a", { sortOrder: 0 }),
note("b", { sortOrder: 1 }),
note("c", { sortOrder: 2 }),
note("d", { sortOrder: 3 }),
];
it("moves a note onto the position of the one it was dropped on", () => {
expect(reindex(all, "d", "b")).toEqual(["a", "d", "b", "c"]);
});
it("works upwards as well as downwards", () => {
expect(reindex(all, "a", "c")).toEqual(["b", "c", "a", "d"]);
});
it("reports nothing to do when a note is dropped on itself", () => {
expect(reindex(all, "b", "b")).toBeNull();
});
it("reports nothing to do for a note that is not in the notebook", () => {
expect(reindex(all, "ghost", "b")).toBeNull();
});
/* The point of the whole design: an arrangement has to survive being looked
* away from. This is the case that used to go wrong in note apps — reorder
* against a filtered list and the hidden notes keep positions that now
* collide with the visible ones. */
it("keeps the arrangement intact when the view was filtered in between", () => {
const arranged = reindex(all, "d", "a")!;
const positioned = all.map((n) => ({ ...n, sortOrder: arranged.indexOf(n.id) }));
// The user searches, sees only two notes, then goes back to manual order.
const filtered = positioned.filter((n) => n.id === "b" || n.id === "d");
expect(ids(sortNotes(filtered, "manual"))).toEqual(["d", "b"]);
// Clearing the search must show exactly what was arranged.
expect(ids(sortNotes(positioned, "manual"))).toEqual(arranged);
});
it("renumbers every note in the notebook, not only the ones on screen", () => {
const order = reindex(all, "c", "a")!;
expect(order).toHaveLength(all.length);
expect(new Set(order)).toEqual(new Set(ids(all)));
});
});
describe("canReorder", () => {
it("allows dragging only in manual order", () => {
expect(canReorder("manual", false)).toBe(true);
expect(canReorder("updated", false)).toBe(false);
});
it("refuses while a search is narrowing the list", () => {
expect(canReorder("manual", true)).toBe(false);
});
});
+89
View File
@@ -0,0 +1,89 @@
/**
* The order notes appear in inside a notebook.
*
* Kept as a pure function so the awkward part can be tested: the promise that
* switching away to another order — or searching, which imposes its own — and
* then switching back leaves the user's own arrangement exactly as they left it.
*
* That promise is only keepable if the manual order lives in the notes and is
* never re-derived from whatever happens to be on screen. Hence `reindex`, which
* takes the *whole* notebook and refuses to renumber a filtered subset: doing so
* would give a hidden note the same position as a visible one and quietly
* scramble everything that was not being looked at.
*/
import type { NoteMeta, SortMode } from "../../types";
export const DEFAULT_SORT: SortMode = "updated";
export const SORT_LABELS: Record<SortMode, string> = {
manual: "Eigene Reihenfolge",
updated: "Zuletzt geändert",
oldest: "Am ältesten",
created: "Zuletzt erstellt",
title: "Titel A–Z",
};
export const SORT_MODES = Object.keys(SORT_LABELS) as SortMode[];
const time = (value: string | undefined) => {
const t = Date.parse(value ?? "");
return Number.isNaN(t) ? 0 : t;
};
const byTitle = (a: NoteMeta, b: NoteMeta) =>
(a.title || "").localeCompare(b.title || "", "de", { sensitivity: "base", numeric: true });
/**
* Sorts a list without mutating it.
*
* Every comparison ends in a tie-break that cannot itself tie: two notes created
* in the same second, or two untouched imports that all carry position 0, must
* still come out in the same order on every render, or the list shuffles under
* the cursor as React re-renders.
*/
export function sortNotes(notes: NoteMeta[], mode: SortMode = DEFAULT_SORT): NoteMeta[] {
const stable = (a: NoteMeta, b: NoteMeta) => a.id.localeCompare(b.id);
const compare: Record<SortMode, (a: NoteMeta, b: NoteMeta) => number> = {
// Notes that have never been dragged share position 0; newest first within
// that group, so a fresh note appears at the top rather than at random.
manual: (a, b) =>
(a.sortOrder ?? 0) - (b.sortOrder ?? 0) || time(b.updatedAt) - time(a.updatedAt) || stable(a, b),
updated: (a, b) => time(b.updatedAt) - time(a.updatedAt) || stable(a, b),
oldest: (a, b) => time(a.updatedAt) - time(b.updatedAt) || stable(a, b),
created: (a, b) => time(b.createdAt) - time(a.createdAt) || stable(a, b),
title: (a, b) => byTitle(a, b) || stable(a, b),
};
return [...notes].sort(compare[mode] ?? compare[DEFAULT_SORT]);
}
/**
* The full notebook renumbered after a note was dragged onto another.
*
* `all` must be every note in the notebook, not the filtered view. Returns the
* ids in their new order, or null when the move changes nothing — which spares
* the caller a pointless write.
*/
export function reindex(all: NoteMeta[], dragId: string, targetId: string): string[] | null {
if (dragId === targetId) return null;
const ordered = sortNotes(all, "manual");
const from = ordered.findIndex((n) => n.id === dragId);
const to = ordered.findIndex((n) => n.id === targetId);
if (from < 0 || to < 0) return null;
const [moved] = ordered.splice(from, 1);
if (!moved) return null;
ordered.splice(to, 0, moved);
return ordered.map((n) => n.id);
}
/**
* Whether the manual order may be changed right now.
*
* Dragging while a search is narrowing the list would reorder against a view the
* hidden notes are not part of — so it is simply not offered, rather than being
* offered and then producing an order nobody arranged.
*/
export const canReorder = (mode: SortMode, filtering: boolean) => mode === "manual" && !filtering;
+1 -1
View File
@@ -75,7 +75,7 @@ export function PdfNotesPanel({ note, dark, onUpdate, onOpenNote, searchLinkTarg
<span style={{ display: "flex", alignItems: "center", gap: 5, paddingRight: 4, flexShrink: 0 }}>
{I.text} Notizen
</span>
{editor && <EditorToolbar editor={editor} T={T} icons={I} compact />}
{editor && <EditorToolbar editor={editor} T={T} icons={I} compact dark={dark} />}
</div>
<div style={{ flex: 1, overflowY: "auto", minHeight: 0, padding: "10px 14px" }}>
+26 -8
View File
@@ -464,14 +464,32 @@ export function PdfViewer({ note, dark, onUpdate, onPagesReady }) {
</>
)}
<button
data-pdf-hide-ink={hideInk ? "on" : "off"}
onClick={() => setHideInk((v) => !v)}
title={hideInk ? "Zeichnung einblenden" : "Zeichnung ausblenden"}
style={{ ...barButton, color: hideInk ? T.accent : T.muted }}
>
{hideInk ? "◌" : "●"}
</button>
{/* 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 && (
<button
data-pdf-hide-ink={hideInk ? "on" : "off"}
onClick={() => setHideInk((v) => !v)}
title={hideInk ? "Zeichnung wieder einblenden" : "Zeichnung ausblenden"}
style={{
...barButton,
width: "auto",
padding: "0 10px",
gap: 6,
border: `1px solid ${hideInk ? T.accent + "66" : T.border}`,
background: hideInk ? T.accent + "1a" : "transparent",
color: hideInk ? T.accent : T.muted,
fontWeight: 600,
fontSize: 12,
}}
>
{hideInk ? I.eyeOff : I.eye} {hideInk ? "Zeichnung aus" : "Zeichnung an"}
</button>
)}
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
<button onClick={() => stepZoom(-1)} title="Kleiner" style={barButton}>
+23
View File
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import * as sync from "../syncEngine";
import * as searchIndex from "../search";
import { sameStrokeDoc } from "../features/drawing/strokes.js";
import { reindex } from "../features/notes/noteSort";
import type { Backlink, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types";
/** How often the whole account is reconciled with the server. */
@@ -44,6 +45,8 @@ export interface NoteData {
permanentDeleteNote: (id: string) => Promise<void>;
emptyTrash: () => Promise<void>;
moveNote: (id: string, folderId: string) => Promise<void>;
/** Drag-to-reorder within a notebook; only meaningful in manual sort. */
reorderNotes: (dragId: string, targetId: string) => void;
createNotebook: (fields: { name: string; cover: number }) => Promise<Notebook>;
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
@@ -261,6 +264,25 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId } : n)));
}, []);
/**
* Puts one note where another was, in the user's own order.
*
* The new positions are computed over *every* note in that notebook, taken
* from the store rather than from whatever the list is currently showing. A
* search or a filter must never be able to renumber the notes it is hiding.
*/
const reorderNotes = useCallback((dragId: string, targetId: string) => {
const dragged = notesRef.current.find((n) => n.id === dragId);
if (!dragged) return;
const inNotebook = notesRef.current.filter((n) => !n.trashed && n.folderId === dragged.folderId);
const order = reindex(inNotebook, dragId, targetId);
if (!order) return;
const position = new Map(order.map((id, i) => [id, i]));
setNotes((p) => p.map((n) => (position.has(n.id) ? { ...n, sortOrder: position.get(n.id)! } : n)));
void sync.reorderNotes(order);
}, []);
/* ── Notebooks ── */
const createNotebook = useCallback(async (fields: { name: string; cover: number }) => {
@@ -311,6 +333,7 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
permanentDeleteNote,
emptyTrash,
moveNote,
reorderNotes,
createNotebook,
updateNotebook,
deleteNotebook,
+253
View File
@@ -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;
}
+294
View File
@@ -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("![Bild](bilder/abc.png)");
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"]);
});
});
+474
View File
@@ -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 `![alt](src)`, 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();
}
+61
View File
@@ -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);
});
});
+95
View File
@@ -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;
}
+38 -1
View File
@@ -72,6 +72,7 @@ interface ServerNotebook {
cover_index?: number;
sort_order?: number;
max_versions?: number | null;
sort_mode?: string | null;
}
interface ServerNote {
@@ -83,6 +84,7 @@ interface ServerNote {
updated_at?: string;
deleted_at?: string;
type?: string;
sort_order?: number;
pdf_attachment_id?: string | null;
}
@@ -403,6 +405,7 @@ export async function fullSync(): Promise<void> {
cover: nb.cover_index || 0,
sortOrder: nb.sort_order || 0,
maxVersions: nb.max_versions ?? null,
sortMode: (nb.sort_mode as Notebook["sortMode"]) ?? null,
}));
const serverNbIds = new Set(nbs.map((nb) => nb.id));
const unsyncedNbs = localNbs.filter((nb) => nb._pendingCreate && !serverNbIds.has(nb.id));
@@ -454,6 +457,7 @@ function toNoteMeta(n: ServerNote, trashed: boolean): NoteMeta {
createdAt: n.created_at,
updatedAt: n.updated_at || n.deleted_at || n.created_at,
type: n.type === "pdf" ? "pdf" : "text",
sortOrder: n.sort_order ?? 0,
pdfAttachmentId: n.pdf_attachment_id ?? null,
};
}
@@ -465,7 +469,14 @@ type QueuedAction =
| { action: "createNotebook"; data: { localId: string; name: string; cover: number; sortOrder: number } }
| {
action: "updateNotebook";
data: { id: string; name?: string; cover?: number; sortOrder?: number; maxVersions?: number | null };
data: {
id: string;
name?: string;
cover?: number;
sortOrder?: number;
maxVersions?: number | null;
sortMode?: Notebook["sortMode"];
};
}
| { action: "deleteNotebook"; data: { id: string } }
| { action: "createNote"; data: { localId: string; title: string; folderId: string | null } }
@@ -550,6 +561,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
coverIndex: a.data.cover,
sortOrder: a.data.sortOrder,
...(a.data.maxVersions !== undefined ? { maxVersions: a.data.maxVersions } : {}),
...(a.data.sortMode !== undefined ? { sortMode: a.data.sortMode } : {}),
});
break;
case "deleteNotebook":
@@ -706,6 +718,7 @@ export async function updateNotebook(id: string, updates: Partial<Notebook>): Pr
// null is meaningful here — it clears the notebook's own setting — so it
// has to travel, while undefined must stay away and change nothing.
...(updates.maxVersions !== undefined ? { maxVersions: updates.maxVersions } : {}),
...(updates.sortMode !== undefined ? { sortMode: updates.sortMode } : {}),
});
return;
} catch (e) {
@@ -720,6 +733,7 @@ export async function updateNotebook(id: string, updates: Partial<Notebook>): Pr
cover: updates.cover,
sortOrder: updates.sortOrder,
maxVersions: updates.maxVersions,
sortMode: updates.sortMode,
},
});
notifyStatus("pending");
@@ -763,6 +777,29 @@ export async function getNotes(): Promise<NoteMeta[]> {
return db.getAllNotes();
}
/**
* Writes the user's own order for a set of notes.
*
* Its own route, not a note update: `PUT /notes/:id` bumps `updated_at` and
* snapshots 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.
*
* Applied locally first, so a drag holds even offline, and queued as a plain
* note update would be wrong for the same reason — it is sent when there is a
* connection and simply re-derived from the local order otherwise.
*/
export async function reorderNotes(orderedIds: string[]): Promise<void> {
const notes = await db.getAllNotes();
for (let i = 0; i < orderedIds.length; i++) {
const note = notes.find((n) => n.id === orderedIds[i]);
if (note && note.sortOrder !== i) await db.putNote({ ...note, sortOrder: i });
}
notifyData("notes", null);
if (_online && api.token) {
api.post("/notes/reorder", { ids: orderedIds }).catch(() => {});
}
}
const shapeContent = (r: (ServerNoteBody & Partial<NoteContent>) | undefined | null): NoteContent => ({
content: r?.content || "",
drawingData: r?.drawingData || null,
+12
View File
@@ -49,6 +49,8 @@ export interface NoteMeta {
trashed: boolean;
createdAt: string;
updatedAt: string;
/** Position in the notebook's own order, used when sorting is set to manual. */
sortOrder?: number;
/** Set while a note created offline has not reached the server yet. */
_pendingCreate?: boolean;
}
@@ -64,6 +66,14 @@ export interface NoteContent {
updatedAt?: string | null;
}
/**
* How a notebook's notes are ordered.
*
* "manual" is the user's own arrangement, kept in each note's `sortOrder`; the
* rest are derived from the notes themselves and need nothing stored.
*/
export type SortMode = "manual" | "updated" | "oldest" | "created" | "title";
export interface Notebook {
id: string;
name: string;
@@ -71,6 +81,8 @@ export interface Notebook {
sortOrder: number;
/** Versions kept per note here; null means the server's default. */
maxVersions?: number | null;
/** Null means the app default, newest first. */
sortMode?: SortMode | null;
_pendingCreate?: boolean;
}
+165
View File
@@ -0,0 +1,165 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { importFiles } from "../import/importNotes";
/**
* Runs an import and says plainly what came of it.
*
* The report is the point. An import can succeed in the sense that no error was
* thrown and still not be what the user expected — a drawing arrives as a flat
* picture, an image the archive did not contain cannot be restored. Those are
* stated afterwards rather than left to be discovered, because the moment to
* learn that a drawing is no longer editable is now, while the original export
* is still on disk.
*/
export function ImportDialog({ files, folderId, onDone, onClose, dark }) {
const [progress, setProgress] = useState({ done: 0, total: 0, label: "" });
const [result, setResult] = useState(null);
const [error, setError] = useState("");
const started = useRef(false);
const T = useMemo(
() => ({
bg: dark ? "#161b22" : "#fff",
border: dark ? "#30363d" : "#d0d7de",
text: dark ? "#e6edf3" : "#1f2328",
muted: dark ? "#8b949e" : "#656d76",
accent: dark ? "#388bfd" : "#0969da",
warning: dark ? "#d29922" : "#9a6700",
danger: dark ? "#f85149" : "#cf222e",
track: dark ? "#21262d" : "#e5e7eb",
}),
[dark]
);
useEffect(() => {
// React runs effects twice in development; importing twice would create
// every note a second time.
if (started.current) return;
started.current = true;
importFiles(files, folderId, setProgress)
.then((r) => {
setResult(r);
onDone?.(r);
})
.catch((e) => setError(e?.message || "Der Import ist fehlgeschlagen."));
}, [files, folderId, onDone]);
const done = result || error;
const pct = progress.total ? Math.round((progress.done / progress.total) * 100) : 0;
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
backdropFilter: "blur(4px)",
}}
onClick={done ? onClose : undefined}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: T.bg,
borderRadius: 16,
padding: 24,
width: 440,
maxWidth: "92vw",
border: `1px solid ${T.border}`,
boxShadow: "0 20px 40px rgba(0,0,0,0.3)",
fontFamily: "'DM Sans',sans-serif",
color: T.text,
}}
>
<div
style={{
fontSize: 17,
fontWeight: 600,
marginBottom: 14,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
{done ? "Import abgeschlossen" : "Notizen werden importiert…"}
</div>
{!done && (
<>
<div style={{ height: 6, borderRadius: 3, background: T.track, overflow: "hidden" }}>
<div
style={{
width: `${pct}%`,
height: "100%",
background: T.accent,
transition: "width .2s",
}}
/>
</div>
<div style={{ fontSize: 12, color: T.muted, marginTop: 8 }}>
{progress.total ? `${progress.done} von ${progress.total}` : "Archiv wird gelesen…"}
{progress.label ? ` — ${progress.label}` : ""}
</div>
</>
)}
{error && <div style={{ fontSize: 13, color: T.danger }}>{error}</div>}
{result && (
<div style={{ fontSize: 13, lineHeight: 1.7 }}>
<div>
<strong>{result.notes.length}</strong>{" "}
{result.notes.length === 1 ? "Notiz importiert" : "Notizen importiert"}
</div>
{result.flattenedDrawings > 0 && (
<div style={{ color: T.warning, marginTop: 10, fontSize: 12.5 }}>
{result.flattenedDrawings === 1
? "Eine Zeichnung kam als Bild zurück"
: `${result.flattenedDrawings} Zeichnungen kamen als Bilder zurück`}{" "}
— im Archiv liegen sie als PNG, und aus Pixeln lassen sich die Striche nicht zurückrechnen.
Sie sind sichtbar und exportierbar, aber nicht mehr mit dem Stift zu bearbeiten.
</div>
)}
{result.missingImages > 0 && (
<div style={{ color: T.warning, marginTop: 10, fontSize: 12.5 }}>
{result.missingImages} {result.missingImages === 1 ? "Bild lag" : "Bilder lagen"} nicht im
Archiv. Die Stelle in der Notiz bleibt erhalten, das Bild fehlt.
</div>
)}
{result.failed.length > 0 && (
<div style={{ color: T.danger, marginTop: 10, fontSize: 12.5 }}>
Nicht gelesen: {result.failed.join(", ")}
</div>
)}
</div>
)}
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 20 }}>
<button
onClick={onClose}
disabled={!done}
style={{
padding: "8px 20px",
borderRadius: 10,
border: "none",
background: done ? T.accent : T.track,
color: done ? "#fff" : T.muted,
cursor: done ? "pointer" : "default",
fontSize: 13,
fontWeight: 600,
}}
>
{done ? "Fertig" : "Läuft…"}
</button>
</div>
</div>
</div>
);
}
+162
View File
@@ -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 (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
backdropFilter: "blur(4px)",
}}
onClick={onClose}
>
<form
onSubmit={submit}
onClick={(e) => 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",
}}
>
<div
style={{
fontSize: 17,
fontWeight: 600,
color: T.text,
marginBottom: 14,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
{initialUrl ? "Link bearbeiten" : "Link einfügen"}
</div>
<input
ref={inputRef}
type="text"
aria-label="Link address"
value={url}
onChange={(e) => {
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",
}}
/>
<div style={{ minHeight: 18, marginTop: 6, fontSize: 11.5, color: error ? T.danger : T.muted }}>
{error || "http, https oder mailto"}
</div>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 12 }}>
{initialUrl && (
<button type="button" onClick={onRemove} style={{ ...button("danger"), marginRight: "auto" }}>
Link entfernen
</button>
)}
<button type="button" onClick={onClose} style={button()}>
Abbrechen
</button>
<button type="submit" style={button("primary")}>
{initialUrl ? "Ändern" : "Einfügen"}
</button>
</div>
</form>
</div>
);
}
+193
View File
@@ -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 (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 2000,
backdropFilter: "blur(4px)",
}}
onClick={onClose}
>
<div
onClick={(e) => 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",
}}
>
<div
style={{
fontSize: 17,
fontWeight: 600,
color: T.text,
marginBottom: 4,
fontFamily: "'Newsreader',Georgia,serif",
}}
>
{title}
</div>
<div style={{ fontSize: 12, color: T.muted, marginBottom: 14 }}>In welches Notizbuch soll es?</div>
<div
role="listbox"
aria-label="Notebook"
style={{ maxHeight: 280, overflowY: "auto", display: "flex", flexDirection: "column", gap: 4 }}
>
{notebooks.map((nb) => {
const active = nb.id === selected;
return (
<button
key={nb.id}
role="option"
aria-selected={active}
data-notebook-option={nb.id}
onClick={() => setSelected(nb.id)}
onDoubleClick={confirm}
style={{
display: "flex",
alignItems: "center",
gap: 10,
width: "100%",
padding: "9px 12px",
borderRadius: 10,
border: `1px solid ${active ? T.accent + "66" : "transparent"}`,
background: active ? T.accent + "1a" : T.row,
color: T.text,
cursor: "pointer",
fontSize: 13,
textAlign: "left",
}}
>
<span
style={{
width: 10,
height: 10,
borderRadius: 3,
flexShrink: 0,
background: COVERS[nb.cover % COVERS.length].bg,
}}
/>
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{nb.name}
</span>
{nb.id === remembered && (
<span style={{ fontSize: 10, color: T.muted }}>zuletzt benutzt</span>
)}
</button>
);
})}
</div>
<div style={{ display: "flex", gap: 10, justifyContent: "flex-end", marginTop: 18 }}>
<button
onClick={onClose}
style={{
padding: "8px 18px",
borderRadius: 10,
border: `1px solid ${T.border}`,
background: "transparent",
color: T.text,
cursor: "pointer",
fontSize: 13,
}}
>
Abbrechen
</button>
<button
onClick={confirm}
disabled={!selected}
style={{
padding: "8px 18px",
borderRadius: 10,
border: "none",
background: selected ? T.accent : T.border,
color: "#fff",
cursor: selected ? "pointer" : "default",
fontSize: 13,
fontWeight: 600,
}}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}
+72 -2
View File
@@ -470,6 +470,9 @@ export const I = {
<path d="M14 18v-6h1a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2z" />
</svg>
),
/* 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: (
<svg
width="16"
@@ -482,8 +485,75 @@ export const I = {
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="16 6 12 2 8 6" />
<line x1="12" y1="2" x2="12" y2="15" />
<polyline points="8 11 12 15 16 11" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
),
/* Reading a file in — the mirror of `export`, for the Markdown importer. */
import: (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="16 7 12 3 8 7" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
),
eye: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z" />
<circle cx="12" cy="12" r="3" />
</svg>
),
eyeOff: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<path d="M9.88 9.88a3 3 0 1 0 4.24 4.24" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
),
sort: (
<svg
width="15"
height="15"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="4" y1="7" x2="16" y2="7" />
<line x1="4" y1="12" x2="13" y2="12" />
<line x1="4" y1="17" x2="10" y2="17" />
<polyline points="17 14 20 17 23 14" />
<line x1="20" y1="17" x2="20" y2="7" />
</svg>
),
users: (
+70 -2
View File
@@ -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