Replace unused vector clocks with a conflict check that works, drop the rest
The vector clocks in the server were never reached: no client sent one, so the branch was dead and every write was a blind overwrite. Not "the newer edit wins" — "the request that arrives last wins", which is worse: a phone that was offline all day buries the laptop's newer work simply by reconnecting late. Each write now carries baseUpdatedAt, the revision it was written on top of. If the note has moved on since, the incoming edit still wins — a rejection would throw away what was just typed — but the state it replaces is kept as a version marked "conflict", so both exist and the history is where you choose. Clocks could have told a real conflict from a device catching up, but that precision only pays off with a merge interface, and this reuses the version history already built. adoptRevision() stores the revision the server reports back, or the second save in a row would be flagged as conflicting with the first. Also removed, as agreed: - POST /api/notes/pdf — accepted PDFs that nothing could ever read back, with no retrieval route and no viewer. The readme advertised "PDF Import & Annotation"; that claim is gone too. - POST /api/sync — a batch endpoint the client never called. - the tags column — notebooks, starring and search already cover it. Columns on existing databases are left in place. Dropping them buys nothing and is not worth touching a live database for. Tables now have a right-click menu (editor/tableMenu.js) for rows, columns, header and merging. It needs stopPropagation: without it the same event reaches window, where the menu that is mounting reads it as a click elsewhere and closes instantly — the reason the other context menus in App.jsx already do this. 92 browser checks across seven suites, 125 unit tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8c36c6121b
commit
df279a5d36
10 files changed
+367
-152
No files matched your search
+53
-130
@@ -12,7 +12,7 @@
|
||||
*
|
||||
* Install:
|
||||
* npm init -y
|
||||
* npm install express better-sqlite3 bcryptjs jsonwebtoken ws uuid cors helmet dotenv multer
|
||||
* npm install express better-sqlite3 bcryptjs jsonwebtoken ws uuid cors helmet dotenv
|
||||
*
|
||||
* Run:
|
||||
* node server.js
|
||||
@@ -28,7 +28,6 @@ const { WebSocketServer } = require("ws");
|
||||
const Database = require("better-sqlite3");
|
||||
const { v4: uuid } = require("uuid");
|
||||
const crypto = require("crypto");
|
||||
const multer = require("multer");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const http = require("http");
|
||||
@@ -148,14 +147,12 @@ db.exec(`
|
||||
pdf_path TEXT,
|
||||
annotations_path TEXT,
|
||||
starred INTEGER DEFAULT 0,
|
||||
tags TEXT DEFAULT '[]',
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
deleted_at TEXT,
|
||||
version INTEGER DEFAULT 1,
|
||||
device_id TEXT,
|
||||
vector_clock TEXT DEFAULT '{}'
|
||||
device_id TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_log (
|
||||
@@ -373,25 +370,27 @@ function detectImageMime(buffer, filePath = "") {
|
||||
return "image/png";
|
||||
}
|
||||
|
||||
/* ─────────── Vector clock conflict resolution ─────────── */
|
||||
function mergeVectorClocks(clockA, clockB) {
|
||||
const merged = { ...clockA };
|
||||
for (const [device, time] of Object.entries(clockB)) {
|
||||
merged[device] = Math.max(merged[device] || 0, time);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function isClockNewer(clockA, clockB) {
|
||||
// Returns true if clockA is strictly newer than clockB
|
||||
let aHasNewer = false;
|
||||
for (const [device, time] of Object.entries(clockA)) {
|
||||
if (time > (clockB[device] || 0)) aHasNewer = true;
|
||||
}
|
||||
for (const [device, time] of Object.entries(clockB)) {
|
||||
if (time > (clockA[device] || 0)) return false; // conflict or older
|
||||
}
|
||||
return aHasNewer;
|
||||
/* ─────────── Conflict detection ─────────── */
|
||||
/**
|
||||
* Whether this write is based on a stale copy of the note.
|
||||
*
|
||||
* A client sends the `updatedAt` it last saw. If the note has moved on since —
|
||||
* another device saved in between — the two edits are independent and one is
|
||||
* about to bury the other.
|
||||
*
|
||||
* Nothing is rejected: refusing the write would lose whatever was just typed,
|
||||
* and on a phone that was offline for a day that is the worse outcome. The
|
||||
* incoming edit wins, but the version it replaces is kept with reason
|
||||
* "conflict", so both exist and the newer one can be picked in the history.
|
||||
*
|
||||
* This deliberately replaces the vector clocks that used to sit here. They
|
||||
* could tell a genuine conflict from a simple catch-up, but no client ever sent
|
||||
* one, and the extra precision only pays off with a merge interface to spend it
|
||||
* on. Comparing revisions needs one field and reuses the history already built.
|
||||
*/
|
||||
function isStaleWrite(serverUpdatedAt, clientBaseUpdatedAt) {
|
||||
if (!clientBaseUpdatedAt || !serverUpdatedAt) return false;
|
||||
return String(serverUpdatedAt) > String(clientBaseUpdatedAt);
|
||||
}
|
||||
|
||||
/* ─────────── Auth middleware ─────────── */
|
||||
@@ -460,14 +459,6 @@ setInterval(() => {
|
||||
// response time does not reveal which usernames exist.
|
||||
const DUMMY_HASH = bcrypt.hashSync("not-a-real-password", 12);
|
||||
|
||||
const upload = multer({
|
||||
dest: UPLOAD_DIR,
|
||||
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (file.mimetype === "application/pdf") cb(null, true);
|
||||
else cb(new Error("Only PDF files allowed"));
|
||||
},
|
||||
});
|
||||
|
||||
/* ─── Auth routes ─── */
|
||||
/* There is no registration route on purpose: this is a private instance, and
|
||||
@@ -719,12 +710,12 @@ function isBigShrink(previous, next) {
|
||||
|
||||
/* ─── Note routes ─── */
|
||||
app.get("/api/notes", authMiddleware, (req, res) => {
|
||||
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, tags, sort_order, created_at, updated_at, version, vector_clock FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId);
|
||||
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, sort_order, created_at, updated_at, version FROM notes WHERE user_id = ? AND deleted_at IS NULL ORDER BY updated_at DESC").all(req.userId);
|
||||
res.json(notes);
|
||||
});
|
||||
|
||||
app.get("/api/notes/trash", authMiddleware, (req, res) => {
|
||||
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, tags, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC").all(req.userId);
|
||||
const notes = db.prepare("SELECT id, user_id, notebook_id, type, title, starred, sort_order, created_at, updated_at, deleted_at, version FROM notes WHERE user_id = ? AND deleted_at IS NOT NULL ORDER BY deleted_at DESC").all(req.userId);
|
||||
res.json(notes);
|
||||
});
|
||||
|
||||
@@ -775,14 +766,13 @@ app.get("/api/notes/:id", authMiddleware, (req, res) => {
|
||||
});
|
||||
|
||||
app.post("/api/notes", authMiddleware, (req, res) => {
|
||||
const { type, title, content, notebookId, starred, tags } = req.body;
|
||||
const { type, title, content, notebookId, starred } = req.body;
|
||||
const id = uuid();
|
||||
const encContent = content ? encrypt(content) : null;
|
||||
const clock = JSON.stringify({ [req.deviceId]: 1 });
|
||||
|
||||
db.prepare(`INSERT INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, tags, device_id, vector_clock)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(id, req.userId, notebookId, type || "text", title || "Untitled", encContent, starred ? 1 : 0, JSON.stringify(tags || []), req.deviceId, clock);
|
||||
db.prepare(`INSERT INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, device_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run(id, req.userId, notebookId, type || "text", title || "Untitled", encContent, starred ? 1 : 0, req.deviceId);
|
||||
|
||||
db.prepare("INSERT INTO sync_log (user_id, entity_type, entity_id, action, device_id) VALUES (?, 'note', ?, 'create', ?)")
|
||||
.run(req.userId, id, req.deviceId);
|
||||
@@ -808,34 +798,25 @@ const replaceNoteLinks = db.transaction((userId, sourceId, links) => {
|
||||
});
|
||||
|
||||
app.put("/api/notes/:id", authMiddleware, (req, res) => {
|
||||
const { title, content, notebookId, starred, tags, vectorClock, links } = req.body;
|
||||
const { title, content, notebookId, starred, baseUpdatedAt, links } = req.body;
|
||||
const note = db.prepare("SELECT * FROM notes WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
|
||||
if (!note) return res.status(404).json({ error: "Not found" });
|
||||
|
||||
// Conflict detection
|
||||
const serverClock = JSON.parse(note.vector_clock || "{}");
|
||||
const clientClock = vectorClock ? JSON.parse(vectorClock) : {};
|
||||
|
||||
if (Object.keys(clientClock).length > 0 && !isClockNewer(clientClock, serverClock)) {
|
||||
// Conflict! Return both versions
|
||||
return res.status(409).json({
|
||||
error: "Conflict detected",
|
||||
serverVersion: { ...note, vectorClock: serverClock },
|
||||
clientVersion: req.body,
|
||||
mergedClock: mergeVectorClocks(serverClock, clientClock),
|
||||
});
|
||||
}
|
||||
|
||||
const conflict = isStaleWrite(note.updated_at, baseUpdatedAt);
|
||||
const encContent = content ? encrypt(content) : note.content_encrypted;
|
||||
const newClock = { ...mergeVectorClocks(serverClock, clientClock), [req.deviceId]: (serverClock[req.deviceId] || 0) + 1 };
|
||||
|
||||
// Keep the state we are about to replace.
|
||||
if (content !== undefined) {
|
||||
// Keep the state we are about to replace. A conflicting write always keeps
|
||||
// one, whatever the coalescing window would otherwise say — that snapshot is
|
||||
// the only copy of the other device's work.
|
||||
if (content !== undefined || conflict) {
|
||||
const shrank = isBigShrink(note.content_encrypted, encContent);
|
||||
snapshotNote(req.userId, req.params.id, req.deviceId, {
|
||||
force: shrank,
|
||||
reason: shrank ? "large-deletion" : "edit",
|
||||
force: shrank || conflict,
|
||||
reason: conflict ? "conflict" : shrank ? "large-deletion" : "edit",
|
||||
});
|
||||
if (conflict) {
|
||||
console.log(` ⚠️ conflicting write to note ${req.params.id} from ${req.deviceId} — previous state kept`);
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`UPDATE notes SET
|
||||
@@ -843,18 +824,18 @@ app.put("/api/notes/:id", authMiddleware, (req, res) => {
|
||||
content_encrypted = COALESCE(?, content_encrypted),
|
||||
notebook_id = COALESCE(?, notebook_id),
|
||||
starred = COALESCE(?, starred),
|
||||
tags = COALESCE(?, tags),
|
||||
updated_at = datetime('now'),
|
||||
version = version + 1,
|
||||
vector_clock = ?,
|
||||
device_id = ?
|
||||
WHERE id = ?`)
|
||||
.run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, tags ? JSON.stringify(tags) : null, JSON.stringify(newClock), req.deviceId, req.params.id);
|
||||
.run(title, encContent, notebookId, starred !== undefined ? (starred ? 1 : 0) : null, req.deviceId, req.params.id);
|
||||
|
||||
if (Array.isArray(links)) replaceNoteLinks(req.userId, req.params.id, links);
|
||||
|
||||
const fresh = db.prepare("SELECT updated_at FROM notes WHERE id = ?").get(req.params.id);
|
||||
broadcastToUser(req.userId, { type: "note_updated", data: { id: req.params.id, title } }, req.deviceId);
|
||||
res.json({ success: true, vectorClock: newClock });
|
||||
// `conflict` lets the client say so; `updatedAt` is what the next write bases itself on.
|
||||
res.json({ success: true, updatedAt: fresh.updated_at, conflict });
|
||||
});
|
||||
|
||||
/* ─── Version history ─── */
|
||||
@@ -1096,73 +1077,15 @@ app.get("/api/attachments/:id", authMiddleware, (req, res) => {
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
fs.createReadStream(filePath).pipe(res);
|
||||
});
|
||||
|
||||
/* ─── PDF upload route ─── */
|
||||
app.post("/api/notes/pdf", authMiddleware, upload.single("pdf"), (req, res) => {
|
||||
if (!req.file) return res.status(400).json({ error: "No file uploaded" });
|
||||
|
||||
const id = uuid();
|
||||
const clock = JSON.stringify({ [req.deviceId]: 1 });
|
||||
|
||||
db.prepare(`INSERT INTO notes (id, user_id, type, title, pdf_path, device_id, vector_clock)
|
||||
VALUES (?, ?, 'pdf', ?, ?, ?, ?)`)
|
||||
.run(id, req.userId, req.file.originalname, req.file.path, req.deviceId, clock);
|
||||
|
||||
res.json({ id, title: req.file.originalname });
|
||||
});
|
||||
|
||||
/* ─── Sync endpoint ─── */
|
||||
app.post("/api/sync", authMiddleware, (req, res) => {
|
||||
const { lastSyncTimestamp, deviceId, changes } = req.body;
|
||||
const results = { pushed: [], pulled: [], conflicts: [] };
|
||||
|
||||
// Apply client changes
|
||||
if (changes && changes.length > 0) {
|
||||
for (const change of changes) {
|
||||
try {
|
||||
if (change.entityType === "note") {
|
||||
if (change.action === "create") {
|
||||
const encContent = change.data.content ? encrypt(change.data.content) : null;
|
||||
db.prepare(`INSERT OR REPLACE INTO notes (id, user_id, notebook_id, type, title, content_encrypted, starred, device_id, vector_clock, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`)
|
||||
.run(change.data.id, req.userId, change.data.notebookId, change.data.type, change.data.title, encContent, change.data.starred ? 1 : 0, deviceId, JSON.stringify(change.data.vectorClock || {}));
|
||||
results.pushed.push({ id: change.data.id, status: "ok" });
|
||||
} else if (change.action === "update") {
|
||||
const existing = db.prepare("SELECT vector_clock FROM notes WHERE id = ? AND user_id = ?").get(change.data.id, req.userId);
|
||||
if (existing) {
|
||||
const serverClock = JSON.parse(existing.vector_clock || "{}");
|
||||
const clientClock = change.data.vectorClock || {};
|
||||
if (!isClockNewer(clientClock, serverClock)) {
|
||||
results.conflicts.push({ id: change.data.id, serverClock, clientClock });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
results.pushed.push({ id: change.data.id, status: "ok" });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
results.pushed.push({ id: change.data?.id, status: "error", error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get server changes since last sync
|
||||
const serverChanges = db.prepare(`
|
||||
SELECT s.*, n.title, n.type, n.updated_at
|
||||
FROM sync_log s
|
||||
LEFT JOIN notes n ON s.entity_id = n.id
|
||||
WHERE s.user_id = ? AND s.timestamp > COALESCE(?, '1970-01-01') AND s.device_id != ?
|
||||
ORDER BY s.timestamp
|
||||
`).all(req.userId, lastSyncTimestamp, deviceId);
|
||||
|
||||
results.pulled = serverChanges;
|
||||
results.syncTimestamp = new Date().toISOString();
|
||||
|
||||
// Update device last sync
|
||||
db.prepare("INSERT OR REPLACE INTO devices (id, user_id, last_sync) VALUES (?, ?, datetime('now'))").run(deviceId, req.userId);
|
||||
|
||||
res.json(results);
|
||||
});
|
||||
/* Two routes used to sit here and were removed on 2026-08-08:
|
||||
*
|
||||
* POST /api/notes/pdf accepted a PDF and stored it, but nothing could ever
|
||||
* read it back — there was no retrieval route and no
|
||||
* viewer, so uploads simply accumulated unreachable.
|
||||
* POST /api/sync a batch sync endpoint the client never called; it
|
||||
* syncs through the individual routes instead.
|
||||
*
|
||||
* Both are in the git history if a PDF viewer is ever built for real. */
|
||||
|
||||
/* ─── WebSocket for real-time sync ─── */
|
||||
const server = http.createServer(app);
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/* Two devices editing the same note without knowing about each other.
|
||||
*
|
||||
* The write that arrives last wins — refusing it would throw away what was just
|
||||
* typed — but the state it buries has to survive as a version, or the other
|
||||
* device's work is simply gone. That is the whole point of the check, so this
|
||||
* verifies it end to end rather than trusting the route in isolation. */
|
||||
import { api, check, createNote, createNotebook, launch, report, setTitle, typeInEditor, withFreshUser } from "./lib.mjs";
|
||||
|
||||
const browser = await launch();
|
||||
let session;
|
||||
try {
|
||||
session = await withFreshUser(browser, "conflict");
|
||||
const p = session.page;
|
||||
await createNotebook(p, "Konflikt Buch");
|
||||
await createNote(p);
|
||||
await setTitle(p, "Umkämpft");
|
||||
await typeInEditor(p, "Ursprünglicher Text von Gerät A.");
|
||||
await p.waitForTimeout(2000);
|
||||
|
||||
const id = (await api(p, "GET", "/notes")).body[0].id;
|
||||
const base = (await api(p, "GET", `/notes/${id}`)).body.updated_at;
|
||||
check(!!base, "the note reports a revision to base an edit on", String(base));
|
||||
|
||||
/* Device B saves — it has seen the same revision, so no conflict. */
|
||||
const first = await api(p, "PUT", `/notes/${id}`, {
|
||||
content: JSON.stringify({ v: 2, doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Gerät B war hier." }] }] } }),
|
||||
baseUpdatedAt: base,
|
||||
});
|
||||
check(first.status === 200, "a save on the current revision is accepted", String(first.status));
|
||||
check(first.body.conflict === false, "and is not reported as a conflict", JSON.stringify(first.body.conflict));
|
||||
check(!!first.body.updatedAt, "the server answers with the new revision");
|
||||
|
||||
/* Device A saves, still believing the note is at the original revision. */
|
||||
const stale = await api(p, "PUT", `/notes/${id}`, {
|
||||
content: JSON.stringify({ v: 2, doc: { type: "doc", content: [{ type: "paragraph", content: [{ type: "text", text: "Gerät A schreibt spät." }] }] } }),
|
||||
baseUpdatedAt: base,
|
||||
});
|
||||
check(stale.status === 200, "the late save is still accepted, not rejected", String(stale.status));
|
||||
check(stale.body.conflict === true, "but it is reported as a conflict");
|
||||
|
||||
/* The late write won... */
|
||||
const now = (await api(p, "GET", `/notes/${id}`)).body;
|
||||
check(now.content.includes("Gerät A schreibt spät"), "the later write is what the note now holds");
|
||||
|
||||
/* ...and the buried version is recoverable. */
|
||||
const versions = (await api(p, "GET", `/notes/${id}/versions`)).body;
|
||||
check(
|
||||
versions.some((v) => v.reason === "conflict"),
|
||||
"a version is kept and labelled as a conflict",
|
||||
JSON.stringify(versions.map((v) => v.reason))
|
||||
);
|
||||
|
||||
const conflictVersion = versions.find((v) => v.reason === "conflict");
|
||||
const buried = (await api(p, "GET", `/notes/${id}/versions/${conflictVersion.id}`)).body;
|
||||
check(
|
||||
buried.content.includes("Gerät B war hier"),
|
||||
"and it holds exactly the work the late write buried"
|
||||
);
|
||||
|
||||
/* A write with no base at all must not be treated as a conflict — that is how
|
||||
* writes that cannot conflict, like starring, reach the server. */
|
||||
const noBase = await api(p, "PUT", `/notes/${id}`, { starred: true });
|
||||
check(noBase.body.conflict === false, "a write without a base revision is never a conflict");
|
||||
} catch (e) {
|
||||
check(false, "suite ran to the end", e.message);
|
||||
} finally {
|
||||
await session?.cleanup();
|
||||
const code = report();
|
||||
await browser.close();
|
||||
process.exit(code);
|
||||
}
|
||||
@@ -44,6 +44,36 @@ try {
|
||||
|
||||
await p.screenshot({ path: `${OUT}/editor-content.png` });
|
||||
|
||||
/* ── right-click menu on the table ── */
|
||||
const cellCount = () => p.locator(".ProseMirror table td, .ProseMirror table th").count();
|
||||
const before = await cellCount();
|
||||
await p.locator(".ProseMirror table td").first().click({ button: "right" });
|
||||
await p.waitForTimeout(600);
|
||||
check((await p.getByText("Zeile darunter einfügen").count()) > 0, "right-click in a table opens the table menu");
|
||||
check((await p.getByText("Tabelle löschen").count()) > 0, "and offers deleting the table");
|
||||
await p.screenshot({ path: `${OUT}/editor-table-menu.png` });
|
||||
|
||||
await p.getByText("Zeile darunter einfügen").click();
|
||||
await p.waitForTimeout(1000);
|
||||
check((await cellCount()) > before, "the menu really added a row", `${before} → ${await cellCount()}`);
|
||||
|
||||
const withRow = await cellCount();
|
||||
await p.locator(".ProseMirror table td").first().click({ button: "right" });
|
||||
await p.waitForTimeout(600);
|
||||
await p.getByText("Zeile löschen").click();
|
||||
await p.waitForTimeout(1000);
|
||||
check((await cellCount()) < withRow, "and removing one works too", `${withRow} → ${await cellCount()}`);
|
||||
|
||||
// Outside a table the browser's own menu must stay available.
|
||||
await p.locator(".ProseMirror p").first().click({ button: "right" });
|
||||
await p.waitForTimeout(500);
|
||||
check(
|
||||
(await p.getByText("Zeile darunter einfügen").count()) === 0,
|
||||
"right-click outside a table shows no table menu"
|
||||
);
|
||||
await p.keyboard.press("Escape");
|
||||
await p.waitForTimeout(400);
|
||||
|
||||
/* ── a second note to link to ── */
|
||||
await setTitle(p, "Quelle");
|
||||
await createNote(p);
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ cd "$(dirname "$0")"
|
||||
IMAGE="mcr.microsoft.com/playwright:v1.49.0-noble"
|
||||
URL="${INKNOTE_URL:-http://localhost:3000}"
|
||||
SUITES=("$@")
|
||||
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export)
|
||||
[ ${#SUITES[@]} -eq 0 ] && SUITES=(admin editor search-attachments history drawing export conflict)
|
||||
|
||||
mkdir -p shots
|
||||
if [ ! -d node_modules ]; then
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* The right-click menu on a table.
|
||||
*
|
||||
* The toolbar carries the same commands, but reaching them means leaving the
|
||||
* cell you are working in, finding the button and reading a list — for shaping
|
||||
* a table, where the target is the cell under the pointer, that is the long way
|
||||
* round. This puts the commands where the cursor already is.
|
||||
*
|
||||
* Only the entries that apply are built: merge appears when several cells are
|
||||
* selected, split when the cell was merged, and nothing at all outside a table
|
||||
* so the browser's own menu stays available for ordinary text.
|
||||
*/
|
||||
|
||||
/** True when the caret or selection sits inside a table cell. */
|
||||
export const inTable = (editor) => !!editor?.isActive("table");
|
||||
|
||||
/**
|
||||
* Menu items for `ContextMenu`, or null when there is no table under the click.
|
||||
*
|
||||
* Returning null rather than an empty list lets the caller tell "no menu" from
|
||||
* "a menu with nothing in it" and fall through to the browser's own.
|
||||
*/
|
||||
export function tableMenuItems(editor) {
|
||||
if (!inTable(editor)) return null;
|
||||
|
||||
const run = (fn) => () => fn(editor.chain().focus()).run();
|
||||
const can = (name) => {
|
||||
try {
|
||||
return editor.can()[name]();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const items = [
|
||||
{ label: "Zeile darüber einfügen", action: run((c) => c.addRowBefore()) },
|
||||
{ label: "Zeile darunter einfügen", action: run((c) => c.addRowAfter()) },
|
||||
{ label: "Spalte links einfügen", action: run((c) => c.addColumnBefore()) },
|
||||
{ label: "Spalte rechts einfügen", action: run((c) => c.addColumnAfter()) },
|
||||
{ divider: true },
|
||||
{ label: "Kopfzeile an/aus", action: run((c) => c.toggleHeaderRow()) },
|
||||
];
|
||||
|
||||
// mergeCells and splitCell are mutually exclusive in practice: one needs a
|
||||
// multi-cell selection, the other a cell that was merged earlier.
|
||||
if (can("mergeCells")) items.push({ label: "Zellen verbinden", action: run((c) => c.mergeCells()) });
|
||||
if (can("splitCell")) items.push({ label: "Zelle teilen", action: run((c) => c.splitCell()) });
|
||||
|
||||
items.push(
|
||||
{ divider: true },
|
||||
{ label: "Zeile löschen", danger: true, action: run((c) => c.deleteRow()) },
|
||||
{ label: "Spalte löschen", danger: true, action: run((c) => c.deleteColumn()) },
|
||||
{ label: "Tabelle löschen", danger: true, action: run((c) => c.deleteTable()) }
|
||||
);
|
||||
|
||||
return items;
|
||||
}
|
||||
@@ -90,6 +90,7 @@ export function HistoryModal({ noteId, noteTitle, dark, onClose, onRestored, ren
|
||||
});
|
||||
};
|
||||
const REASONS = {
|
||||
conflict: "conflict — another device had saved",
|
||||
"large-deletion": "large deletion",
|
||||
"before-restore": "before restore",
|
||||
"drawing-cleared": "drawing cleared",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNoteEditor, EditorSurface } from "../../editor/useNoteEditor.jsx";
|
||||
import { EditorToolbar } from "../../editor/EditorToolbar.jsx";
|
||||
import { tableMenuItems } from "../../editor/tableMenu.js";
|
||||
import { ContextMenu } from "../../ui/ContextMenu.jsx";
|
||||
import { DrawingLayer } from "../drawing/DrawingLayer.jsx";
|
||||
import {
|
||||
CANON_W,
|
||||
@@ -41,6 +43,7 @@ export function NoteEditor({
|
||||
const [showColors, setShowColors] = useState(false);
|
||||
const [showSizes, setShowSizes] = useState(false);
|
||||
const [hideDrawing, setHideDrawing] = useState(false);
|
||||
const [tableMenu, setTableMenu] = useState(null);
|
||||
const [drawingBlend, setDrawingBlend] = useState({ mode: "normal", opacity: 1 });
|
||||
const [isMobileViewport, setIsMobileViewport] = useState(window.innerWidth < 768);
|
||||
const stageRef = useRef(null);
|
||||
@@ -303,6 +306,35 @@ export function NoteEditor({
|
||||
};
|
||||
|
||||
/* ── DRAWING: history ── */
|
||||
/**
|
||||
* Right-click inside a table opens the table menu; anywhere else the
|
||||
* browser's own menu is left alone, because for ordinary text that one is
|
||||
* more useful than anything offered here.
|
||||
*/
|
||||
const onTableContextMenu = (e) => {
|
||||
if (mode !== "text" || !textEditor) return;
|
||||
const at = textEditor.view.posAtCoords({ left: e.clientX, top: e.clientY });
|
||||
|
||||
// Put the caret in the cell that was clicked, so the commands act on it and
|
||||
// not on wherever the cursor last sat. A selection spanning several cells is
|
||||
// left intact — collapsing it would take "merge cells" away in the moment
|
||||
// the user reached for it.
|
||||
const sel = textEditor.state.selection;
|
||||
const isCellSelection = !!sel.$anchorCell;
|
||||
const clickedInside = at && at.pos >= sel.from && at.pos <= sel.to;
|
||||
if (at && !(isCellSelection && clickedInside)) {
|
||||
textEditor.commands.setTextSelection(at.pos);
|
||||
}
|
||||
|
||||
const items = tableMenuItems(textEditor);
|
||||
if (!items) return;
|
||||
e.preventDefault();
|
||||
// Without this the same event carries on to window, where the menu that is
|
||||
// about to mount reads it as a click elsewhere and closes again instantly.
|
||||
e.stopPropagation();
|
||||
setTableMenu({ x: e.clientX, y: e.clientY, items });
|
||||
};
|
||||
|
||||
const pushUndo = () => {
|
||||
setUndoStack((p) => [...p.slice(-30), strokesRef.current]);
|
||||
setRedoStack([]);
|
||||
@@ -597,7 +629,11 @@ export function NoteEditor({
|
||||
)}
|
||||
|
||||
{/* Content area */}
|
||||
<div ref={stageRef} style={{ flex: 1, position: "relative", overflow: "hidden" }}>
|
||||
<div
|
||||
ref={stageRef}
|
||||
onContextMenu={onTableContextMenu}
|
||||
style={{ flex: 1, position: "relative", overflow: "hidden" }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
@@ -686,6 +722,15 @@ export function NoteEditor({
|
||||
</div>
|
||||
</div>
|
||||
{mode === "text" && <BacklinksPanel backlinks={backlinks} onOpenNote={onOpenNote} dark={dark} />}
|
||||
{tableMenu && (
|
||||
<ContextMenu
|
||||
x={tableMenu.x}
|
||||
y={tableMenu.y}
|
||||
items={tableMenu.items}
|
||||
onClose={() => setTableMenu(null)}
|
||||
dark={dark}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,9 @@ const succeed = () => {
|
||||
const failWith = (status: number, body: unknown = { error: "nope" }) => {
|
||||
handler = () => ({ status, body });
|
||||
};
|
||||
const respondWith = (body: unknown) => {
|
||||
handler = () => ({ status: 200, body });
|
||||
};
|
||||
const failTransport = () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
@@ -320,7 +323,30 @@ describe("updateNoteContent", () => {
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
const put = calls.find((c) => c.method === "PUT");
|
||||
expect(put?.body).toEqual({ content: "body" });
|
||||
expect(put?.body).toMatchObject({ content: "body" });
|
||||
expect(put?.body).not.toHaveProperty("links");
|
||||
});
|
||||
|
||||
it("sends the revision the edit was based on, so the server can spot a conflict", async () => {
|
||||
await db.putNote(note("n1"));
|
||||
await db.putNoteContent("n1", "old", null, null, "2026-01-01T10:00:00.000Z");
|
||||
await withFakeDebounce(async () => {
|
||||
await sync.updateNoteContent("n1", "body");
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
const put = calls.find((c) => c.method === "PUT");
|
||||
expect((put?.body as Record<string, unknown>).baseUpdatedAt).toBe("2026-01-01T10:00:00.000Z");
|
||||
});
|
||||
|
||||
it("adopts the revision the server reports, so the next save is not taken for a conflict", async () => {
|
||||
await db.putNote(note("n1"));
|
||||
await db.putNoteContent("n1", "old", null, null, "2026-01-01T10:00:00.000Z");
|
||||
respondWith({ success: true, updatedAt: "2026-01-01T11:00:00.000Z" });
|
||||
await withFakeDebounce(async () => {
|
||||
await sync.updateNoteContent("n1", "body");
|
||||
await vi.runAllTimersAsync();
|
||||
});
|
||||
expect((await db.getNoteContent("n1"))?.updatedAt).toBe("2026-01-01T11:00:00.000Z");
|
||||
});
|
||||
|
||||
it("queues the change when the request fails", async () => {
|
||||
|
||||
@@ -101,8 +101,23 @@ export interface NoteUpdate {
|
||||
content?: string;
|
||||
notebookId?: string | null;
|
||||
starred?: boolean;
|
||||
tags?: string[];
|
||||
links?: string[];
|
||||
/**
|
||||
* The server revision this edit was written on top of.
|
||||
*
|
||||
* Lets the server notice that another device saved in between, and keep the
|
||||
* state it is about to bury as a version rather than losing it. Absent means
|
||||
* "do not check" — used for writes that cannot conflict, like starring.
|
||||
*/
|
||||
baseUpdatedAt?: string | null;
|
||||
}
|
||||
|
||||
/** What `PUT /notes/:id` answers with. */
|
||||
interface UpdateResult {
|
||||
success?: boolean;
|
||||
updatedAt?: string;
|
||||
/** True when another device had saved since this edit was started. */
|
||||
conflict?: boolean;
|
||||
}
|
||||
|
||||
/* ─── API helper ─── */
|
||||
@@ -189,6 +204,34 @@ const api = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Records the revision the server ended up at after our write.
|
||||
*
|
||||
* Without this every later edit would keep quoting the revision the note had
|
||||
* when it was first loaded, and the second save in a row would look like a
|
||||
* conflict with our own first one.
|
||||
*/
|
||||
async function adoptRevision(id: string, result: UpdateResult): Promise<void> {
|
||||
if (result?.updatedAt) {
|
||||
const cached = await db.getNoteContent(id);
|
||||
if (cached) {
|
||||
await db.putNoteContent(
|
||||
id,
|
||||
cached.content,
|
||||
cached.drawingData ?? null,
|
||||
cached.drawingStrokes ?? null,
|
||||
result.updatedAt
|
||||
);
|
||||
}
|
||||
}
|
||||
if (result?.conflict) {
|
||||
// The other device's state is kept server-side as a version marked
|
||||
// "conflict"; the interface points at the history rather than trying to
|
||||
// merge two documents nobody asked it to merge.
|
||||
notifyData("conflict", { id });
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Online status ─── */
|
||||
let _online = navigator.onLine;
|
||||
window.addEventListener("online", () => {
|
||||
@@ -502,7 +545,7 @@ async function processEntry(entry: QueueEntry): Promise<void> {
|
||||
break;
|
||||
}
|
||||
case "updateNote":
|
||||
await api.put(`/notes/${a.data.id}`, a.data.updates);
|
||||
await adoptRevision(a.data.id, await api.put<UpdateResult>(`/notes/${a.data.id}`, a.data.updates));
|
||||
break;
|
||||
case "updateDrawing":
|
||||
await api.put(`/notes/${a.data.id}/drawing`, { drawingStrokes: a.data.drawingStrokes ?? null });
|
||||
@@ -792,11 +835,16 @@ export async function updateNoteContent(id: string, content: string, links?: str
|
||||
notifyData("noteContent", { id });
|
||||
|
||||
// The server cannot read the encrypted body, so outgoing links travel with it.
|
||||
const updates: NoteUpdate = Array.isArray(links) ? { content, links } : { content };
|
||||
// baseUpdatedAt is the revision this copy came from — see NoteUpdate.
|
||||
const updates: NoteUpdate = Array.isArray(links)
|
||||
? { content, links, baseUpdatedAt: ex?.updatedAt ?? null }
|
||||
: { content, baseUpdatedAt: ex?.updatedAt ?? null };
|
||||
|
||||
debounce(`content_${id}`, async () => {
|
||||
if (_online && api.token && !note?._pendingCreate) {
|
||||
try {
|
||||
await trackWrite(id, () => api.put(`/notes/${id}`, updates));
|
||||
const r = await trackWrite(id, () => api.put<UpdateResult>(`/notes/${id}`, updates));
|
||||
await adoptRevision(id, r);
|
||||
notifyStatus("synced");
|
||||
return;
|
||||
} catch {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ✏️ InkNote
|
||||
|
||||
A cross-platform note-taking application with rich text editing, freehand drawing, PDF annotation, and encrypted sync — inspired by Samsung Notes.
|
||||
A self-hosted note-taking application with rich text editing, freehand drawing, full-text search and encrypted sync — inspired by Samsung Notes.
|
||||
|
||||
---
|
||||
|
||||
@@ -15,7 +15,6 @@ A cross-platform note-taking application with rich text editing, freehand drawin
|
||||
- **Image Attachments** — Pasted images are stored as deduplicated blobs, not inlined into the note
|
||||
- **Freehand Drawing** — Pen, highlighter, eraser with adjustable thickness and color palette (20 colors, 7 pen sizes)
|
||||
- **Undo/Redo** — Full history stack for drawing operations
|
||||
- **PDF Import & Annotation** — Import PDFs, overlay drawing/highlighting, persistent annotations
|
||||
- **Notebooks & Folders** — Create notebooks with custom gradient covers, organize notes by folder
|
||||
- **Star/Favorite** — Quick access to important notes
|
||||
- **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser
|
||||
@@ -23,7 +22,7 @@ A cross-platform note-taking application with rich text editing, freehand drawin
|
||||
|
||||
### Sync & Storage
|
||||
- **Offline-First** — Works fully offline, syncs when connection returns
|
||||
- **Conflict-Aware Sync** — Vector clock–based conflict detection; notifies user of merge conflicts
|
||||
- **Conflict-Aware Sync** — A write from a device that missed another's changes keeps the state it replaces as a version, rather than burying it
|
||||
- **WebSocket Real-Time Push** — Changes appear on other devices instantly
|
||||
- **Encrypted at Rest** — AES-256-CBC encryption for all note content on the server
|
||||
- **JWT Authentication** — Username + password accounts with bcrypt password hashing. No public
|
||||
@@ -84,7 +83,7 @@ host up:
|
||||
```
|
||||
$INKNOTE_DATA_DIR/
|
||||
├── db/ # inknote.db — notes, drawings, version history
|
||||
└── uploads/ # legacy raster drawings, PDFs
|
||||
└── uploads/ # image attachments, legacy raster drawings
|
||||
```
|
||||
|
||||
Default is `./data` for development and `/srv/inknote` in production; override
|
||||
@@ -234,13 +233,11 @@ deleted person's session stops working immediately: tokens are signed rather tha
|
||||
| GET | `/api/links` | Every link, for backlink counts |
|
||||
| DELETE | `/api/notes/:id` | Soft-delete note |
|
||||
| PUT | `/api/notes/:id/drawing` | Save vector strokes |
|
||||
| POST | `/api/notes/pdf` | Upload PDF (multipart/form) |
|
||||
|
||||
### Sync
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|------------- |------------------------------------------|
|
||||
| POST | `/api/sync` | Bidirectional sync with conflict reports |
|
||||
|
||||
### WebSocket
|
||||
|
||||
@@ -328,10 +325,11 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
|
||||
| Suite | Checks | What it holds down |
|
||||
| --- | --- | --- |
|
||||
| `admin` | 15 | no sign-up anywhere; `/api/admin/*` refused for non-admins even when called directly |
|
||||
| `editor` | 12 | tables, task lists, note links, backlinks — and that each survives a reload |
|
||||
| `editor` | 17 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload |
|
||||
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
|
||||
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
|
||||
| `drawing` | 7 | strokes survive the poller, and match across four viewport widths |
|
||||
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
|
||||
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
|
||||
|
||||
Each suite creates a throwaway account through the admin API and deletes it
|
||||
@@ -550,13 +548,29 @@ underneath any newer strokes so nothing is lost.
|
||||
|
||||
## Conflict Resolution
|
||||
|
||||
InkNote uses **vector clocks** to detect conflicts:
|
||||
Two devices can edit the same note without knowing about each other — a phone
|
||||
that was offline all day, a laptop that was not. When the phone reconnects, its
|
||||
save arrives last, and without care it would simply bury the newer work.
|
||||
|
||||
1. Each device maintains a logical clock counter
|
||||
2. On save, the device increments its counter and includes the full vector clock
|
||||
3. The server compares clocks — if the client clock is strictly newer, the update applies
|
||||
4. If clocks are concurrent (both have changes the other doesn't), a **409 Conflict** is returned
|
||||
5. The client receives both versions and can present a merge UI to the user
|
||||
Each write carries `baseUpdatedAt`: the revision the editing device had when it
|
||||
started. The server compares it with the note's current revision:
|
||||
|
||||
1. **Same** — an ordinary save, nothing to think about.
|
||||
2. **Server is newer** — someone else saved in between. The incoming edit still
|
||||
wins, because rejecting it would throw away whatever was just typed, and on a
|
||||
phone that has been offline for a day that is the worse loss. But the state
|
||||
it replaces is kept as a version marked `conflict`, so both exist, and the
|
||||
history dialog is where you pick which one to keep.
|
||||
|
||||
This replaced vector clocks, which sat in the server unused: no client ever sent
|
||||
one. They can tell a genuine conflict from a device merely catching up, which is
|
||||
strictly more accurate — but that accuracy only pays for itself with a merge
|
||||
interface to spend it on, and comparing revisions needs one field and reuses the
|
||||
version history that already exists.
|
||||
|
||||
**What this does not do:** it will not tell you at the moment it happens. The
|
||||
conflicting version appears in the history, labelled, and that is where you find
|
||||
it. A notification is worth adding if it ever actually bites.
|
||||
|
||||
---
|
||||
|
||||
@@ -566,10 +580,10 @@ InkNote uses **vector clocks** to detect conflicts:
|
||||
Use `note-app.jsx` components with React Native adaptations (replace HTML canvas with `react-native-canvas` or `@shopify/react-native-skia` for drawing). The sync engine works as-is.
|
||||
|
||||
### Flutter
|
||||
Use the sync engine via a JavaScript bridge, or port the API calls to Dart. For drawing, use Flutter's `CustomPainter`. For PDF, use `flutter_pdfview`.
|
||||
Use the sync engine via a JavaScript bridge, or port the API calls to Dart. For drawing, use Flutter's `CustomPainter`.
|
||||
|
||||
### Electron
|
||||
Wrap the React app directly in Electron for desktop. Add native file system access for PDF import/export.
|
||||
Wrap the React app directly in Electron for desktop. Add native file system access for export.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in new issue
Block a user