Four things that were built for a mouse on a desk and quietly did nothing useful on a phone. The last one is the serious one. **A note made offline lost everything written into it.** Its create reached the server, the server named it something else, and every edit still queued behind it went on addressing the name only that device ever knew — 404, five retries, thrown away. The note arrived empty and untitled, with nothing to say that anything had been lost. The database was repointed at the new id all along; the queue was not. It is now, by replacing the id across the whole queued payload rather than in a list of named fields, because a list of fields is a list to forget to add to. The same held for a notebook made offline and everything filed into it. **The order of notes could not be changed on a phone**, because the list went through the HTML5 drag API, which mobile browsers never fire from touch. Pointer events instead, which mouse, pen and finger all produce. A mouse still drags a row from anywhere; a finger drags from a grip, because pressing the row has to go on scrolling and holding it has to go on opening the menu. The row under the finger is found with elementFromPoint — a finger raises no pointerover on anything it did not press. **"Move to..." opened off the side of the screen**, and could not be opened at all without a hover. It is a tap now, unfolding in place where there is no room beside the menu and flipping sides where there is. The menu itself is placed by measuring rather than by assuming it is 220px wide, its rows are tall enough to hit with a thumb, and Escape closes it. **Installing said nothing when it could not be done.** The entry appeared only when the browser had offered a prompt, so where installing was impossible there was no button and no reason. It is always there now and names the cause — most usefully that a browser will only install a site served over HTTPS, which is also why the service worker would not have registered, so one cause was showing up as two mysteries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1347 lines
43 KiB
TypeScript
1347 lines
43 KiB
TypeScript
/**
|
|
* 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<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
const headers: Record<string, string> = {
|
|
"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<T>(p: string) {
|
|
return this.req<T>("GET", p);
|
|
},
|
|
post<T>(p: string, b?: unknown) {
|
|
return this.req<T>("POST", p, b);
|
|
},
|
|
put<T>(p: string, b?: unknown) {
|
|
return this.req<T>("PUT", p, b);
|
|
},
|
|
del<T>(p: string) {
|
|
return this.req<T>("DELETE", p);
|
|
},
|
|
|
|
/** Sends raw bytes; req() would JSON-encode them and inflate the payload. */
|
|
async postBinary<T>(path: string, body: Blob, contentType: string): Promise<T> {
|
|
const headers: Record<string, string> = { "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<Blob> {
|
|
const headers: Record<string, string> = { "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<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", () => {
|
|
_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<User> {
|
|
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<User> {
|
|
return acceptAuth(await api.post<AuthResponse>("/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<User | Partial<User> | null> {
|
|
let token = await db.getMeta<string>("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<User>("/auth/me");
|
|
await db.setMeta("user", u);
|
|
return u;
|
|
} catch (e) {
|
|
if (isAuthError(e)) {
|
|
await logout();
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
return (await db.getMeta<User>("user")) || {};
|
|
}
|
|
|
|
export async function logout(): Promise<void> {
|
|
api.forgetToken();
|
|
await db.clearAll();
|
|
}
|
|
|
|
/* ─── Account ─── */
|
|
export async function updatePreferences(preferences: Partial<UserPreferences>): Promise<User> {
|
|
return api.put<User>("/auth/preferences", preferences);
|
|
}
|
|
|
|
export async function updateProfile(displayName: string): Promise<User> {
|
|
const u = await api.put<User>("/auth/profile", { displayName });
|
|
await db.setMeta("user", u);
|
|
return u;
|
|
}
|
|
|
|
export async function updateUsername(newUsername: string, password: string): Promise<User> {
|
|
const u = await api.put<User>("/auth/username", { newUsername, password });
|
|
await db.setMeta("user", u);
|
|
return u;
|
|
}
|
|
|
|
export async function updatePassword(currentPassword: string, newPassword: string): Promise<void> {
|
|
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<void> {
|
|
if (!_online || !api.token) return;
|
|
notifyStatus("syncing");
|
|
try {
|
|
await flushQueue();
|
|
|
|
const localNbs = await db.getAllNotebooks();
|
|
const serverNbs = await api.get<ServerNotebook[]>("/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<ServerNote[]>("/notes");
|
|
const active: NoteMeta[] = serverNotes.map((n) => toNoteMeta(n, false));
|
|
|
|
let trashed: NoteMeta[] = [];
|
|
try {
|
|
const st = await api.get<ServerNote[]>("/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<string, never> };
|
|
|
|
/** Typed wrapper around the generic store, so producers cannot enqueue nonsense. */
|
|
function enqueue(a: QueuedAction): Promise<void> {
|
|
return db.enqueue(a as unknown as { action: string; data: Record<string, unknown> });
|
|
}
|
|
|
|
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<void> {
|
|
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<void> {
|
|
// 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 });
|
|
}
|
|
// ...and everything still queued behind this create, which is addressed
|
|
// to a name only this device ever knew.
|
|
await db.repointQueue(d.localId, 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);
|
|
/* The queue too. Without this the title, the text and the drawing that
|
|
* followed were all addressed to the local id, met a 404, and were
|
|
* thrown away after five attempts — so a note written offline arrived
|
|
* empty and untitled, with no sign that anything had been lost. */
|
|
await db.repointQueue(d.localId, r.id);
|
|
}
|
|
break;
|
|
}
|
|
case "updateNote":
|
|
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 });
|
|
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<string, ReturnType<typeof setTimeout> | 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<string, number>();
|
|
|
|
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<T>(id: string, run: () => Promise<T>): Promise<T> {
|
|
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<Notebook[]> {
|
|
return db.getAllNotebooks();
|
|
}
|
|
|
|
export async function createNotebook({ name, cover }: { name: string; cover: number }): Promise<Notebook> {
|
|
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<Notebook>): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<NoteMeta[]> {
|
|
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<void> {
|
|
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<NoteContent>) | 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<NoteContent> {
|
|
// 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<ServerNoteBody>(`/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<ServerNoteBody>(`/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<NoteMeta> {
|
|
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<void> {
|
|
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<void> {
|
|
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<UpdateResult>(`/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<void> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<AttachmentInfo> {
|
|
return api.post<AttachmentInfo>("/attachments", { data: dataUrl, filename });
|
|
}
|
|
|
|
export async function fetchAttachmentBlob(id: string): Promise<Blob> {
|
|
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<AttachmentInfo> {
|
|
const query = filename ? `?filename=${encodeURIComponent(filename)}` : "";
|
|
return api.postBinary<AttachmentInfo>(`/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<Uint8Array> {
|
|
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());
|
|
}
|
|
|
|
/* ─── The text inside a PDF ───
|
|
*
|
|
* Read on a device, stored with the file, handed to every other device. The
|
|
* server never opens a PDF; it keeps this the way it keeps a note body —
|
|
* encrypted, and unread by it. */
|
|
|
|
/** The stored text of a PDF, page by page, or null if nobody has read it yet. */
|
|
export async function fetchPdfText(attachmentId: string): Promise<string[] | null> {
|
|
const r = await api.get<{ pages: string[] | null }>(`/attachments/${attachmentId}/text`);
|
|
return r.pages ?? null;
|
|
}
|
|
|
|
export async function storePdfText(attachmentId: string, pages: string[]): Promise<void> {
|
|
await api.put(`/attachments/${attachmentId}/text`, { pages });
|
|
}
|
|
|
|
/** Creates a note that holds a PDF. */
|
|
export async function createPdfNote(
|
|
folderId: string | null,
|
|
title: string,
|
|
pdfAttachmentId: string
|
|
): Promise<NoteMeta> {
|
|
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<void> {
|
|
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<ServerNoteBody>(`/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<NoteVersionSummary[]> {
|
|
if (!_online || !api.token) return [];
|
|
try {
|
|
return await api.get<NoteVersionSummary[]>(`/notes/${id}/versions`);
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
export async function getNoteVersion(id: string, versionId: number): Promise<NoteVersion> {
|
|
return api.get<NoteVersion>(`/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<Backlink[]> {
|
|
if (!_online || !api.token) return [];
|
|
try {
|
|
return await api.get<Backlink[]>(`/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<ManagedUser[]> {
|
|
return api.get<ManagedUser[]>("/admin/users");
|
|
}
|
|
|
|
export async function createUser(fields: {
|
|
username: string;
|
|
password: string;
|
|
displayName?: string;
|
|
}): Promise<User> {
|
|
return api.post<User>("/admin/users", fields);
|
|
}
|
|
|
|
export async function resetUserPassword(id: string, newPassword: string): Promise<void> {
|
|
await api.put(`/admin/users/${id}/password`, { newPassword });
|
|
}
|
|
|
|
export async function deleteUser(id: string): Promise<void> {
|
|
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,
|
|
};
|