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();