diff --git a/backend/server.js b/backend/server.js index 84fa5bf..082a09c 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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); diff --git a/e2e/conflict.mjs b/e2e/conflict.mjs new file mode 100644 index 0000000..63edf9b --- /dev/null +++ b/e2e/conflict.mjs @@ -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); +} diff --git a/e2e/editor.mjs b/e2e/editor.mjs index c99d212..31384a3 100644 --- a/e2e/editor.mjs +++ b/e2e/editor.mjs @@ -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); diff --git a/e2e/run.sh b/e2e/run.sh index b18e0b5..7a7f48f 100755 --- a/e2e/run.sh +++ b/e2e/run.sh @@ -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 diff --git a/frontend/src/editor/tableMenu.js b/frontend/src/editor/tableMenu.js new file mode 100644 index 0000000..f9460c7 --- /dev/null +++ b/frontend/src/editor/tableMenu.js @@ -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; +} diff --git a/frontend/src/features/notes/HistoryModal.jsx b/frontend/src/features/notes/HistoryModal.jsx index 5a904d9..9306470 100644 --- a/frontend/src/features/notes/HistoryModal.jsx +++ b/frontend/src/features/notes/HistoryModal.jsx @@ -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", diff --git a/frontend/src/features/notes/NoteEditor.jsx b/frontend/src/features/notes/NoteEditor.jsx index b9b8b61..f006937 100644 --- a/frontend/src/features/notes/NoteEditor.jsx +++ b/frontend/src/features/notes/NoteEditor.jsx @@ -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 */} -