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

+221
View File
@@ -0,0 +1,221 @@
/**
* Note links — type `@` to reference another note or notebook.
*
* A link is an atomic inline node carrying the target's id, so it survives
* renames: the label is refreshed from the current title on render, and the id
* is what the backlink index is built from.
*/
import { Node, mergeAttributes } from "@tiptap/core";
import Suggestion from "@tiptap/suggestion";
import { PluginKey } from "@tiptap/pm/state";
export const NoteLinkPluginKey = new PluginKey("noteLinkSuggestion");
export const NoteLink = Node.create({
name: "noteLink",
group: "inline",
inline: true,
atom: true,
selectable: true,
draggable: false,
addOptions() {
return {
// (query) => [{ id, label, kind: 'note' | 'notebook' }]
search: () => [],
HTMLAttributes: {},
};
},
addAttributes() {
return {
noteId: {
default: null,
parseHTML: (el) => el.getAttribute("data-note-link"),
renderHTML: (a) => ({ "data-note-link": a.noteId }),
},
label: {
default: "",
parseHTML: (el) => el.getAttribute("data-label") || el.textContent,
renderHTML: (a) => ({ "data-label": a.label }),
},
kind: {
default: "note",
parseHTML: (el) => el.getAttribute("data-kind") || "note",
renderHTML: (a) => ({ "data-kind": a.kind }),
},
// Set when the target no longer exists, so a dead link reads as broken
// instead of silently going nowhere.
missing: {
default: false,
parseHTML: (el) => el.getAttribute("data-missing") === "true",
renderHTML: (a) => (a.missing ? { "data-missing": "true" } : {}),
},
};
},
parseHTML() {
return [{ tag: "a[data-note-link]" }, { tag: "span[data-note-link]" }];
},
renderHTML({ node, HTMLAttributes }) {
return [
"a",
mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
class: "note-link",
// Real href would navigate away; the app intercepts the click instead.
href: "#",
title: node.attrs.label,
}),
`${node.attrs.kind === "notebook" ? "📓" : "📝"} ${node.attrs.label}`,
];
},
renderText({ node }) {
return `@${node.attrs.label}`;
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: "@",
pluginKey: NoteLinkPluginKey,
allowSpaces: true,
startOfLine: false,
items: ({ query }) => this.options.search(query),
command: ({ editor, range, props }) => {
editor
.chain()
.focus()
.insertContentAt(range, [
{
type: "noteLink",
attrs: { noteId: props.id, label: props.label, kind: props.kind || "note" },
},
{ type: "text", text: " " },
])
.run();
},
render: renderSuggestionList,
}),
];
},
});
/**
* A small dropdown driven by the suggestion plugin.
*
* Deliberately plain DOM rather than a positioning library — it is one list in
* one place, and pulling in tippy/popper for it is not worth the bundle.
*/
function renderSuggestionList() {
let el = null;
let items = [];
let selected = 0;
let command = null;
const paint = () => {
if (!el) return;
if (items.length === 0) {
el.innerHTML = '<div class="note-link-empty">No matching note</div>';
return;
}
el.innerHTML = items
.map(
(
it,
i
) => `<button type="button" class="note-link-option${i === selected ? " is-selected" : ""}" data-index="${i}">
<span class="note-link-kind">${it.kind === "notebook" ? "📓" : "📝"}</span>
<span class="note-link-label"></span>
</button>`
)
.join("");
// Titles are user text — set them as text, never as markup.
el.querySelectorAll(".note-link-option").forEach((btn, i) => {
btn.querySelector(".note-link-label").textContent = items[i].label;
btn.addEventListener("mousedown", (e) => {
e.preventDefault();
pick(i);
});
});
};
const pick = (i) => {
const item = items[i];
if (item && command) command({ id: item.id, label: item.label, kind: item.kind });
};
const place = (rect) => {
if (!el || !rect) return;
const r = rect();
if (!r) return;
const width = 260;
const left = Math.min(Math.max(8, r.left), window.innerWidth - width - 8);
// Flip above the caret when there is no room below.
const below = window.innerHeight - r.bottom;
el.style.width = `${width}px`;
el.style.left = `${left}px`;
if (below < 220) {
el.style.top = "auto";
el.style.bottom = `${window.innerHeight - r.top + 6}px`;
} else {
el.style.bottom = "auto";
el.style.top = `${r.bottom + 6}px`;
}
};
return {
onStart: (props) => {
items = props.items || [];
selected = 0;
command = props.command;
el = document.createElement("div");
el.className = "note-link-popup";
document.body.appendChild(el);
paint();
place(props.clientRect);
},
onUpdate: (props) => {
items = props.items || [];
selected = Math.min(selected, Math.max(0, items.length - 1));
command = props.command;
paint();
place(props.clientRect);
},
onKeyDown: (props) => {
const key = props.event.key;
if (key === "Escape") {
props.event.preventDefault();
el?.remove();
el = null;
return true;
}
if (items.length === 0) return false;
if (key === "ArrowDown") {
selected = (selected + 1) % items.length;
paint();
return true;
}
if (key === "ArrowUp") {
selected = (selected - 1 + items.length) % items.length;
paint();
return true;
}
if (key === "Enter" || key === "Tab") {
props.event.preventDefault();
pick(selected);
return true;
}
return false;
},
onExit: () => {
el?.remove();
el = null;
items = [];
command = null;
},
};
}