Files
InkNote/frontend/src/hooks/useSearchIndex.ts
T
marcandClaude Opus 5 92d525b3ab 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>
2026-08-07 17:00:14 +02:00

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 };
}