import { useCallback, useEffect, useRef, useState } from "react"; import * as sync from "../syncEngine"; import * as searchIndex from "../search"; import { sameStrokeDoc } from "../features/drawing/strokes.js"; import { placeOnTop, reindexEntries } from "../features/notes/noteSort"; import type { Arrangement } from "../features/notes/noteSort"; import { extractOnImport } from "../features/pdf/pdfText"; import type { Backlink, Chapter, NoteContent, NoteMeta, Notebook, Separator, StrokeDoc, User, } from "../types"; /** How often the whole account is reconciled with the server. */ const FULL_SYNC_MS = 30_000; /** How often the open note is re-read, so a second device's edits show up. */ const ACTIVE_NOTE_POLL_MS = 6_000; /** After this long without a body arriving, show an empty editor rather than a spinner. */ const CONTENT_TIMEOUT_MS = 5_000; const EMPTY_CONTENT: NoteContent = { content: "", drawingData: null, drawingStrokes: null }; export interface NoteEdit { title?: string; content?: string; /** Ids of notes and notebooks the body links to; stored server-side for backlinks. */ links?: string[]; /** Ids of attachments the body embeds, so unused ones can eventually be freed. */ attachments?: string[]; drawingStrokes?: StrokeDoc | null; } export interface NoteData { notes: NoteMeta[]; notebooks: Notebook[]; noteContents: Record; /** Notes linking to the currently open one. */ backlinks: Backlink[]; reload: () => Promise; reset: () => void; createNote: (folderId: string, chapterId?: string | null) => Promise; importPdf: (folderId: string, file: File) => Promise; updateNote: (id: string, edit: NoteEdit) => void; setNoteContent: (id: string, content: NoteContent) => void; toggleStar: (id: string) => Promise; /** The note's own mark beside its title, and the colour it is drawn in. */ setNoteIcon: (id: string, icon: string | null, iconColor: string | null) => Promise; trashNote: (id: string) => Promise; restoreNote: (id: string) => Promise; permanentDeleteNote: (id: string) => Promise; emptyTrash: () => Promise; moveNote: (id: string, folderId: string, chapterId?: string | null) => Promise; /** Drag-to-reorder within a notebook; moves notes and separators alike. */ rearrange: (notebookId: string, dragId: string, targetId: string, chapterId?: string | null) => void; createNotebook: (fields: { name: string; cover: number }) => Promise; updateNotebook: (id: string, updates: Partial) => Promise; chapters: Chapter[]; createChapter: ( notebookId: string, name: string, color?: string | null, icon?: string | null ) => Promise; /** Name, colour, or both — one write for one decision. */ editChapter: (id: string, updates: { name?: string; color?: string | null }) => Promise; deleteChapter: (id: string) => Promise; deleteNotebook: (id: string) => Promise; reorderNotebooks: (dragId: string, targetId: string) => void; } /** * Owns everything the app knows about notes and notebooks: the local copy, the * mutations that change it, and the background work that keeps it current. * * Every mutation writes locally first and hands the change to the sync engine, * so the UI stays responsive and offline edits are never lost. Confirmation * prompts are deliberately *not* here — destructive intent is a question for * the interface, not the store. */ export function useNoteData(user: User | null, activeNote: string | null): NoteData { const [notes, setNotes] = useState([]); const [notebooks, setNotebooks] = useState([]); const [chapters, setChapters] = useState([]); const [noteContents, setNoteContents] = useState>({}); const [backlinks, setBacklinks] = useState([]); const notesRef = useRef(notes); notesRef.current = notes; const notebooksRef = useRef(notebooks); notebooksRef.current = notebooks; const chaptersRef = useRef(chapters); chaptersRef.current = chapters; const contentsRef = useRef(noteContents); contentsRef.current = noteContents; const reload = useCallback(async () => { setNotebooks(await sync.getNotebooks()); setChapters(await sync.getChapters()); setNotes(await sync.getNotes()); }, []); const reset = useCallback(() => { setNotes([]); setNotebooks([]); setChapters([]); setNoteContents({}); setBacklinks([]); }, []); /* ── Background upkeep ── */ // The sync engine tells us when it has written to IndexedDB behind our back. // Loaded bodies are kept as they are: replacing them here would make the // editor flicker on every poll. useEffect(() => sync.onDataChange(reload), [reload]); useEffect(() => { const i = setInterval(() => { if (sync.isOnline()) void sync.fullSync().then(reload).catch(noop); }, FULL_SYNC_MS); return () => clearInterval(i); }, [reload]); /* ── The open note's body ── */ const hasContent = activeNote != null && noteContents[activeNote] !== undefined; // Fetched on demand: on a cold start every body is missing, and loading them // all up front would be slow and mostly wasted. useEffect(() => { if (!activeNote || hasContent) return; let cancelled = false; sync .loadNoteContent(activeNote) .then((c) => { if (cancelled) return; setNoteContents((p) => ({ ...p, [activeNote]: c })); const title = notesRef.current.find((n) => n.id === activeNote)?.title || ""; searchIndex.setEntry(activeNote, title, c.content); }) .catch(() => { if (!cancelled) setNoteContents((p) => ({ ...p, [activeNote]: EMPTY_CONTENT })); }); return () => { cancelled = true; }; }, [activeNote, hasContent]); // Hard fallback: an unreachable server must not leave the editor stuck. useEffect(() => { if (!activeNote || hasContent) return; const t = setTimeout(() => { setNoteContents((p) => (p[activeNote] ? p : { ...p, [activeNote]: EMPTY_CONTENT })); }, CONTENT_TIMEOUT_MS); return () => clearTimeout(t); }, [activeNote, hasContent]); // Keep the open note fresh across devices. useEffect(() => { if (!user || !activeNote) return; const i = setInterval(() => { if (!sync.isOnline()) return; // Never adopt a response while this note has writes on the way out: it // was requested before them and would undo what was just typed. if (sync.hasPendingWrites(activeNote)) return; sync .loadNoteContent(activeNote, { forceRemote: true }) .then((c) => { setNoteContents((p) => { const prev = p[activeNote]; if ( prev && prev.content === c.content && prev.drawingData === c.drawingData && sameStrokeDoc(prev.drawingStrokes, c.drawingStrokes) ) return p; return { ...p, [activeNote]: c }; }); }) .catch(noop); }, ACTIVE_NOTE_POLL_MS); return () => clearInterval(i); }, [user, activeNote]); const activeBody = activeNote ? noteContents[activeNote]?.content : undefined; useEffect(() => { if (!activeNote || !user) { setBacklinks([]); return; } let cancelled = false; sync .getBacklinks(activeNote) .then((r) => { if (!cancelled) setBacklinks(r); }) .catch(noop); return () => { cancelled = true; }; }, [activeNote, user, activeBody]); /* ── Notes ── */ const updateNote = useCallback((id: string, edit: NoteEdit) => { if (edit.title !== undefined) { const title = edit.title; const updatedAt = new Date().toISOString(); setNotes((p) => p.map((n) => (n.id === id ? { ...n, title, updatedAt } : n))); void sync.updateNoteTitle(id, title); searchIndex.setEntry(id, title, contentsRef.current[id]?.content); } if (edit.content !== undefined) { const content = edit.content; setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), content } })); void sync.updateNoteContent(id, content, edit.links, edit.attachments); searchIndex.setEntry(id, notesRef.current.find((n) => n.id === id)?.title || "", content); } if (edit.drawingStrokes !== undefined) { const drawingStrokes = edit.drawingStrokes; setNoteContents((p) => ({ ...p, [id]: { ...(p[id] ?? EMPTY_CONTENT), drawingStrokes } })); void sync.updateNoteDrawing(id, drawingStrokes); } }, []); /** Replaces a body wholesale — used when restoring an older version. */ const setNoteContent = useCallback((id: string, content: NoteContent) => { setNoteContents((p) => ({ ...p, [id]: content })); }, []); const setNoteIcon = useCallback(async (id: string, icon: string | null, iconColor: string | null) => { setNotes((p) => p.map((n) => (n.id === id ? { ...n, icon, iconColor } : n))); await sync.updateNoteIcon(id, icon, iconColor); }, []); const toggleStar = useCallback(async (id: string) => { const starred = await sync.toggleNoteStar(id); setNotes((p) => p.map((n) => (n.id === id ? { ...n, starred } : n))); }, []); const trashNote = useCallback(async (id: string) => { await sync.trashNote(id); setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: true } : n))); }, []); const restoreNote = useCallback(async (id: string) => { await sync.restoreNoteLocal(id); setNotes((p) => p.map((n) => (n.id === id ? { ...n, trashed: false } : n))); }, []); const permanentDeleteNote = useCallback(async (id: string) => { await sync.permanentDeleteNoteLocal(id); searchIndex.removeEntry(id); setNotes((p) => p.filter((n) => n.id !== id)); }, []); const emptyTrash = useCallback(async () => { await sync.emptyTrashLocal(); for (const n of notesRef.current) if (n.trashed) searchIndex.removeEntry(n.id); setNotes((p) => p.filter((n) => !n.trashed)); }, []); 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))); }, []); /** * Everything one list is made of: its notes, its lines, its chapters. * * `chapterId` scopes it to one list — the notebook itself, or one of its * chapters. A row's place is only meaningful beside the rows it is shown with, * and the separators of the other chapters have to come through a write * untouched. The notes are taken from the store rather than from whatever the * list is showing: a search must never be able to renumber what it is hiding. * * `extra` is a note that exists but has not reached the store yet — the one * just created. Building the list without it would renumber the notebook * around a row that is already on screen. */ const listOf = useCallback((notebookId: string, chapterId: string | null, extra?: NoteMeta) => { const notebook = notebooksRef.current.find((nb) => nb.id === notebookId); if (!notebook) return null; const known = extra ? [extra, ...notesRef.current.filter((n) => n.id !== extra.id)] : notesRef.current; const notes = known.filter( (n) => !n.trashed && n.folderId === notebookId && (n.chapterId ?? null) === chapterId ); const all = notebook.separators || []; return { notes, all, mine: all.filter((sep) => (sep.chapterId ?? null) === chapterId), others: all.filter((sep) => (sep.chapterId ?? null) !== chapterId), /* Chapters are rows in this list too, and only in the notebook itself — a chapter has no chapters of its own. */ chapters: chapterId ? [] : chaptersRef.current.filter((c) => c.notebookId === notebookId), }; }, []); /** Writes an arrangement back — to the screen at once, to the server behind it. */ const applyArrangement = useCallback( ( notebookId: string, chapterId: string | null, all: Separator[], others: Separator[], next: Arrangement ) => { const merged = [...others, ...next.separators.map((sep) => ({ ...sep, chapterId }))]; const position = new Map(next.notes.map((n) => [n.id, n.order])); setNotes((p) => p.map((n) => (position.has(n.id) ? { ...n, sortOrder: position.get(n.id)! } : n))); void sync.reorderNotes(next.notes); if (next.chapters.length) { const chapterAt = new Map(next.chapters.map((c) => [c.id, c.order])); setChapters((p) => p.map((c) => (chapterAt.has(c.id) ? { ...c, sortOrder: chapterAt.get(c.id)! } : c))); void sync.reorderChapters(next.chapters); } /* 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 = (seps: Separator[]) => seps .map((sep) => `${sep.id}|${sep.label}|${sep.order}|${sep.chapterId ?? ""}`) .sort() .join("\n"); if (fingerprint(merged) !== fingerprint(all)) { setNotebooks((p) => p.map((nb) => (nb.id === notebookId ? { ...nb, separators: merged } : nb))); void sync.updateNotebook(notebookId, { separators: merged }); } }, [] ); /** * Puts one row under the one it was dropped on, in the user's own order. * * Notes, lines and chapters move the same way, so this takes all three and * writes all three back. */ const rearrange = useCallback( (notebookId: string, dragId: string, targetId: string, chapterId: string | null = null) => { const list = listOf(notebookId, chapterId); if (!list) return; const next = reindexEntries(list.notes, list.mine, dragId, targetId, list.chapters); if (!next) return; applyArrangement(notebookId, chapterId, list.all, list.others, next); }, [applyArrangement, listOf] ); /** * Puts a freshly made note at the top of the list it was made in. * * A new note used to be given no position at all, and the sort put it near the * top by age. That is not the same thing: a separator's position counts the * rows above it, so a row that appears without anybody being renumbered leaves * every line one row too high — and each new note pulled the notebook's * arrangement further apart. Renumbering the list once, here, keeps every * other row exactly where it was and puts the new one above all of them, * lines and chapters included. */ const putOnTop = useCallback( (note: NoteMeta) => { if (!note.folderId) return; const chapterId = note.chapterId ?? null; const list = listOf(note.folderId, chapterId, note); if (!list) return; const next = placeOnTop(list.notes, list.mine, list.chapters, note.id); if (!next) return; applyArrangement(note.folderId, chapterId, list.all, list.others, next); }, [applyArrangement, listOf] ); 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 })); putOnTop(note); return note; }, [putOnTop] ); /** * Uploads a PDF and creates the note that holds it. * * Not queued for offline: the file has to reach the server before a note can * point at it, and a note referring to bytes nobody has would be worse than * being told to try again with a connection. */ const importPdf = useCallback( async (folderId: string, file: File) => { const attachment = await sync.uploadPdf(file, file.name); const title = file.name.replace(/\.pdf$/i, "") || "Dokument"; const note = await sync.createPdfNote(folderId, title, attachment.id); setNotes((p) => [note, ...p]); putOnTop(note); /* Read the document here, now, on the machine that already has it open. * * Waiting until someone opens it means a filed invoice may never be read * at all, and until it is, no device can find it. Not awaited: the note * exists and should appear at once — the text catches up. */ void extractOnImport(file, attachment.id); return note; }, [putOnTop] ); /* ── 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, color: string | null = null, icon: string | null = null) => { const chapter = await sync.createChapter(notebookId, name, color, icon); setChapters((p) => [...p, chapter]); return chapter; }, [] ); /* Name and colour go the same way, in one write. * * They used to be two calls because they came from two places — a dialog and * a submenu — and renaming a chapter while recolouring it then meant two * requests for one decision. */ const editChapter = useCallback(async (id: string, updates: { name?: string; color?: string | null }) => { setChapters((p) => p.map((c) => (c.id === id ? { ...c, ...updates } : c))); await sync.updateChapter(id, updates); }, []); 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]); return nb; }, []); const updateNotebook = useCallback(async (id: string, updates: Partial) => { await sync.updateNotebook(id, updates); setNotebooks((p) => p.map((nb) => (nb.id === id ? { ...nb, ...updates } : nb))); }, []); /** Deleting a notebook trashes its notes rather than destroying them. */ const deleteNotebook = useCallback(async (id: string) => { await sync.deleteNotebookLocal(id); setNotes((p) => p.map((n) => (n.folderId === id ? { ...n, trashed: true } : n))); setNotebooks((p) => p.filter((nb) => nb.id !== id)); }, []); const reorderNotebooks = useCallback((dragId: string, targetId: string) => { const list = [...notebooksRef.current]; const from = list.findIndex((n) => n.id === dragId); const to = list.findIndex((n) => n.id === targetId); if (from < 0 || to < 0 || from === to) return; const [moved] = list.splice(from, 1); if (!moved) return; list.splice(to, 0, moved); const reordered = list.map((n, i) => ({ ...n, sortOrder: i })); setNotebooks(reordered); void sync.reorderNotebooks(reordered.map((n) => n.id)); }, []); return { notes, notebooks, noteContents, backlinks, reload, reset, createNote, importPdf, updateNote, setNoteContent, toggleStar, setNoteIcon, trashNote, restoreNote, permanentDeleteNote, emptyTrash, moveNote, rearrange, createNotebook, updateNotebook, chapters, createChapter, editChapter, deleteChapter, deleteNotebook, reorderNotebooks, }; } const noop = () => {};