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,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 ? "…" : ""}`;
|
||||
}
|
||||
Reference in new issue
Block a user