TipTap editor, version history, search, attachments — and a cleanup
The app is meant to run in daily private use now, so this pass was mostly
about the ways it could quietly lose data.
Reliability and security:
- the server no longer invents a JWT_SECRET or ENCRYPTION_KEY when one is
missing; it refuses to start. The old fallback silently made every stored
note unreadable after a restart.
- notes are encrypted with AES-256-GCM (v2:); existing CBC bodies still read.
- login is rate limited and compares against a dummy hash when the user does
not exist, so timing does not reveal who is registered.
- a server response issued before a local save is never adopted. This had
already eaten drawing strokes and note links; sync.hasPendingWrites() now
guards every path that takes a body from the server.
Editor and features:
- TipTap replaces the contenteditable editor: tables, task lists, and links
to other notes and notebooks, with a backlinks bar on the target.
- content format v2 ({v:2,doc}); v1 {html,tasks} bodies migrate on open.
- version history: the state before each write is snapshotted, coalesced
into 5-minute windows, 50 versions or 90 days per note.
- full-text search runs in the client, because the server cannot read
encrypted bodies and a server index would defeat the encryption.
- image attachments are stored by SHA-256 and deduplicated; the client
fetches them authenticated and caches the blob, so they work offline.
- drawings are vector strokes in a fixed 1200x1600 page space and now look
the same on every screen (0.0-0.2% deviation across four widths).
- login is by username; existing email accounts migrate automatically.
Operations:
- data lives on host bind mounts, not Docker volumes, so Proxmox Backup
covers it. Back up .env with it — without ENCRYPTION_KEY the dump is
unreadable.
Structure:
- NoteApp.jsx is gone. App.jsx now holds only where the user is; note data,
search, layout, history and drag each live behind a hook, and the four
panes are stateless.
- the whole data layer is TypeScript, with 81 unit tests concentrated on
the places that have actually broken.
- fixed along the way: the browser back button had a dead press, because
restoring a history entry pushed a duplicate one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d97591b242
commit
92d525b3ab
71 files changed
+14354
-1826
No files matched your search
@@ -0,0 +1,302 @@
|
||||
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 type { Backlink, NoteContent, NoteMeta, Notebook, 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[];
|
||||
drawingStrokes?: StrokeDoc | null;
|
||||
}
|
||||
|
||||
export interface NoteData {
|
||||
notes: NoteMeta[];
|
||||
notebooks: Notebook[];
|
||||
noteContents: Record<string, NoteContent>;
|
||||
/** Notes linking to the currently open one. */
|
||||
backlinks: Backlink[];
|
||||
|
||||
reload: () => Promise<void>;
|
||||
reset: () => void;
|
||||
|
||||
createNote: (folderId: string) => Promise<NoteMeta>;
|
||||
updateNote: (id: string, edit: NoteEdit) => void;
|
||||
setNoteContent: (id: string, content: NoteContent) => void;
|
||||
toggleStar: (id: string) => Promise<void>;
|
||||
trashNote: (id: string) => Promise<void>;
|
||||
restoreNote: (id: string) => Promise<void>;
|
||||
permanentDeleteNote: (id: string) => Promise<void>;
|
||||
emptyTrash: () => Promise<void>;
|
||||
moveNote: (id: string, folderId: string) => Promise<void>;
|
||||
|
||||
createNotebook: (fields: { name: string; cover: number }) => Promise<Notebook>;
|
||||
updateNotebook: (id: string, updates: Partial<Notebook>) => Promise<void>;
|
||||
deleteNotebook: (id: string) => Promise<void>;
|
||||
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<NoteMeta[]>([]);
|
||||
const [notebooks, setNotebooks] = useState<Notebook[]>([]);
|
||||
const [noteContents, setNoteContents] = useState<Record<string, NoteContent>>({});
|
||||
const [backlinks, setBacklinks] = useState<Backlink[]>([]);
|
||||
|
||||
const notesRef = useRef(notes);
|
||||
notesRef.current = notes;
|
||||
const notebooksRef = useRef(notebooks);
|
||||
notebooksRef.current = notebooks;
|
||||
const contentsRef = useRef(noteContents);
|
||||
contentsRef.current = noteContents;
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setNotebooks(await sync.getNotebooks());
|
||||
setNotes(await sync.getNotes());
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setNotes([]);
|
||||
setNotebooks([]);
|
||||
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 createNote = useCallback(async (folderId: string) => {
|
||||
const note = await sync.createNote(folderId);
|
||||
setNotes((p) => [note, ...p]);
|
||||
setNoteContents((p) => ({ ...p, [note.id]: EMPTY_CONTENT }));
|
||||
return note;
|
||||
}, []);
|
||||
|
||||
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);
|
||||
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 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) => {
|
||||
await sync.moveNote(id, folderId);
|
||||
setNotes((p) => p.map((n) => (n.id === id ? { ...n, folderId } : n)));
|
||||
}, []);
|
||||
|
||||
/* ── Notebooks ── */
|
||||
|
||||
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<Notebook>) => {
|
||||
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,
|
||||
updateNote,
|
||||
setNoteContent,
|
||||
toggleStar,
|
||||
trashNote,
|
||||
restoreNote,
|
||||
permanentDeleteNote,
|
||||
emptyTrash,
|
||||
moveNote,
|
||||
createNotebook,
|
||||
updateNotebook,
|
||||
deleteNotebook,
|
||||
reorderNotebooks,
|
||||
};
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
Reference in new issue
Block a user