From 1757ec9af33ad8a152431dca5fc175cce54dbee0 Mon Sep 17 00:00:00 2001 From: marc Date: Wed, 12 Aug 2026 13:04:18 +0200 Subject: [PATCH] Chapters: folders inside a notebook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 28 ++++ backend/server.js | 156 +++++++++++++++++++- e2e/chapters.mjs | 123 ++++++++++++++++ e2e/organise.mjs | 36 ++++- e2e/run.sh | 2 +- frontend/src/App.jsx | 160 +++++++++++++++++--- frontend/src/features/notes/NoteList.jsx | 112 +++++++++++++- frontend/src/features/notes/noteSort.ts | 21 ++- frontend/src/hooks/useNoteData.ts | 104 ++++++++++--- frontend/src/offlineStore.ts | 35 ++++- frontend/src/syncEngine.ts | 178 ++++++++++++++++++++++- frontend/src/types.ts | 24 +++ frontend/src/ui/ContextMenu.jsx | 13 +- frontend/src/ui/NameDialog.jsx | 127 ++++++++++++++++ readme.md | 37 ++++- 15 files changed, 1078 insertions(+), 78 deletions(-) create mode 100644 e2e/chapters.mjs create mode 100644 frontend/src/ui/NameDialog.jsx diff --git a/CHANGELOG.md b/CHANGELOG.md index e5b4398..293d712 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,34 @@ not from the output of `docker push`. --- +## Unreleased + +**Added** +- **Chapters — folders inside a notebook.** A note is in one or in none; tapping + a chapter opens its own list, with a way back out. Removing a chapter never + removes what it held: the notes come back to the notebook, and the dialog says + so with the number affected. Separators now belong to the list they were drawn + in, so a line drawn in one chapter does not appear in another. + +**Changed** +- **A notebook's design is its own.** A 45-degree stripe texture was baked into + every cover at 1.5% white — meant to be imperceptible, visibly banding on the + darker gradients, and a second pattern fighting the one actually chosen. And + the same pair of rings sat in the middle of every notebook. The mark belongs + to the design now, and the designs whose pattern already fills the face wear + none. + +**Fixed** +- **A submenu was being clipped away on a wide screen.** The menu had been given + `overflow-y: auto`, which cuts off anything flying out sideways — so the list + of notebooks under *Move to…* was invisible and every click on it landed on + the note list behind. The side was also inverted: `right: 100%` places a panel + on the left. +- **A separator was effectively invisible on a phone** — a one-pixel hairline in + the border colour with faint grey text on near-black. + +--- + ## 2026-08-12 — Two-part covers, and an app that can be installed **Images** — web `sha256:1217b81b…`, server `sha256:3303d3dc…` diff --git a/backend/server.js b/backend/server.js index b8cbe21..75bc9cd 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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 diff --git a/e2e/chapters.mjs b/e2e/chapters.mjs new file mode 100644 index 0000000..9604b4a --- /dev/null +++ b/e2e/chapters.mjs @@ -0,0 +1,123 @@ +/* Chapters — folders inside a notebook. + * + * The claim the feature rests on is the one under test: a note filed into a + * chapter is *in* it and not also loose in the notebook, and removing a chapter + * never removes what it held. Both look fine on screen while being wrong on the + * server, so the server is asked directly. + */ +import { + api, + check, + createNote, + createNotebook, + launch, + report, + setTitle, + withFreshUser, +} from "./lib.mjs"; + +const browser = await launch(); +let session; +try { + session = await withFreshUser(browser, "chapters"); + const p = session.page; + + const titles = () => + p.locator("[data-note-row] [data-note-title]").evaluateAll((els) => els.map((e) => e.textContent.trim())); + const addChapter = async (name) => { + await p.locator("[data-add-chapter]").click(); + await p.waitForTimeout(600); + await p.getByLabel("Chapter name").fill(name); + await p.getByRole("button", { name: "Create", exact: true }).click(); + await p.waitForTimeout(2000); + }; + const moveTo = async (noteTitle, target) => { + await p.locator("[data-note-row]").filter({ hasText: noteTitle }).first().click({ button: "right", force: true }); + await p.waitForTimeout(600); + await p.locator("[data-submenu-toggle]").first().click(); + await p.waitForTimeout(500); + await p.locator("[data-submenu] button").filter({ hasText: target }).first().click(); + await p.waitForTimeout(2500); + }; + + await createNotebook(p, "Haus"); + await p.locator("button").filter({ hasText: "Haus" }).first().click(); + await p.waitForTimeout(700); + for (const name of ["Zählerstand", "Stromrechnung"]) { + await createNote(p); + await setTitle(p, name); + await p.waitForTimeout(600); + } + await p.waitForTimeout(1000); + + /* ── making one ── */ + check((await p.locator("[data-add-chapter]").count()) === 1, "a notebook can be given a chapter"); + await addChapter("Rechnungen"); + check((await p.locator("[data-chapter-row]").count()) === 1, "and it appears in the list"); + + const server = (await api(p, "GET", "/chapters")).body; + check(server.length === 1 && server[0].name === "Rechnungen", "the server has it", JSON.stringify(server)); + + /* ── filing a note ── */ + await moveTo("Stromrechnung", "Rechnungen"); + check( + (await titles()).join(",") === "Zählerstand", + "a note filed into a chapter leaves the notebook's own list", + (await titles()).join(", ") + ); + + await p.locator("[data-chapter-row]").first().click(); + await p.waitForTimeout(1200); + check((await titles()).join(",") === "Stromrechnung", "and is inside the chapter", (await titles()).join(", ")); + check((await p.locator("[data-chapter-back]").count()) === 1, "with a way back out"); + + const filed = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Stromrechnung"); + check(!!filed?.chapter_id, "the server filed it too", String(filed?.chapter_id)); + + /* A note made inside a chapter belongs to it, without being moved. */ + await createNote(p); + await setTitle(p, "Wasserrechnung"); + await p.waitForTimeout(1500); + const made = (await api(p, "GET", "/notes")).body.find((n) => n.title === "Wasserrechnung"); + check(made?.chapter_id === filed?.chapter_id, "a note made inside a chapter is filed there", String(made?.chapter_id)); + + await p.locator("[data-chapter-back]").click(); + await p.waitForTimeout(1200); + check( + (await titles()).join(",") === "Zählerstand", + "and going back shows the notebook again, without the chapter's notes", + (await titles()).join(", ") + ); + + /* ── a chapter is not a wastebasket ── + * Removing one must never remove what it held: the notes come back to the + * notebook, where they can be seen and filed again. */ + await p.locator("[data-chapter-row]").first().click({ button: "right", force: true }); + await p.waitForTimeout(600); + await p.getByText("Remove chapter", { exact: true }).click(); + await p.waitForTimeout(700); + check(/stay/i.test(await p.locator("body").innerText()), "removing one says out loud that the notes stay"); + await p.getByRole("button", { name: "Remove", exact: true }).click(); + await p.waitForTimeout(2500); + + check((await p.locator("[data-chapter-row]").count()) === 0, "the chapter is gone"); + const after = (await titles()).sort().join(","); + check( + after === "Stromrechnung,Wasserrechnung,Zählerstand", + "and every note it held is back in the notebook", + after + ); + const freed = (await api(p, "GET", "/notes")).body; + check( + freed.every((n) => !n.chapter_id), + "with nothing on the server still pointing at a chapter that is gone", + JSON.stringify(freed.map((n) => [n.title, n.chapter_id])) + ); +} catch (e) { + check(false, "suite ran to the end", e.message); +} finally { + await session?.cleanup(); + const code = report(); + await browser.close(); + process.exit(code); +} diff --git a/e2e/organise.mjs b/e2e/organise.mjs index 3de8a93..c329d52 100644 --- a/e2e/organise.mjs +++ b/e2e/organise.mjs @@ -134,13 +134,33 @@ try { ); /* 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"); + "last changed", or the two orders would fight each other. + + Asked of the timestamps rather than of the order on screen. Three notes made + seconds apart routinely share an `updated_at` second — the column has that + resolution — and then "which is newest" is a tie broken arbitrarily. The + check passed on luck for weeks and started failing on a run that was a + fraction faster. */ + const stamps = async () => + Object.fromEntries((await api(p, "GET", "/notes")).body.map((n) => [n.title, n.updated_at])); + + await sortBy("manual"); + await p.waitForTimeout(600); + const beforeStamps = await stamps(); + const order = await titles(); + await drag(rows.nth(2), rows.nth(0)); + const afterStamps = await stamps(); check( - (await titles())[0] === "Gamma", - "a dragged note did not become the most recently changed one", - (await titles()).join(", ") + Object.keys(beforeStamps).every((t) => beforeStamps[t] === afterStamps[t]), + "dragging a note does not mark it as changed", + `${JSON.stringify(beforeStamps)} → ${JSON.stringify(afterStamps)}` ); + check( + (await titles()).join(",") !== order.join(","), + "though it did move", + `${order.join(",")} → ${(await titles()).join(",")}` + ); + await sortBy("manual"); await p.waitForTimeout(800); @@ -160,6 +180,8 @@ try { ); /* ── separators ── */ + // The arrangement as it stands now: rows have been dragged since `arranged`. + const beforeSeparator = await titles(); await p.locator("[data-add-separator]").click(); await p.waitForTimeout(1800); check((await p.locator("[data-separator]").count()) === 1, "a separator can be added"); @@ -194,9 +216,9 @@ try { const withSeparator = await titles(); check( - withSeparator.join(",") === arranged.join(","), + withSeparator.join(",") === beforeSeparator.join(","), "and adding one did not disturb the arrangement", - `${arranged.join(",")} vs ${withSeparator.join(",")}` + `${beforeSeparator.join(",")} vs ${withSeparator.join(",")}` ); // Separators only mean something in a hand-made order; in "newest first" the diff --git a/e2e/run.sh b/e2e/run.sh index fbf9dcd..71969f8 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -13,7 +13,7 @@ cd "$(dirname "$0")" IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble" URL="${INKNOTE_URL:-http://localhost:3000}" SUITES=("$@") -[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise offline sync) +[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict pdf attachments-lifecycle organise chapters offline sync) mkdir -p shots if [ ! -d node_modules ]; then diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 362b8eb..e192758 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,6 +12,7 @@ import { DEFAULT_FOLDERS, coverBackground } from "./ui/constants.jsx"; import { I } from "./ui/icons.jsx"; import { makeTheme, FONT_UI } from "./ui/theme"; import { ConfirmDialog } from "./ui/ConfirmDialog.jsx"; +import { NameDialog } from "./ui/NameDialog.jsx"; import { ContextMenu } from "./ui/ContextMenu.jsx"; import { EditNotebookModal, CreateNotebookModal } from "./ui/NotebookModals.jsx"; import { AccountMenu } from "./ui/AccountMenu.jsx"; @@ -28,6 +29,7 @@ import { canReorder, sortModesFor, sortNotes, + separatorsFor, withNewSeparator, } from "./features/notes/noteSort"; import { EditorPane } from "./features/notes/EditorPane.jsx"; @@ -61,6 +63,10 @@ export default function App() { const [activeFolder, setActiveFolder] = useState("dashboard"); const [activeNote, setActiveNote] = useState(null); + /* Which chapter of the open notebook is being looked into; null is the + notebook itself. A chapter is a folder inside a notebook, so this is a + second step of "where the user is", not a second notebook. */ + const [activeChapter, setActiveChapter] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const [showNewNb, setShowNewNb] = useState(false); @@ -82,6 +88,8 @@ export default function App() { const [renamingSeparator, setRenamingSeparator] = useState(null); const [historyFor, setHistoryFor] = useState(null); const [ctxMenu, setCtxMenu] = useState(null); + // { title, value, onSave } while a chapter is being named or renamed. + const [namingChapter, setNamingChapter] = useState(null); const [confirmDialog, setConfirmDialog] = useState(null); // Bumped on a version restore to force a fresh editor on the restored body. const [restoreEpoch, setRestoreEpoch] = useState(0); @@ -101,8 +109,8 @@ export default function App() { whether a hand-made arrangement is even in effect. Notebooks keep the HTML5 hook: they are dragged with a mouse, in the sidebar and on the dashboard. */ const rearrangeNotes = useCallback( - (dragId, targetId) => data.rearrange(activeFolder, dragId, targetId), - [data, activeFolder] + (dragId, targetId) => data.rearrange(activeFolder, dragId, targetId, activeChapter), + [data, activeFolder, activeChapter] ); useEffect(() => sync.onSyncStatus(setSyncStatus), []); @@ -151,6 +159,7 @@ export default function App() { const openFolder = useCallback( (id) => { setActiveFolder(id); + setActiveChapter(null); setActiveNote(null); if (isMobile) { setSidebarOpen(false); @@ -186,6 +195,16 @@ export default function App() { /* ── Derived note lists ── */ const activeNotes = useMemo(() => notes.filter((n) => !n.trashed), [notes]); + /* Chapters of the notebook in hand, with how many notes each holds — the + count is what makes a chapter worth opening rather than a guess. */ + const folderChapters = useMemo(() => { + const list = data.chapters.filter((c) => c.notebookId === activeFolder); + return list.map((c) => ({ + ...c, + noteCount: notes.filter((n) => !n.trashed && n.chapterId === c.id).length, + })); + }, [data.chapters, activeFolder, notes]); + const openChapter = folderChapters.find((c) => c.id === activeChapter) || null; const trashedNotes = useMemo(() => notes.filter((n) => n.trashed), [notes]); const isTrashView = activeFolder === "trash"; @@ -211,14 +230,25 @@ export default function App() { /* ── Separators ── * Lines the user draws between notes. They belong to the notebook's own * arrangement, so they are stored on it and only ever shown in manual order. */ - const separators = activeNotebook?.separators || []; + const allSeparators = activeNotebook?.separators || []; + /* Only the ones drawn in the list being looked at — the notebook itself, or + the chapter that is open. */ + const separators = useMemo( + () => separatorsFor(allSeparators, activeChapter), + [allSeparators, activeChapter] + ); const writeSeparators = (next) => activeNotebook && void data.updateNotebook(activeNotebook.id, { separators: next }); + /* Written back whole: the notebook stores one list, and the ones belonging to + the other chapters have to survive a change made in this one. */ + const writeVisible = (visible) => + writeSeparators([...allSeparators.filter((s) => (s.chapterId ?? null) !== activeChapter), ...visible]); - const addSeparator = () => writeSeparators(withNewSeparator(separators, `sep-${Date.now().toString(36)}`)); + const addSeparator = () => + writeVisible(withNewSeparator(separators, `sep-${Date.now().toString(36)}`, "", activeChapter)); const renameSeparator = (id, label) => - writeSeparators(separators.map((s) => (s.id === id ? { ...s, label } : s))); - const removeSeparator = (id) => writeSeparators(separators.filter((s) => s.id !== id)); + writeSeparators(allSeparators.map((s) => (s.id === id ? { ...s, label } : s))); + const removeSeparator = (id) => writeSeparators(allSeparators.filter((s) => s.id !== id)); const separatorContextMenu = (e, separator) => { e.preventDefault(); @@ -254,7 +284,11 @@ export default function App() { if (activeFolder === "trash") list = trashedNotes; else if (activeFolder === "starred") list = activeNotes.filter((n) => n.starred); else if (activeFolder === "all" || activeFolder === "dashboard") list = activeNotes; - else list = activeNotes.filter((n) => n.folderId === activeFolder); + else if (activeChapter) list = activeNotes.filter((n) => n.chapterId === activeChapter); + /* In the notebook itself, a note filed into a chapter is *in* that chapter + and not loose here — otherwise it would appear twice and moving it would + look like it had done nothing. */ + else list = activeNotes.filter((n) => n.folderId === activeFolder && !n.chapterId); if (searchQuery.trim()) { // A search reads every note's text, so results keep the index's relevance @@ -268,7 +302,7 @@ export default function App() { return list.filter((n) => n.title.toLowerCase().includes(q)); } return sortNotes(list, sortMode); - }, [activeFolder, searchQuery, search.results, activeNotes, trashedNotes, sortMode]); + }, [activeFolder, activeChapter, 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 @@ -404,7 +438,11 @@ export default function App() { const createNote = () => withNotebook("New note", async (folderId) => { - const note = await data.createNote(folderId); + /* A note made while a chapter is open belongs to that chapter — but only + if it is a chapter of the notebook being written into, which it is not + when the notebook had to be asked for. */ + const chapterId = folderId === activeFolder ? activeChapter : null; + const note = await data.createNote(folderId, chapterId); if (VIRTUAL_FOLDERS.includes(activeFolder)) setActiveFolder(folderId); openNote(note.id); }); @@ -503,16 +541,86 @@ export default function App() { }); }; - /** "Move to..." lists every notebook except the one the note is already in. */ + /** + * "Move to..." — the other notebooks, and the chapters of this one. + * + * Both in one list because both answer the same question: where should this + * note live. A chapter of the notebook it is already in is one step; another + * notebook is another, and takes it out of whatever chapter it was in, since + * a chapter belongs to the book it is in. + */ const moveSubmenu = (note) => { - const targets = notebooks - .filter((nb) => nb.id !== note.folderId) - .map((nb) => ({ - label: nb.name, - colorDot: coverBackground(nb.cover, nb.coverPattern), - action: () => data.moveNote(note.id, nb.id), - })); - return targets.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : []; + const here = data.chapters.filter((c) => c.notebookId === note.folderId && c.id !== note.chapterId); + const targets = [ + ...(note.chapterId + ? [{ label: "The notebook itself", action: () => data.moveNote(note.id, note.folderId, null) }] + : []), + ...here.map((c) => ({ + label: c.name, + icon: I.books, + action: () => data.moveNote(note.id, note.folderId, c.id), + })), + ...(here.length || note.chapterId ? [{ divider: true }] : []), + ...notebooks + .filter((nb) => nb.id !== note.folderId) + .map((nb) => ({ + label: nb.name, + colorDot: coverBackground(nb.cover, nb.coverPattern), + action: () => data.moveNote(note.id, nb.id, null), + })), + ]; + // A lone divider is not a menu. + const usable = targets.filter((t) => !t.divider); + return usable.length > 0 ? [{ label: "Move to...", icon: I.move, submenu: targets }] : []; + }; + + /* ── Chapters ── + * Folders inside a notebook. Named on creation, because a chapter with no name + * is a row nobody can tell from the next one — unlike a separator, which is a + * line and reads perfectly well unnamed. */ + const addChapter = () => { + if (!activeNotebook) return; + setNamingChapter({ title: "New chapter", value: "", onSave: (name) => data.createChapter(activeNotebook.id, name) }); + }; + + const chapterContextMenu = (e, chapter) => { + e.preventDefault(); + e.stopPropagation(); + setCtxMenu({ + x: e.clientX, + y: e.clientY, + items: [ + { + label: "Rename", + icon: I.edit, + action: () => + setNamingChapter({ + title: "Rename chapter", + value: chapter.name, + onSave: (name) => data.renameChapter(chapter.id, name), + }), + }, + { divider: true }, + { + label: "Remove chapter", + icon: I.trash, + danger: true, + action: () => + setConfirmDialog({ + title: "Remove chapter?", + message: `"${chapter.name}" is removed. Its ${chapter.noteCount} note${ + chapter.noteCount === 1 ? "" : "s" + } stay — they go back to the notebook.`, + confirmLabel: "Remove", + danger: true, + onConfirm: () => { + if (activeChapter === chapter.id) setActiveChapter(null); + void data.deleteChapter(chapter.id); + }, + }), + }, + ], + }); }; const noteContextMenu = (e, note) => { @@ -663,6 +771,11 @@ export default function App() { onStartRenameSeparator={setRenamingSeparator} onRenameSeparator={renameSeparator} onSeparatorContextMenu={separatorContextMenu} + chapters={folderChapters} + openChapter={openChapter} + onOpenChapter={setActiveChapter} + onAddChapter={activeNotebook ? addChapter : null} + onChapterContextMenu={chapterContextMenu} onReorder={rearrangeNotes} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} onSelectNote={openNote} @@ -787,6 +900,17 @@ export default function App() { )} /> )} + {namingChapter && ( + setNamingChapter(null)} + dark={dark} + /> + )} {confirmDialog && ( onOpen(chapter.id)} + onContextMenu={(e) => onContextMenu(e, chapter)} + style={{ + display: "flex", + alignItems: "center", + gap: 9, + width: "100%", + padding: "10px 12px", + borderRadius: 10, + border: `1px solid ${T.borderLight}`, + background: "transparent", + cursor: "pointer", + marginBottom: 4, + textAlign: "left", + }} + > + {I.books} + + {chapter.name} + + {chapter.noteCount} + › + + ); +} + /** * How this notebook's notes are ordered. * @@ -370,6 +418,11 @@ export function NoteList({ onStartRenameSeparator, onRenameSeparator, onSeparatorContextMenu, + chapters, + openChapter, + onOpenChapter, + onAddChapter, + onChapterContextMenu, onReorder, onToggleSidebar, onSelectNote, @@ -383,6 +436,11 @@ export function NoteList({ browsers never fire from touch — on a phone the arrangement could not be changed at all, with nothing on screen to say why. */ const dnd = useReorderDrag(!!reorderable, onReorder); + /* Chapters divide a notebook, so they are offered in one — not in "All Notes" + or the trash, which are views over notebooks rather than a place to file + anything, and not inside a chapter, because a chapter holds notes. */ + const canAddChapter = !!onAddChapter && !openChapter; + const shownChapters = openChapter ? [] : chapters || []; return (
+ {/* Inside a chapter this is the way back out to the notebook — which + on a phone is the only way, since the chapter fills the screen. */}

- {title} + {openChapter ? openChapter.name : title}

@@ -490,7 +553,7 @@ export function NoteList({ onQueryChange(""); e.currentTarget.blur(); }} - placeholder={`Search in ${title}...`} + placeholder={`Search in ${openChapter ? openChapter.name : title}...`} aria-label="Search in this notebook" style={{ width: "100%", @@ -508,15 +571,40 @@ export function NoteList({ {/* Just the one control. The sentence that used to sit here explained the order the sort menu already names, on every visit, forever. */} - {reorderable && ( + {(reorderable || canAddChapter) && (
+ {/* A chapter divides the notebook; a separator divides a list. They + sit together because both are "add something that is not a note", + and apart from the + button, which makes notes. */} + {canAddChapter && ( + + )} + +
+
+
+ ); +} diff --git a/readme.md b/readme.md index bbd9ccc..49ae694 100644 --- a/readme.md +++ b/readme.md @@ -431,7 +431,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` | 50 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole | +| `organise` | 52 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole | +| `chapters` | 13 | a note filed into a chapter leaves the notebook's list, and removing the chapter gives every note back | | `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection | | `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it | @@ -940,6 +941,40 @@ rows behind. --- +## Chapters + +Folders inside a notebook. A note is in exactly one chapter, or in none — which +means the notebook itself. Chapters are listed above the loose notes; tapping one +opens its own list, and the menu button becomes a 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` on `notes.chapter_id`, and it is also written out explicitly +in the delete route — it is the promise the whole feature rests on, and it should +be visible where it happens rather than inferred from a schema. The dialog says +so too, with the number of notes affected. + +Three rules that keep the two levels honest: + +- **A note filed into a chapter leaves the notebook's own list.** Showing it in + both would mean filing it appeared to do nothing. +- **A chapter belongs to its notebook.** Filing a note into a chapter of a book + it is not in is refused by the server; moving a note to another notebook clears + its chapter, since the old one is in the book it just left. +- **Separators belong to the list they were drawn in.** A separator's `order` + counts the notes before it, which only means anything inside one list, so each + carries the chapter it belongs to — `null` for the notebook itself. The + notebook still stores one list of them, and a change made in one chapter writes + the others back untouched. + +A note made while a chapter is open is filed there without being moved. A chapter +is named when it is created, unlike a separator: a separator is a line and reads +perfectly well unnamed, while an unnamed chapter is a row nobody can tell from +the next one. + +--- + ## Sorting a notebook Each notebook remembers how its notes are ordered, and remembers it on the