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>
85 lines
2.8 KiB
TypeScript
85 lines
2.8 KiB
TypeScript
import { useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
import * as sync from "../syncEngine";
|
|
import * as searchIndex from "../search";
|
|
import type { NoteMeta, SearchHit, User } from "../types";
|
|
|
|
const DEBOUNCE_MS = 120;
|
|
/** Refresh visible results every N notes while the backfill is still running. */
|
|
const PROGRESS_EVERY = 10;
|
|
|
|
export interface SearchState {
|
|
/** null means "not searching"; an empty array means "searched, found nothing". */
|
|
results: SearchHit[] | null;
|
|
/** Excerpt around the match, empty when the note is not a hit. */
|
|
snippetFor: (id: string) => string;
|
|
}
|
|
|
|
/**
|
|
* Full-text search over the local copy of every note.
|
|
*
|
|
* The server stores bodies encrypted and cannot index them, so the index is
|
|
* built here. Bodies normally reach the client only when a note is opened, so
|
|
* the missing ones are backfilled in the background after login.
|
|
*/
|
|
export function useSearchIndex(user: User | null, notes: NoteMeta[], query: string): SearchState {
|
|
const [results, setResults] = useState<SearchHit[] | null>(null);
|
|
const [version, setVersion] = useState(0);
|
|
const running = useRef(false);
|
|
|
|
// Read by the backfill so it sees current titles without being torn down and
|
|
// restarted every time a note changes.
|
|
const notesRef = useRef(notes);
|
|
notesRef.current = notes;
|
|
|
|
useEffect(() => {
|
|
// Waits for the note list: starting before the first sync would find
|
|
// nothing to fetch and never run again.
|
|
if (!user || notes.length === 0 || running.current) return;
|
|
running.current = true;
|
|
let cancelled = false;
|
|
const signal = { aborted: false };
|
|
|
|
void (async () => {
|
|
await searchIndex.rebuild();
|
|
if (!cancelled) setVersion((v) => v + 1);
|
|
await sync.prefetchAllContent({
|
|
signal,
|
|
onProgress: ({ id, content, fetched }) => {
|
|
const note = notesRef.current.find((n) => n.id === id);
|
|
searchIndex.setEntry(id, note?.title || "", content);
|
|
if (fetched % PROGRESS_EVERY === 0 && !cancelled) setVersion((v) => v + 1);
|
|
},
|
|
});
|
|
if (!cancelled) setVersion((v) => v + 1);
|
|
running.current = false;
|
|
})();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
signal.aborted = true;
|
|
};
|
|
}, [user, notes.length]);
|
|
|
|
useEffect(() => {
|
|
const q = query.trim();
|
|
if (!q) {
|
|
setResults(null);
|
|
return;
|
|
}
|
|
// Debounced so typing does not re-scan the index on every keystroke.
|
|
const t = setTimeout(() => setResults(searchIndex.search(q)), DEBOUNCE_MS);
|
|
return () => clearTimeout(t);
|
|
}, [query, version]);
|
|
|
|
const snippets = useMemo(() => {
|
|
const m = new Map<string, string>();
|
|
for (const r of results || []) if (r.snippet) m.set(r.id, r.snippet);
|
|
return m;
|
|
}, [results]);
|
|
|
|
const snippetFor = useMemo(() => (id: string) => snippets.get(id) || "", [snippets]);
|
|
|
|
return { results, snippetFor };
|
|
}
|