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

+133
View File
@@ -0,0 +1,133 @@
/**
* Local full-text search.
*
* The server stores note bodies encrypted and deliberately keeps no plaintext
* index — so searching has to happen on the client, over the copy already in
* IndexedDB. That also means search keeps working offline.
*
* The index is a plain in-memory map. For a personal note collection that is
* both fast enough and far simpler than a real inverted index: matching a few
* thousand strings costs well under a frame.
*/
import * as db from "./offlineStore";
import { docToText } from "./editor/content";
import type { SearchHit } from "./types";
interface IndexEntry {
/** Normalised title, for matching. */
title: string;
/** Normalised body text, for matching. */
text: string;
/** Original body text, for building the snippet shown to the user. */
raw: string;
}
const _index = new Map<string, IndexEntry>();
/** Lowercases and strips diacritics, so `kuhlschrank` matches `Kühlschrank`. */
const normalize = (s: unknown): string =>
String(s ?? "")
.toLowerCase()
.normalize("NFKD")
.replace(/\p{Diacritic}/gu, "");
/** Plain text of a stored note body, whatever format it is in. */
export function contentToText(raw: string | null | undefined): string {
if (!raw) return "";
let parsed: unknown = null;
try {
parsed = JSON.parse(raw);
} catch {
parsed = null;
}
const obj = parsed as { v?: number; doc?: unknown; html?: unknown; tasks?: unknown } | null;
if (obj && obj.v === 2 && obj.doc) return docToText(obj.doc, 20000);
// v1 — HTML plus a detached task list.
const html = typeof obj?.html === "string" ? obj.html : raw;
const el = document.createElement("div");
el.innerHTML = html;
const tasks = Array.isArray(obj?.tasks)
? (obj.tasks as Array<{ text?: string }>).map((t) => t.text ?? "").join(" ")
: "";
return `${el.textContent || ""} ${tasks}`.replace(/\s+/g, " ").trim();
}
export function setEntry(id: string, title: string, content?: string | null): void {
const raw = contentToText(content);
_index.set(id, { title: normalize(title), text: normalize(raw), raw });
}
export function removeEntry(id: string): void {
_index.delete(id);
}
export function size(): number {
return _index.size;
}
/** Builds the index from whatever is cached locally. */
export async function rebuild(): Promise<number> {
const notes = await db.getAllNotes();
_index.clear();
for (const n of notes) {
const c = await db.getNoteContent(n.id);
setEntry(n.id, n.title, c?.content);
}
return _index.size;
}
/**
* Ranked matches for a query.
*
* All terms must appear (AND). Title matches outrank body matches so that
* searching for a note by name still puts that note first. Returns `null` for
* an empty query, which callers read as "no search, show the plain list".
*/
export function search(query: string, { limit = 200 }: { limit?: number } = {}): SearchHit[] | null {
const terms = normalize(query).split(/\s+/).filter(Boolean);
if (terms.length === 0) return null;
const hits: Array<{ id: string; score: number; entry: IndexEntry }> = [];
for (const [id, entry] of _index) {
let score = 0;
let matchedAll = true;
for (const term of terms) {
const inTitle = entry.title.includes(term);
const inText = entry.text.includes(term);
if (!inTitle && !inText) {
matchedAll = false;
break;
}
if (inTitle) score += entry.title.startsWith(term) ? 12 : 8;
if (inText) score += 1;
}
if (matchedAll) hits.push({ id, score, entry });
}
hits.sort((a, b) => b.score - a.score);
return hits.slice(0, limit).map(({ id, score, entry }) => ({
id,
score,
snippet: snippetFor(entry, terms),
}));
}
/** A short excerpt around the first matching term, for the results list. */
function snippetFor(entry: IndexEntry, terms: string[], radius = 60): string {
const text = entry.raw;
if (!text) return "";
const haystack = normalize(text);
let at = -1;
for (const term of terms) {
const i = haystack.indexOf(term);
if (i !== -1 && (at === -1 || i < at)) at = i;
}
if (at === -1) return text.slice(0, radius * 2).trim();
const start = Math.max(0, at - radius);
const end = Math.min(text.length, at + radius);
return `${start > 0 ? "…" : ""}${text.slice(start, end).trim()}${end < text.length ? "…" : ""}`;
}