Chapters: folders inside a notebook

A note is in exactly one chapter, or in none — which means the notebook
itself. Chapters sit above the loose notes; tapping one opens its own
list and the menu button becomes the way back out, which on a phone is
the only way, since the list fills the screen.

A chapter is not a wastebasket. Removing one never removes what it held:
the notes come back to the notebook, where they can be seen and filed
again. That is ON DELETE SET NULL, and it is also written out in the
delete route, because it is the promise the whole thing rests on and
should be visible where it happens rather than inferred from a schema.
The dialog says so too, with the number of notes affected, and the suite
removes a chapter holding three notes and asks the server for all three.

Three rules keep the two levels honest. A note filed into a chapter
leaves the notebook's own list, or filing it would appear to do nothing.
A chapter belongs to its notebook, so the server refuses to file a note
into a chapter of a book it is not in, and moving a note to another
notebook clears its chapter. And separators belong to the list they were
drawn in: a separator's order counts the notes before it, which means
nothing across lists.

Two bugs found while building it, both a day old and both affecting the
desktop rather than the phone they were written for:

- A submenu was clipped away. The context menu had been given
  `overflow-y: auto`, which cuts off anything flying out sideways — so
  the notebooks under "Move to..." were invisible and every click on them
  landed on the note list behind. The side was inverted too:
  `right: 100%` places a panel on the left.
- A separator was a one-pixel hairline in the border colour with faint
  grey text on near-black. Present, and on a phone effectively invisible.

And one test that never measured what it claimed. It asked whether a
dragged note had become "the most recently changed" by reading the order
on screen — but three notes made seconds apart share an `updated_at`
second, so that order is a tie broken arbitrarily. It passed on luck for
weeks and fell over on a run a fraction faster. It now compares the
timestamps themselves, before and after the drag.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-12 13:04:18 +02:00
1 parent e037f01dc2
commit 1757ec9af3
15 files changed
+1078 -78

No files matched your search

+150 -6
View File
@@ -422,6 +422,36 @@ if (!tableColumns("users").includes("preferences")) {
*
* On `attachments` rather than in a table of its own: it is a property of the
* file, it is deduplicated with the file, and it is freed with the file. */
/**
* Chapters — folders inside a notebook.
*
* A note sits in exactly one chapter, or in none, which is the notebook itself.
* `ON DELETE SET NULL` is the whole design in one clause: removing a chapter
* must never take notes with it. They come back to the notebook, where they can
* be seen and filed again.
*
* Separate from notebooks rather than a self-reference on them: a notebook has a
* cover, a sort order and separators, none of which a chapter has or wants.
*/
db.exec(`
CREATE TABLE IF NOT EXISTS chapters (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
notebook_id TEXT NOT NULL REFERENCES notebooks(id) ON DELETE CASCADE,
name TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
device_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_chapters_notebook ON chapters(notebook_id);
`);
if (!tableColumns("notes").includes("chapter_id")) {
db.exec("ALTER TABLE notes ADD COLUMN chapter_id TEXT REFERENCES chapters(id) ON DELETE SET NULL");
db.exec("CREATE INDEX IF NOT EXISTS idx_notes_chapter ON notes(chapter_id)");
}
/* The pattern laid over a notebook's cover, chosen apart from its colour.
* Its own column rather than more values in `cover_index`: they are two
* decisions, and folding them into one number would mean renumbering every
@@ -888,6 +918,10 @@ function cleanSeparators(input) {
id: s.id.slice(0, 64),
label: typeof s.label === "string" ? s.label.slice(0, MAX_SEPARATOR_LABEL) : "",
order: Number.isFinite(Number(s.order)) ? Math.max(0, Math.round(Number(s.order))) : 0,
/* Which list it was drawn in — a chapter of the notebook, or the notebook
* itself. `order` counts the notes before it, and that only means anything
* within one list. */
chapterId: typeof s.chapterId === "string" && s.chapterId ? s.chapterId.slice(0, 64) : null,
}));
}
@@ -1025,7 +1059,7 @@ function isBigShrink(previous, next) {
/* ─── Note routes ─── */
app.get("/api/notes", authMiddleware, (req, res) => {
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, sort_order, created_at, updated_at, version, pdf_attachment_id FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId);
const notes = db.prepare("SELECT id, user_id, notebook_id, chapter_id, type, title, starred, sort_order, created_at, updated_at, version, pdf_attachment_id FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId);
res.json(notes);
});
@@ -1106,13 +1140,25 @@ app.post("/api/notes/reorder", authMiddleware, (req, res) => {
});
app.post("/api/notes", authMiddleware, (req, res) => {
const { type, title, content, notebookId, starred, pdfAttachmentId } = req.body;
const { type, title, content, notebookId, chapterId, starred, pdfAttachmentId } = req.body;
const id = uuid();
const encContent = content ? encrypt(content) : null;
db.prepare(`INSERT INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, pdf_attachment_id, device_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(id, req.userId, notebookId, type || "text", title || "Untitled", encContent, starred ? 1 : 0, pdfAttachmentId || null, req.deviceId);
/* A note made while a chapter is open belongs to that chapter. Checked
* against the notebook, so it cannot be filed into a chapter of another. */
let chapter = null;
if (chapterId) {
const found = db.prepare("SELECT notebook_id FROM chapters WHERE id = ? AND user_id = ?").get(chapterId, req.userId);
if (!found) return res.status(404).json({ error: "Chapter not found" });
if (found.notebook_id !== notebookId) {
return res.status(400).json({ error: "That chapter is in a different notebook" });
}
chapter = chapterId;
}
db.prepare(`INSERT INTO notes (id, user_id, notebook_id, chapter_id, type, title, content_encrypted, starred, pdf_attachment_id, device_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
.run(id, req.userId, notebookId, chapter, type || "text", title || "Untitled", encContent, starred ? 1 : 0, pdfAttachmentId || null, req.deviceId);
db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, device_id) VALUES (?, 'note', ?, 'create', ?)")
.run(req.userId, id, req.deviceId);
@@ -1154,10 +1200,33 @@ const replaceNoteAttachments = db.transaction((noteId, ids) => {
});
app.put("/api/notes/:id", authMiddleware, (req, res) => {
const { title, content, notebookId, starred, baseUpdatedAt, links, attachments } = req.body;
const { title, content, notebookId, chapterId, starred, baseUpdatedAt, links, attachments } = req.body;
const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!note) return res.status(404).json({ error: "Not found" });
/* Which chapter the note is filed in. `null` means the notebook itself, so it
* is meaningful and has to travel — which is why this cannot ride on COALESCE
* like the fields above, where undefined and null mean the same thing.
*
* A chapter belongs to a notebook, so the two have to agree: filing a note
* into a chapter of a book it is not in would leave it in a place that cannot
* be reached from either. */
let chapterUpdate;
if (chapterId !== undefined) {
if (chapterId === null) chapterUpdate = null;
else {
const chapter = db
.prepare("SELECT notebook_id FROM chapters WHERE id = ? AND user_id = ?")
.get(chapterId, req.userId);
if (!chapter) return res.status(404).json({ error: "Chapter not found" });
const target = notebookId !== undefined && notebookId !== null ? notebookId : note.notebook_id;
if (chapter.notebook_id !== target) {
return res.status(400).json({ error: "That chapter is in a different notebook" });
}
chapterUpdate = chapterId;
}
}
const conflict = isStaleWrite(note.updated_at, baseUpdatedAt);
const encContent = content ? encrypt(content) : note.content_encrypted;
@@ -1186,6 +1255,14 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => {
WHERE id = ?`)
.run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, req.deviceId, req.params.id);
if (chapterId !== undefined) {
db.prepare("UPDATE notes SET chapter_id = ? WHERE id = ?").run(chapterUpdate, req.params.id);
} else if (notebookId !== undefined && notebookId !== null && notebookId !== note.notebook_id) {
// Moved to another notebook without naming a chapter: the old chapter is in
// the book it left, so keeping it would file the note somewhere invisible.
db.prepare("UPDATE notes SET chapter_id = NULL WHERE id = ?").run(req.params.id);
}
if (Array.isArray(links)) replaceNoteLinks(req.userId, req.params.id, links);
if (Array.isArray(attachments)) replaceNoteAttachments(req.params.id, attachments);
@@ -1589,6 +1666,73 @@ app.get("/api/attachments/:id", authMiddleware, (req, res) => {
res.end(bytes);
});
/* ─── Chapters ───
*
* Folders inside a notebook. A note is in one or in none; deleting a chapter
* hands its notes back to the notebook rather than taking them with it.
*/
const MAX_CHAPTER_NAME = 120;
app.get("/api/chapters", authMiddleware, (req, res) => {
res.json(
db
.prepare("SELECT id, notebook_id, name, sort_order FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at")
.all(req.userId)
);
});
app.post("/api/chapters", authMiddleware, (req, res) => {
const { notebookId, name } = req.body || {};
const label = String(name ?? "").trim();
if (!label) return res.status(400).json({ error: "name required" });
if (label.length > MAX_CHAPTER_NAME) return res.status(400).json({ error: "name is too long" });
// The notebook has to be this user's — otherwise a chapter could be hung off
// someone else's, and every note filed into it would follow.
const nb = db.prepare("SELECT id FROM notebooks WHERE id = ? AND user_id = ?").get(notebookId, req.userId);
if (!nb) return res.status(404).json({ error: "Notebook not found" });
const order = db
.prepare("SELECT COUNT(*) AS n FROM chapters WHERE notebook_id = ?")
.get(notebookId).n;
const id = uuid();
db.prepare("INSERT INTO chapters (id, user_id, notebook_id, name, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?)")
.run(id, req.userId, notebookId, label, order, req.deviceId);
broadcastToUser(req.userId, { type: "chapter_created", data: { id, notebookId, name: label } }, req.deviceId);
res.json({ id, notebookId, name: label, sortOrder: order });
});
app.put("/api/chapters/:id", authMiddleware, (req, res) => {
const { name, sortOrder } = req.body || {};
const chapter = db.prepare("SELECT id FROM chapters WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!chapter) return res.status(404).json({ error: "Not found" });
let label;
if (name !== undefined) {
label = String(name ?? "").trim();
if (!label) return res.status(400).json({ error: "name required" });
if (label.length > MAX_CHAPTER_NAME) return res.status(400).json({ error: "name is too long" });
}
db.prepare("UPDATE chapters SET name = COALESCE(?, name), sort_order = COALESCE(?, sort_order), updated_at = datetime('now'), device_id = ? WHERE id = ?")
.run(label ?? null, sortOrder ?? null, req.deviceId, req.params.id);
broadcastToUser(req.userId, { type: "chapter_updated", data: { id: req.params.id, name: label } }, req.deviceId);
res.json({ success: true });
});
app.delete("/api/chapters/:id", authMiddleware, (req, res) => {
const chapter = db.prepare("SELECT id FROM chapters WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
if (!chapter) return res.status(404).json({ error: "Not found" });
/* The notes are not deleted with it. Written out rather than left to the
* foreign key, because it is the promise the feature rests on and it should
* be visible here. */
const freed = db.prepare("UPDATE notes SET chapter_id = NULL WHERE chapter_id = ? AND user_id = ?")
.run(req.params.id, req.userId).changes;
db.prepare("DELETE FROM chapters WHERE id = ?").run(req.params.id);
broadcastToUser(req.userId, { type: "chapter_deleted", data: { id: req.params.id } }, req.deviceId);
res.json({ success: true, notesFreed: freed });
});
/* ─── The text inside a PDF ───
*
* Extracted by the device that has the file, never here. The server has no PDF