/** * InkNote Sync Engine — online-first with an offline fallback. * * When online: server call → cache into IndexedDB (server ids everywhere). * When offline: IndexedDB plus a queue, flushed on reconnect. */ import * as db from "./offlineStore"; import type { Backlink, ManagedUser, NoteContent, NoteMeta, NoteVersion, NoteVersionSummary, Notebook, QueueEntry, StrokeDoc, SyncStatus, User, UserPreferences, AttachmentInfo, } from "./types"; const REQUEST_TIMEOUT_MS = 12000; const DEVICE_ID: string = (() => { try { let d = localStorage.getItem("inknote_device"); if (!d) { d = "dev_" + Math.random().toString(36).slice(2, 10); localStorage.setItem("inknote_device", d); } return d; } catch { return "dev_" + Math.random().toString(36).slice(2, 10); } })(); /* ─── Errors ─── * * Failures used to be plain Errors distinguished by comparing `.message` to a * magic string. The code now travels in a typed field; `message` still carries * it so the remaining JavaScript callers keep working unchanged. */ export type SyncErrorCode = "AUTH" | "NETWORK" | "TIMEOUT" | "REQUEST_FAILED"; export class SyncError extends Error { readonly code: SyncErrorCode; constructor(code: SyncErrorCode, message: string = code) { super(message); this.name = "SyncError"; this.code = code; } } const codeOf = (e: unknown): SyncErrorCode | null => { if (e instanceof SyncError) return e.code; const msg = (e as Error | undefined)?.message; return msg === "AUTH" || msg === "NETWORK" || msg === "TIMEOUT" ? msg : null; }; const isAuthError = (e: unknown): boolean => codeOf(e) === "AUTH"; /** True while the failure looks like "no usable connection", not "bad request". */ const isOffline = (e: unknown): boolean => { const c = codeOf(e); return c === "NETWORK" || c === "TIMEOUT"; }; /* ─── Server response shapes ─── * SQLite columns arrive snake_cased; the UI works in camelCase. */ interface ServerNotebook { id: string; name: string; cover_index?: number; sort_order?: number; max_versions?: number | null; sort_mode?: string | null; separators?: string | null; } interface ServerNote { id: string; title: string; notebook_id: string | null; starred?: number | boolean; created_at: string; updated_at?: string; deleted_at?: string; type?: string; sort_order?: number; pdf_attachment_id?: string | null; } interface ServerNoteBody { content?: string; drawingData?: string | null; drawingStrokes?: StrokeDoc | null; updated_at?: string; } interface AuthResponse { token: string; user: User; } /** Fields `PUT /notes/:id` accepts. */ export interface NoteUpdate { title?: string; content?: string; notebookId?: string | null; starred?: boolean; links?: string[]; /** Attachments the body embeds, so ones nothing uses can be released. */ attachments?: 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 ─── */ const api = { base: "/api", token: null as string | null, async req(method: string, path: string, body?: unknown): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); const headers: Record = { "Content-Type": "application/json", "X-Device-Id": DEVICE_ID, }; if (this.token) headers["Authorization"] = "Bearer " + this.token; try { let r: Response; try { r = await fetch(this.base + path, { method, headers, signal: controller.signal, ...(body ? { body: JSON.stringify(body) } : {}), }); } catch (err) { throw new SyncError((err as Error)?.name === "AbortError" ? "TIMEOUT" : "NETWORK"); } if (r.status === 401) { this.forgetToken(); throw new SyncError("AUTH"); } let data: unknown = {}; try { data = await r.json(); } catch { /* an empty or non-JSON body is fine for 204-style replies */ } if (!r.ok) { const message = (data as { error?: string })?.error || "Request failed"; throw new SyncError("REQUEST_FAILED", message); } return data as T; } finally { clearTimeout(timeoutId); } }, forgetToken() { this.token = null; try { localStorage.removeItem("inknote_token"); } catch { /* private mode — the in-memory token is already cleared */ } }, get(p: string) { return this.req("GET", p); }, post(p: string, b?: unknown) { return this.req("POST", p, b); }, put(p: string, b?: unknown) { return this.req("PUT", p, b); }, del(p: string) { return this.req("DELETE", p); }, /** Sends raw bytes; req() would JSON-encode them and inflate the payload. */ async postBinary(path: string, body: Blob, contentType: string): Promise { const headers: Record = { "Content-Type": contentType, "X-Device-Id": DEVICE_ID }; if (this.token) headers["Authorization"] = "Bearer " + this.token; // No timeout here: a large upload on a slow line is not a hung request. const r = await fetch(this.base + path, { method: "POST", headers, body }); if (r.status === 401) { this.forgetToken(); throw new SyncError("AUTH"); } let data: unknown = {}; try { data = await r.json(); } catch { /* an error page without JSON still needs a sensible failure */ } if (!r.ok) throw new SyncError("REQUEST_FAILED", (data as { error?: string })?.error || "Upload failed"); return data as T; }, /** Binary responses cannot go through req(), which always parses JSON. */ async getBlob(path: string): Promise { const headers: Record = { "X-Device-Id": DEVICE_ID }; if (this.token) headers["Authorization"] = "Bearer " + this.token; const r = await fetch(this.base + path, { headers }); if (r.status === 401) { this.forgetToken(); throw new SyncError("AUTH"); } if (!r.ok) throw new SyncError("REQUEST_FAILED"); return r.blob(); }, }; /** * 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 { 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", () => { _online = true; notifyStatus("syncing"); flushQueue() .then(() => fullSync()) .catch(() => {}); }); window.addEventListener("offline", () => { _online = false; notifyStatus("offline"); }); export function isOnline(): boolean { return _online; } /* ─── Status and change notifications ─── */ type StatusListener = (s: SyncStatus) => void; type DataListener = (topic: string, payload: unknown) => void; let _syncStatus: SyncStatus = "synced"; let _statusCbs: StatusListener[] = []; let _dataCbs: DataListener[] = []; function notifyStatus(s: SyncStatus): void { _syncStatus = s; _statusCbs.forEach((fn) => fn(s)); } export function onSyncStatus(fn: StatusListener): () => void { _statusCbs.push(fn); fn(_syncStatus); return () => { _statusCbs = _statusCbs.filter((f) => f !== fn); }; } function notifyData(topic: string, payload: unknown): void { _dataCbs.forEach((fn) => fn(topic, payload)); } export function onDataChange(fn: DataListener): () => void { _dataCbs.push(fn); return () => { _dataCbs = _dataCbs.filter((f) => f !== fn); }; } /* ─── Auth ─── */ async function acceptAuth(data: AuthResponse): Promise { api.token = data.token; await db.setMeta("token", data.token); await db.setMeta("user", data.user); try { localStorage.setItem("inknote_token", data.token); } catch { /* session-only login is still a login */ } return data.user; } export async function login(username: string, password: string): Promise { return acceptAuth(await api.post("/auth/login", { username, password })); } /* There is no register(): this instance has no public sign-up. Accounts are * created by an administrator through the functions under "Administration". */ /** * Restores a session from storage. * * Offline with a token but no cached profile still counts as signed in — the * notes are local anyway — so this can resolve to a user without details. */ export async function restoreSession(): Promise | null> { let token = await db.getMeta("token"); if (!token) { try { token = localStorage.getItem("inknote_token"); } catch { token = null; } } if (!token) return null; api.token = token; if (_online) { try { const u = await api.get("/auth/me"); await db.setMeta("user", u); return u; } catch (e) { if (isAuthError(e)) { await logout(); return null; } } } return (await db.getMeta("user")) || {}; } export async function logout(): Promise { api.forgetToken(); await db.clearAll(); } /* ─── Account ─── */ export async function updatePreferences(preferences: Partial): Promise { return api.put("/auth/preferences", preferences); } export async function updateProfile(displayName: string): Promise { const u = await api.put("/auth/profile", { displayName }); await db.setMeta("user", u); return u; } export async function updateUsername(newUsername: string, password: string): Promise { const u = await api.put("/auth/username", { newUsername, password }); await db.setMeta("user", u); return u; } export async function updatePassword(currentPassword: string, newPassword: string): Promise { await api.put("/auth/password", { currentPassword, newPassword }); } /* ─── Full sync ─── * * Replaces the local mirror with the server's view. * * Anything created offline lives only in IndexedDB until its queue entry is * pushed, and is marked `_pendingCreate`. A plain clear-and-refill wiped those * notes and notebooks — the queue still held the create, but the content it * referenced was already gone. They are carried over explicitly instead. */ export async function fullSync(): Promise { if (!_online || !api.token) return; notifyStatus("syncing"); try { await flushQueue(); const localNbs = await db.getAllNotebooks(); const serverNbs = await api.get("/notebooks"); const nbs: Notebook[] = serverNbs.map((nb) => ({ id: nb.id, name: nb.name, cover: nb.cover_index || 0, sortOrder: nb.sort_order || 0, maxVersions: nb.max_versions ?? null, sortMode: (nb.sort_mode as Notebook["sortMode"]) ?? null, separators: parseSeparators(nb.separators), })); const serverNbIds = new Set(nbs.map((nb) => nb.id)); const unsyncedNbs = localNbs.filter((nb) => nb._pendingCreate && !serverNbIds.has(nb.id)); const localNotes = await db.getAllNotes(); const serverNotes = await api.get("/notes"); const active: NoteMeta[] = serverNotes.map((n) => toNoteMeta(n, false)); let trashed: NoteMeta[] = []; try { const st = await api.get("/notes/trash"); trashed = st.map((n) => toNoteMeta(n, true)); } catch { /* the bin is optional context; an empty one must not fail the sync */ } const serverNoteIds = new Set([...active, ...trashed].map((n) => n.id)); const unsyncedNotes = localNotes.filter((n) => n._pendingCreate && !serverNoteIds.has(n.id)); // Only touch local storage once both server reads have succeeded, so a // failure halfway through leaves the mirror intact. await db.clearNotebooks(); const allNbs = [...nbs, ...unsyncedNbs]; if (allNbs.length > 0) await db.putNotebooks(allNbs); await db.clearNotes(); const all = [...active, ...trashed, ...unsyncedNotes]; if (all.length > 0) await db.putNotes(all); if (unsyncedNotes.length || unsyncedNbs.length) { console.info( `[sync] kept ${unsyncedNotes.length} note(s) and ${unsyncedNbs.length} notebook(s) not yet on the server` ); } notifyStatus("synced"); notifyData("fullSync", null); } catch (e) { if (isAuthError(e)) throw e; notifyStatus("error"); } } /** Separators travel as a JSON string; a damaged one must not fail the sync. */ function parseSeparators(raw: string | null | undefined): Notebook["separators"] { if (!raw) return []; try { const parsed = JSON.parse(raw); return Array.isArray(parsed) ? parsed : []; } catch { return []; } } function toNoteMeta(n: ServerNote, trashed: boolean): NoteMeta { return { id: n.id, title: n.title, folderId: n.notebook_id, starred: !!n.starred, trashed, createdAt: n.created_at, updatedAt: n.updated_at || n.deleted_at || n.created_at, type: n.type === "pdf" ? "pdf" : "text", sortOrder: n.sort_order ?? 0, pdfAttachmentId: n.pdf_attachment_id ?? null, }; } /* ─── Queue ─── */ /** Every kind of change that can wait offline. */ type QueuedAction = | { action: "createNotebook"; data: { localId: string; name: string; cover: number; sortOrder: number } } | { action: "updateNotebook"; data: { id: string; name?: string; cover?: number; sortOrder?: number; maxVersions?: number | null; sortMode?: Notebook["sortMode"]; separators?: Notebook["separators"]; }; } | { action: "deleteNotebook"; data: { id: string } } | { action: "createNote"; data: { localId: string; title: string; folderId: string | null } } | { action: "updateNote"; data: { id: string; updates: NoteUpdate } } | { action: "updateDrawing"; data: { id: string; drawingStrokes: StrokeDoc | null } } | { action: "trashNote"; data: { id: string } } | { action: "restoreNote"; data: { id: string } } | { action: "permanentDelete"; data: { id: string } } | { action: "emptyTrash"; data: Record }; /** Typed wrapper around the generic store, so producers cannot enqueue nonsense. */ function enqueue(a: QueuedAction): Promise { return db.enqueue(a as unknown as { action: string; data: Record }); } const MAX_QUEUE_ATTEMPTS = 5; let _flushing = false; /** * Pushes queued changes to the server. * * An entry used to be dequeued whether or not it succeeded — a single failed * request silently threw away an offline edit. Now a failure leaves the entry * queued for the next flush, and only gives up after repeated attempts so one * permanently broken entry cannot block everything behind it. */ async function flushQueue(): Promise { if (_flushing || !_online || !api.token) return; _flushing = true; try { const pending = await db.peekQueue(100); for (const entry of pending) { try { await processEntry(entry); await db.dequeue(entry.queueId); } catch (e) { if (isAuthError(e)) throw e; if (!_online || isOffline(e)) { notifyStatus("pending"); break; // still offline — keep everything for the next attempt } const attempts = (entry.attempts || 0) + 1; const message = (e as Error)?.message ?? "unknown"; if (attempts >= MAX_QUEUE_ATTEMPTS) { console.error("[sync] giving up on entry after", attempts, "attempts:", entry.action, message); await db.dequeue(entry.queueId); notifyStatus("error"); } else { console.warn("[sync] retrying later:", entry.action, message); await db.updateQueueEntry(entry.queueId, { attempts, lastError: message }); notifyStatus("pending"); } } } } finally { _flushing = false; } } async function processEntry(entry: QueueEntry): Promise { // The store is intentionally generic; this is the one place that knows what a // queued payload means, so it is also the one place that narrows it. const a = entry as unknown as QueuedAction; switch (a.action) { case "createNotebook": { const d = a.data; const r = await api.post<{ id: string }>("/notebooks", { name: d.name, coverIndex: d.cover }); if (r.id !== d.localId) { // The server assigned a different id — repoint everything that referenced // the temporary local one. await db.deleteNotebook(d.localId); await db.putNotebook({ id: r.id, name: d.name, cover: d.cover, sortOrder: d.sortOrder || 0 }); for (const n of await db.getAllNotes()) { if (n.folderId === d.localId) await db.putNote({ ...n, folderId: r.id }); } } break; } case "updateNotebook": await api.put(`/notebooks/${a.data.id}`, { name: a.data.name, coverIndex: a.data.cover, sortOrder: a.data.sortOrder, ...(a.data.maxVersions !== undefined ? { maxVersions: a.data.maxVersions } : {}), ...(a.data.sortMode !== undefined ? { sortMode: a.data.sortMode } : {}), ...(a.data.separators !== undefined ? { separators: a.data.separators } : {}), }); break; case "deleteNotebook": await api.del(`/notebooks/${a.data.id}`); break; case "createNote": { const d = a.data; const content = await db.getNoteContent(d.localId); const r = await api.post<{ id: string }>("/notes", { title: d.title, notebookId: d.folderId, content: content?.content || "", }); if (r.id !== d.localId) { const old = await db.getNote(d.localId); const oldC = await db.getNoteContent(d.localId); await db.deleteNote(d.localId); if (old) { const { _pendingCreate, ...rest } = old; void _pendingCreate; await db.putNote({ ...rest, id: r.id }); } if (oldC) await db.putNoteContent(r.id, oldC.content, oldC.drawingData, oldC.drawingStrokes ?? null); } break; } case "updateNote": await adoptRevision(a.data.id, await api.put(`/notes/${a.data.id}`, a.data.updates)); break; case "updateDrawing": await api.put(`/notes/${a.data.id}/drawing`, { drawingStrokes: a.data.drawingStrokes ?? null }); break; case "trashNote": await api.del(`/notes/${a.data.id}`); break; case "restoreNote": await api.put(`/notes/${a.data.id}/restore`, {}); break; case "permanentDelete": await api.del(`/notes/${a.data.id}/permanent`); break; case "emptyTrash": await api.del("/notes/trash/empty"); break; default: { // A queue written by a newer version of the app. Dropping it silently // would lose data, so make the noise visible. const unknown: never = a; console.warn("[sync] unknown queued action, skipping:", (unknown as QueuedAction).action); } } } /* ─── Debounce ─── */ const _timers: Record | undefined> = {}; function debounce(key: string, fn: () => void, ms = 800): void { const existing = _timers[key]; if (existing) clearTimeout(existing); _timers[key] = setTimeout(() => { delete _timers[key]; fn(); }, ms); } /* ─── In-flight writes ─── * * A note has "pending writes" from the moment it is edited until its request has * been acknowledged. Readers use this to avoid adopting a server response that * was already on its way before the edit — doing so silently reverted fresh * edits, which is how note links kept disappearing on reload. */ const _pendingWrites = new Map(); function beginWrite(id: string): void { _pendingWrites.set(id, (_pendingWrites.get(id) || 0) + 1); } function endWrite(id: string): void { const left = (_pendingWrites.get(id) || 1) - 1; if (left > 0) _pendingWrites.set(id, left); else _pendingWrites.delete(id); } async function trackWrite(id: string, run: () => Promise): Promise { beginWrite(id); try { return await run(); } finally { endWrite(id); } } export function hasPendingWrites(id: string | null | undefined): boolean { if (!id) return false; return ( _pendingWrites.has(id) || _timers[`content_${id}`] != null || _timers[`drawing_${id}`] != null || _timers[`title_${id}`] != null ); } /* ════════ PUBLIC DATA API ════════ */ export async function getNotebooks(): Promise { return db.getAllNotebooks(); } export async function createNotebook({ name, cover }: { name: string; cover: number }): Promise { if (_online && api.token) { try { const r = await api.post<{ id: string }>("/notebooks", { name, coverIndex: cover }); const nb: Notebook = { id: r.id, name, cover, sortOrder: (await db.getAllNotebooks()).length }; await db.putNotebook(nb); notifyData("notebook", nb); return nb; } catch (e) { if (isAuthError(e)) throw e; } } const lid = "nb_" + Math.random().toString(36).slice(2, 10); // Flagged so fullSync knows this exists only locally and must not drop it. const nb: Notebook = { id: lid, name, cover, sortOrder: (await db.getAllNotebooks()).length, _pendingCreate: true, }; await db.putNotebook(nb); await enqueue({ action: "createNotebook", data: { localId: lid, name, cover, sortOrder: nb.sortOrder }, }); notifyData("notebook", nb); notifyStatus("pending"); return nb; } export async function updateNotebook(id: string, updates: Partial): Promise { const nbs = await db.getAllNotebooks(); const nb = nbs.find((n) => n.id === id); if (!nb) return; const upd = { ...nb, ...updates }; await db.putNotebook(upd); notifyData("notebook", upd); if (_online && api.token) { try { await api.put(`/notebooks/${id}`, { name: updates.name, coverIndex: updates.cover, sortOrder: updates.sortOrder, // null is meaningful here — it clears the notebook's own setting — so it // has to travel, while undefined must stay away and change nothing. ...(updates.maxVersions !== undefined ? { maxVersions: updates.maxVersions } : {}), ...(updates.sortMode !== undefined ? { sortMode: updates.sortMode } : {}), ...(updates.separators !== undefined ? { separators: updates.separators } : {}), }); return; } catch (e) { if (isAuthError(e)) throw e; } } await enqueue({ action: "updateNotebook", data: { id, name: updates.name, cover: updates.cover, sortOrder: updates.sortOrder, maxVersions: updates.maxVersions, sortMode: updates.sortMode, separators: updates.separators, }, }); notifyStatus("pending"); } export async function deleteNotebookLocal(id: string): Promise { for (const n of await db.getAllNotes()) { if (n.folderId === id && !n.trashed) await db.putNote({ ...n, trashed: true }); } await db.deleteNotebook(id); notifyData("notebook", { id, deleted: true }); if (_online && api.token) { try { await api.del(`/notebooks/${id}`); return; } catch (e) { if (isAuthError(e)) throw e; } } await enqueue({ action: "deleteNotebook", data: { id } }); notifyStatus("pending"); } export async function reorderNotebooks(orderedIds: string[]): Promise { const nbs = await db.getAllNotebooks(); for (let i = 0; i < orderedIds.length; i++) { const id = orderedIds[i]; const nb = nbs.find((n) => n.id === id); if (nb) await db.putNotebook({ ...nb, sortOrder: i }); } notifyData("notebooks", null); if (_online && api.token) { for (let i = 0; i < orderedIds.length; i++) { api.put(`/notebooks/${orderedIds[i]}`, { sortOrder: i }).catch(() => {}); } } } export async function getNotes(): Promise { return db.getAllNotes(); } /** * Writes the user's own order for a set of notes. * * Its own route, not a note update: `PUT /notes/:id` bumps `updated_at` and * snapshots a version, so dragging a note would move it to the top of "last * changed" and spend a slot of its history on a change that is not to the note. * * Applied locally first, so a drag holds even offline, and queued as a plain * note update would be wrong for the same reason — it is sent when there is a * connection and simply re-derived from the local order otherwise. */ export async function reorderNotes(orderedIds: string[]): Promise { const notes = await db.getAllNotes(); for (let i = 0; i < orderedIds.length; i++) { const note = notes.find((n) => n.id === orderedIds[i]); if (note && note.sortOrder !== i) await db.putNote({ ...note, sortOrder: i }); } notifyData("notes", null); if (_online && api.token) { api.post("/notes/reorder", { ids: orderedIds }).catch(() => {}); } } const shapeContent = (r: (ServerNoteBody & Partial) | undefined | null): NoteContent => ({ content: r?.content || "", drawingData: r?.drawingData || null, drawingStrokes: r?.drawingStrokes || null, updatedAt: r?.updated_at || r?.updatedAt || null, }); export async function loadNoteContent( id: string, opts: { forceRemote?: boolean } = {} ): Promise { // Local-first, so the editor opens instantly and a slow request cannot block it. if (!opts.forceRemote) { const local = await db.getNoteContent(id); if (local) { // Refresh the cache in the background, but never while our own writes are // still in flight — that response predates them. if (_online && api.token && !hasPendingWrites(id)) { api .get(`/notes/${id}`) .then(async (d) => { if (hasPendingWrites(id)) return; const c = shapeContent(d); await db.putNoteContent(id, c.content, c.drawingData, c.drawingStrokes, c.updatedAt); }) .catch(() => {}); } return shapeContent(local); } } // No local copy, or a forced refresh: try the server, then fall back to cache. if (_online && api.token) { try { const d = await api.get(`/notes/${id}`); const c = shapeContent(d); await db.putNoteContent(id, c.content, c.drawingData, c.drawingStrokes, c.updatedAt); return c; } catch { /* fall through to whatever is cached */ } } return shapeContent(await db.getNoteContent(id)); } export async function createNote(folderId: string | null): Promise { const ts = new Date().toISOString(); const base = { title: "Untitled Note", folderId, starred: false, trashed: false, createdAt: ts, updatedAt: ts, }; if (_online && api.token) { try { const r = await api.post<{ id: string }>("/notes", { title: base.title, notebookId: folderId, content: "", }); const note: NoteMeta = { id: r.id, ...base }; await db.putNote(note); await db.putNoteContent(r.id, "", null); notifyData("note", note); return note; } catch (e) { if (isAuthError(e)) throw e; } } const lid = "local_" + Math.random().toString(36).slice(2, 10); const note: NoteMeta = { id: lid, ...base, _pendingCreate: true }; await db.putNote(note); await db.putNoteContent(lid, "", null); await enqueue({ action: "createNote", data: { localId: lid, title: base.title, folderId } }); notifyData("note", note); notifyStatus("pending"); return note; } export async function updateNoteTitle(id: string, title: string): Promise { const note = await db.getNote(id); if (!note) return; const updated: NoteMeta = { ...note, title, updatedAt: new Date().toISOString() }; await db.putNote(updated); notifyData("note", updated); debounce(`title_${id}`, async () => { if (_online && api.token && !updated._pendingCreate) { try { await trackWrite(id, () => api.put(`/notes/${id}`, { title })); notifyStatus("synced"); return; } catch { /* queue it below */ } } await enqueue({ action: "updateNote", data: { id, updates: { title } } }); notifyStatus("pending"); }); } export async function updateNoteContent( id: string, content: string, links?: string[], attachments?: string[] ): Promise { const ex = await db.getNoteContent(id); await db.putNoteContent(id, content, ex?.drawingData || null, ex?.drawingStrokes || null); const note = await db.getNote(id); if (note) await db.putNote({ ...note, updatedAt: new Date().toISOString() }); notifyData("noteContent", { id }); // The server cannot read the encrypted body, so outgoing links travel with it. // baseUpdatedAt is the revision this copy came from — see NoteUpdate. const updates: NoteUpdate = { content, baseUpdatedAt: ex?.updatedAt ?? null }; if (Array.isArray(links)) updates.links = links; if (Array.isArray(attachments)) updates.attachments = attachments; debounce(`content_${id}`, async () => { if (_online && api.token && !note?._pendingCreate) { try { const r = await trackWrite(id, () => api.put(`/notes/${id}`, updates)); await adoptRevision(id, r); notifyStatus("synced"); return; } catch { /* queue it below */ } } await enqueue({ action: "updateNote", data: { id, updates } }); notifyStatus("pending"); }); } /** Drawings sync as vector strokes; saving strokes retires the legacy raster. */ export async function updateNoteDrawing(id: string, drawingStrokes: StrokeDoc | null): Promise { const ex = await db.getNoteContent(id); await db.putNoteContent(id, ex?.content || "", null, drawingStrokes); notifyData("noteContent", { id }); debounce(`drawing_${id}`, async () => { const note = await db.getNote(id); if (_online && api.token && !note?._pendingCreate) { try { await trackWrite(id, () => api.put(`/notes/${id}/drawing`, { drawingStrokes })); notifyStatus("synced"); return; } catch { /* queue it below */ } } await enqueue({ action: "updateDrawing", data: { id, drawingStrokes } }); notifyStatus("pending"); }); } export async function toggleNoteStar(id: string): Promise { const note = await db.getNote(id); if (!note) return false; const updated: NoteMeta = { ...note, starred: !note.starred }; await db.putNote(updated); notifyData("note", updated); if (_online && api.token && !updated._pendingCreate) { api.put(`/notes/${id}`, { starred: updated.starred }).catch(() => {}); } else { await enqueue({ action: "updateNote", data: { id, updates: { starred: updated.starred } } }); notifyStatus("pending"); } return updated.starred; } export async function moveNote(id: string, folderId: string | null): Promise { const note = await db.getNote(id); if (!note) return; const updated: NoteMeta = { ...note, folderId, updatedAt: new Date().toISOString() }; await db.putNote(updated); notifyData("note", updated); if (_online && api.token && !updated._pendingCreate) { api.put(`/notes/${id}`, { notebookId: folderId }).catch(() => {}); } else { await enqueue({ action: "updateNote", data: { id, updates: { notebookId: folderId } } }); notifyStatus("pending"); } } export async function trashNote(id: string): Promise { const note = await db.getNote(id); if (!note) return; const updated: NoteMeta = { ...note, trashed: true, updatedAt: new Date().toISOString() }; await db.putNote(updated); notifyData("note", updated); if (_online && api.token && !updated._pendingCreate) { try { await api.del(`/notes/${id}`); } catch { await enqueue({ action: "trashNote", data: { id } }); notifyStatus("pending"); } } else { await enqueue({ action: "trashNote", data: { id } }); notifyStatus("pending"); } } export async function restoreNoteLocal(id: string): Promise { const note = await db.getNote(id); if (!note) return; const updated: NoteMeta = { ...note, trashed: false, updatedAt: new Date().toISOString() }; await db.putNote(updated); notifyData("note", updated); if (_online && api.token) { try { await api.put(`/notes/${id}/restore`, {}); return; } catch { /* queue it below */ } } await enqueue({ action: "restoreNote", data: { id } }); notifyStatus("pending"); } export async function permanentDeleteNoteLocal(id: string): Promise { await db.deleteNote(id); notifyData("note", { id, deleted: true }); if (_online && api.token) { try { await api.del(`/notes/${id}/permanent`); return; } catch { /* queue it below */ } } await enqueue({ action: "permanentDelete", data: { id } }); notifyStatus("pending"); } export async function emptyTrashLocal(): Promise { for (const n of await db.getAllNotes()) { if (n.trashed) await db.deleteNote(n.id); } notifyData("notes", null); if (_online && api.token) { try { await api.del("/notes/trash/empty"); return; } catch { /* queue it below */ } } await enqueue({ action: "emptyTrash", data: {} }); notifyStatus("pending"); } /* ─── Attachments ─── */ export async function uploadAttachment(dataUrl: string, filename?: string): Promise { return api.post("/attachments", { data: dataUrl, filename }); } export async function fetchAttachmentBlob(id: string): Promise { return api.getBlob(`/attachments/${id}`); } /* ─── PDFs ─── * * A PDF rides on the attachment machinery — encrypted at rest, named by content * hash, shared between notes holding the same file — but takes its own route up, * as raw bytes rather than base64 inside JSON. */ export async function uploadPdf(file: Blob, filename?: string): Promise { const query = filename ? `?filename=${encodeURIComponent(filename)}` : ""; return api.postBinary(`/attachments/pdf${query}`, file, "application/pdf"); } /** * The bytes of a PDF, from the local cache when possible. * * Cached on first read, so a document stays readable offline and a reopened note * does not re-download tens of megabytes. Content-addressed storage means the * bytes behind an id never change, so a cached copy can never be stale. */ export async function loadPdfBytes(attachmentId: string): Promise { const cached = await db.getAttachmentBlob(attachmentId); if (cached) return new Uint8Array(await cached.arrayBuffer()); const blob = await api.getBlob(`/attachments/${attachmentId}`); try { await db.putAttachmentBlob(attachmentId, blob); } catch { /* a full quota costs us the cache, not the document */ } return new Uint8Array(await blob.arrayBuffer()); } /** Creates a note that holds a PDF. */ export async function createPdfNote( folderId: string | null, title: string, pdfAttachmentId: string ): Promise { const r = await api.post<{ id: string }>("/notes", { type: "pdf", title, notebookId: folderId, pdfAttachmentId, }); const now = new Date().toISOString(); const meta: NoteMeta = { id: r.id, title, folderId, starred: false, trashed: false, createdAt: now, updatedAt: now, type: "pdf", pdfAttachmentId, }; await db.putNote(meta); notifyData("notes", null); return meta; } /* ─── Content prefetch ─── * * Note bodies are normally fetched only when a note is opened, which leaves * search with almost nothing to look at on a fresh device. This walks the notes * with no cached body (or a stale one) and fills them in, deliberately slowly so * it never competes with what the user is doing. */ /** * Tells the server which attachments a note body uses. * * Notes written before attachment references existed have never said, and the * server cannot look inside an encrypted body to find out. Until every note has * reported, nothing is ever released — so this pass, riding along with the * prefetch that fills the search index, is what eventually lets unused pictures * be freed. Sent even when empty: "uses nothing" is the answer that matters. */ async function reportAttachments(noteId: string, content: string): Promise { try { const { parseNoteContent, extractAttachmentIds } = await import("./editor/content"); const { parsingExtensions } = await import("./editor/extensions.js"); const ids = extractAttachmentIds(parseNoteContent(content, parsingExtensions)); await api.put(`/notes/${noteId}`, { attachments: ids }); } catch { /* one note that will not parse must not stop the pass */ } } export interface PrefetchProgress { id: string; content: string; fetched: number; total: number; } export async function prefetchAllContent({ onProgress, signal, }: { onProgress?: (p: PrefetchProgress) => void; signal?: { aborted: boolean }; } = {}): Promise<{ fetched: number; total: number }> { if (!_online || !api.token) return { fetched: 0, total: 0 }; const notes = await db.getAllNotes(); const pending: NoteMeta[] = []; for (const n of notes) { if (n._pendingCreate) continue; const local = await db.getNoteContent(n.id); const stale = !!local?.updatedAt && !!n.updatedAt && n.updatedAt > local.updatedAt; if (!local || stale) pending.push(n); } let fetched = 0; for (const n of pending) { if (signal?.aborted || !_online) break; if (hasPendingWrites(n.id)) continue; try { const d = await api.get(`/notes/${n.id}`); const c = shapeContent(d); await db.putNoteContent(n.id, c.content, c.drawingData, c.drawingStrokes, c.updatedAt); await reportAttachments(n.id, c.content); fetched++; onProgress?.({ id: n.id, content: c.content, fetched, total: pending.length }); } catch { /* one unreadable note must not stop the rest */ } await new Promise((r) => setTimeout(r, 25)); } return { fetched, total: pending.length }; } /* ─── Version history ─── */ export async function getNoteVersions(id: string): Promise { if (!_online || !api.token) return []; try { return await api.get(`/notes/${id}/versions`); } catch { return []; } } export async function getNoteVersion(id: string, versionId: number): Promise { return api.get(`/notes/${id}/versions/${versionId}`); } export async function restoreNoteVersion( id: string, versionId: number ): Promise<{ success: boolean; restoredFrom: string; content: NoteContent }> { const r = await api.post<{ success: boolean; restoredFrom: string }>( `/notes/${id}/versions/${versionId}/restore`, {} ); // The local mirror is now behind the server, so pull the restored body back. const fresh = await loadNoteContent(id, { forceRemote: true }); notifyData("noteContent", { id }); return { ...r, content: fresh }; } /* ─── Note links ─── */ export async function getBacklinks(id: string): Promise { if (!_online || !api.token) return []; try { return await api.get(`/notes/${id}/backlinks`); } catch { return []; } } export async function getAllLinks(): Promise< Array<{ source_id: string; target_id: string; target_kind: string }> > { if (!_online || !api.token) return []; try { return await api.get("/links"); } catch { return []; } } /* ─── Administration ─── * Only reachable for accounts flagged isAdmin; the server refuses the rest. * These talk to the server directly and are never queued: creating or deleting * an account offline would be a promise the client cannot keep. */ export async function listUsers(): Promise { return api.get("/admin/users"); } export async function createUser(fields: { username: string; password: string; displayName?: string; }): Promise { return api.post("/admin/users", fields); } export async function resetUserPassword(id: string, newPassword: string): Promise { await api.put(`/admin/users/${id}/password`, { newPassword }); } export async function deleteUser(id: string): Promise { await api.del(`/admin/users/${id}`); } export { DEVICE_ID }; /* ─── Test seam ─── * Lets the unit tests drive the engine without a browser: they can point it at a * stub transport and simulate going offline. Not used by the app. */ export const __testing = { setToken(t: string | null) { api.token = t; }, setOnline(v: boolean) { _online = v; }, resetPendingWrites() { _pendingWrites.clear(); for (const k of Object.keys(_timers)) delete _timers[k]; }, flushQueue, beginWrite, endWrite, };