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