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:
marcandClaude Opus 5 committed 2026-08-07 17:00:14 +02:00
1 parent d97591b242
commit 92d525b3ab
71 files changed
+14354 -1826

No files matched your search

+302
View File
@@ -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 = () => {};