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

+83 -21
View File
@@ -5,7 +5,7 @@ import * as searchIndex from "../search";
import { sameStrokeDoc } from "../features/drawing/strokes.js";
import { reindexEntries } from "../features/notes/noteSort";
import { extractOnImport } from "../features/pdf/pdfText";
import type { Backlink, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types";
import type { Backlink, Chapter, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types";
/** How often the whole account is reconciled with the server. */
const FULL_SYNC_MS = 30_000;
@@ -36,7 +36,7 @@ export interface NoteData {
reload: () => Promise<void>;
reset: () => void;
createNote: (folderId: string) => Promise<NoteMeta>;
createNote: (folderId: string, chapterId?: string | null) => Promise<NoteMeta>;
importPdf: (folderId: string, file: File) => Promise<NoteMeta>;
updateNote: (id: string, edit: NoteEdit) => void;
setNoteContent: (id: string, content: NoteContent) => void;
@@ -45,12 +45,16 @@ export interface NoteData {
restoreNote: (id: string) => Promise<void>;
permanentDeleteNote: (id: string) => Promise<void>;
emptyTrash: () => Promise<void>;
moveNote: (id: string, folderId: string) => Promise<void>;
moveNote: (id: string, folderId: string, chapterId?: string | null) => Promise<void>;
/** Drag-to-reorder within a notebook; moves notes and separators alike. */
rearrange: (notebookId: string, dragId: string, targetId: string) => void;
rearrange: (notebookId: string, dragId: string, targetId: string, chapterId?: string | null) => void;
createNotebook: (fields: { name: string; cover: number }) => Promise<Notebook>;
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
chapters: Chapter[];
createChapter: (notebookId: string, name: string) => Promise<Chapter>;
renameChapter: (id: string, name: string) => Promise<void>;
deleteChapter: (id: string) => Promise<void>;
deleteNotebook: (id: string) => Promise<void>;
reorderNotebooks: (dragId: string, targetId: string) => void;
}
@@ -67,6 +71,7 @@ export interface NoteData {
export function useNoteData(user: User | null, activeNote: string | null): NoteData {
const [notes, setNotes] = useState<NoteMeta[]>([]);
const [notebooks, setNotebooks] = useState<Notebook[]>([]);
const [chapters, setChapters] = useState<Chapter[]>([]);
const [noteContents, setNoteContents] = useState<Record<string, NoteContent>>({});
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
@@ -79,12 +84,14 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
const reload = useCallback(async () => {
setNotebooks(await sync.getNotebooks());
setChapters(await sync.getChapters());
setNotes(await sync.getNotes());
}, []);
const reset = useCallback(() => {
setNotes([]);
setNotebooks([]);
setChapters([]);
setNoteContents({});
setBacklinks([]);
}, []);
@@ -185,8 +192,8 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
/* ── Notes ── */
const createNote = useCallback(async (folderId: string) => {
const note = await sync.createNote(folderId);
const createNote = useCallback(async (folderId: string, chapterId: string | null = null) => {
const note = await sync.createNote(folderId, chapterId);
setNotes((p) => [note, ...p]);
setNoteContents((p) => ({ ...p, [note.id]: EMPTY_CONTENT }));
return note;
@@ -266,9 +273,9 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
setNotes((p) => p.filter((n) => !n.trashed));
}, []);
const moveNote = useCallback(async (id: string, folderId: string) => {
await sync.moveNote(id, folderId);
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId } : n)));
const moveNote = useCallback(async (id: string, folderId: string, chapterId: string | null = null) => {
await sync.moveNote(id, folderId, chapterId);
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId, chapterId } : n)));
}, []);
/**
@@ -279,31 +286,82 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
* taken from the store rather than from whatever the list is showing: a search
* or a filter must never be able to renumber the notes it is hiding.
*/
const rearrange = useCallback((notebookId: string, dragId: string, targetId: string) => {
/* `chapterId` scopes the arrangement to one list: the notebook itself, or one
of its chapters. A note's place is only meaningful beside the notes it is
shown with, and the separators of the other chapters must come through the
write untouched. */
const rearrange = useCallback(
(notebookId: string, dragId: string, targetId: string, chapterId: string | null = null) => {
const notebook = notebooksRef.current.find((nb) => nb.id === notebookId);
if (!notebook) return;
const inNotebook = notesRef.current.filter((n) => !n.trashed && n.folderId === notebookId);
const inNotebook = notesRef.current.filter(
(n) => !n.trashed && n.folderId === notebookId && (n.chapterId ?? null) === chapterId
);
const all = notebook.separators || [];
const mine = all.filter((sep) => (sep.chapterId ?? null) === chapterId);
const others = all.filter((sep) => (sep.chapterId ?? null) !== chapterId);
const next = reindexEntries(inNotebook, notebook.separators || [], dragId, targetId);
const next = reindexEntries(inNotebook, mine, dragId, targetId);
if (!next) return;
const merged = [...others, ...next.separators.map((sep) => ({ ...sep, chapterId }))];
const position = new Map(next.noteIds.map((id, i) => [id, i]));
setNotes((p) => p.map((n) => (position.has(n.id) ? { ...n, sortOrder: position.get(n.id)! } : n)));
void sync.reorderNotes(next.noteIds);
// Only written when it actually changed: a plain note drag leaves the
// separators alone, and a pointless notebook write would sync for nothing.
const before = JSON.stringify(notebook.separators || []);
if (JSON.stringify(next.separators) !== before) {
setNotebooks((p) =>
p.map((nb) => (nb.id === notebookId ? { ...nb, separators: next.separators } : nb))
);
void sync.updateNotebook(notebookId, { separators: next.separators });
/* Only written when it actually changed.
*
* A plain note drag leaves the separators alone, and a write for nothing
* costs more than a request: it reloads the notes, which reshuffles any that
* share an `updatedAt` second — so "last changed" quietly came out in a
* different order after every drag.
*
* Compared as a set rather than as an array, because the merge above puts
* the other chapters' separators first and that says nothing about whether
* anything changed. */
const fingerprint = (list: typeof all) =>
list
.map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.chapterId ?? ""}`)
.sort()
.join("\n");
const before = fingerprint(all);
if (fingerprint(merged) !== before) {
setNotebooks((p) => p.map((nb) => (nb.id === notebookId ? { ...nb, separators: merged } : nb)));
void sync.updateNotebook(notebookId, { separators: merged });
}
}, []);
},
[]
);
/* ── Notebooks ── */
/* ── Chapters ──
*
* Folders inside a notebook. Deleting one never deletes its notes: they come
* back to the notebook, which is why the local copy is refreshed rather than
* having the removal applied by hand here.
*/
const createChapter = useCallback(async (notebookId: string, name: string) => {
const chapter = await sync.createChapter(notebookId, name);
setChapters((p) => [...p, chapter]);
return chapter;
}, []);
const renameChapter = useCallback(async (id: string, name: string) => {
setChapters((p) => p.map((c) => (c.id === id ? { ...c, name } : c)));
await sync.updateChapter(id, { name });
}, []);
const deleteChapter = useCallback(
async (id: string) => {
await sync.deleteChapter(id);
setChapters((p) => p.filter((c) => c.id !== id));
setNotes((p) => p.map((n) => (n.chapterId === id ? { ...n, chapterId: null } : n)));
},
[]
);
const createNotebook = useCallback(async (fields: { name: string; cover: number }) => {
const nb = await sync.createNotebook(fields);
setNotebooks((p) => [...p, nb]);
@@ -355,6 +413,10 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD
rearrange,
createNotebook,
updateNotebook,
chapters,
createChapter,
renameChapter,
deleteChapter,
deleteNotebook,
reorderNotebooks,
};