A chapter with an order of its own

A chapter orders its own notes now, chosen in the same dialog as its name,
mark and colour -- one decision about one thing. The notebook's setting
governs the notebook's own list; a chapter is a place someone made and
filled, so a reference chapter can stay alphabetical while the notebook
around it stays as it was arranged. It starts hand-made, and a chapter put
in one of the other orders loses its lines and its handles exactly as the
notebook does: neither means anything against an order the app is choosing.

A line still would not go under another line, and the comparator was only
half of it. The write was then thrown away by the "did anything change?"
test, which sorted the separators before comparing them -- to ignore the
other chapters' lines riding along in the same field -- and a sorted
comparison cannot see a sequence. Two lines in one gap carry the same
position, so swapping them changes nothing else. It compares this list's own
lines, in order.

An open chapter swelled while rearranging: the strips at the two ends of its
list stood open for the whole of the mode, adding half a finger of empty
space inside every chapter. They are thin now, and only there while
something is actually being carried -- which is also why the suite presses
first and aims afterwards.

Server: sort_mode on chapters, validated against the same five modes as a
notebook's, and set outside the COALESCE so null means the hand-made order
rather than "leave it alone".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
marcandClaude Opus 5 committed 2026-08-17 16:45:05 +02:00
1 parent ea1bca5d25
commit 3b7958b713
11 files changed
+354 -94

No files matched your search

+38 -6
View File
@@ -474,6 +474,16 @@ if (!tableColumns("chapters").includes("color")) {
db.exec("ALTER TABLE chapters ADD COLUMN color TEXT");
}
/* A chapter orders its own notes.
*
* Its own column rather than the notebook's setting, because a chapter is a
* place someone made and filled: a reference chapter can stay alphabetical
* while the notebook around it stays in the order it was arranged. Null means
* the hand-made order, which is what a chapter is in until it is told otherwise. */
if (!tableColumns("chapters").includes("sort_mode")) {
db.exec("ALTER TABLE chapters ADD COLUMN sort_mode TEXT");
}
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)");
@@ -1759,16 +1769,19 @@ const cleanIcon = (value) => {
app.get("/api/chapters", authMiddleware, (req, res) => {
res.json(
db
.prepare("SELECT id, notebook_id, name, color, icon, sort_order FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at")
.prepare("SELECT id, notebook_id, name, color, icon, sort_order, sort_mode FROM chapters WHERE user_id = ? ORDER BY sort_order, created_at")
.all(req.userId)
);
});
app.post("/api/chapters", authMiddleware, (req, res) => {
const { notebookId, name, color, icon } = req.body || {};
const { notebookId, name, color, icon, sortMode } = 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" });
if (sortMode !== undefined && sortMode !== null && !SORT_MODES.includes(sortMode)) {
return res.status(400).json({ error: `sortMode must be one of ${SORT_MODES.join(", ")}` });
}
// 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.
@@ -1779,10 +1792,21 @@ app.post("/api/chapters", authMiddleware, (req, res) => {
.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, color, icon, sort_order, device_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
.run(id, req.userId, notebookId, label, cleanColor(color), cleanIcon(icon), order, req.deviceId);
db.prepare(
"INSERT INTO chapters (id, user_id, notebook_id, name, color, icon, sort_order, sort_mode, device_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)"
).run(
id,
req.userId,
notebookId,
label,
cleanColor(color),
cleanIcon(icon),
order,
sortMode ?? null,
req.deviceId
);
broadcastToUser(req.userId, { type: "chapter_created", data: { id, notebookId, name: label } }, req.deviceId);
res.json({ id, notebookId, name: label, sortOrder: order });
res.json({ id, notebookId, name: label, sortOrder: order, sortMode: sortMode ?? null });
});
app.post("/api/chapters/reorder", authMiddleware, (req, res) => {
@@ -1804,9 +1828,12 @@ app.post("/api/chapters/reorder", authMiddleware, (req, res) => {
});
app.put("/api/chapters/:id", authMiddleware, (req, res) => {
const { name, sortOrder, color, icon } = req.body || {};
const { name, sortOrder, color, icon, sortMode } = 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" });
if (sortMode !== undefined && sortMode !== null && !SORT_MODES.includes(sortMode)) {
return res.status(400).json({ error: `sortMode must be one of ${SORT_MODES.join(", ")}` });
}
let label;
if (name !== undefined) {
@@ -1826,6 +1853,11 @@ app.put("/api/chapters/:id", authMiddleware, (req, res) => {
if (icon !== undefined) {
db.prepare("UPDATE chapters SET icon = ? WHERE id = ?").run(cleanIcon(icon), req.params.id);
}
// Also outside the COALESCE: null here means the hand-made order, not "leave
// it as it was".
if (sortMode !== undefined) {
db.prepare("UPDATE chapters SET sort_mode = ? WHERE id = ?").run(sortMode ?? null, req.params.id);
}
broadcastToUser(req.userId, { type: "chapter_updated", data: { id: req.params.id, name: label } }, req.deviceId);
res.json({ success: true });
});