A cover is a colour *and* a pattern now, chosen in turn. Offering every combination as its own entry meant fifteen colours times a dozen patterns in a grid nobody could take in — and in practice only the handful someone had thought to write out. Each pattern is previewed over the colour in hand, so what is on screen is the cover as it will be. `cover_pattern` is its own column: they are two decisions, and folding them into one number would mean renumbering every cover the day a pattern is added. For one day the patterns *were* twelve appended entries. Those indices are read back as the colour-and-pattern pair they were drawn as, so nothing chosen then looks different now. And three separate reasons the install button never appeared, none of them in anyone's configuration: - `beforeinstallprompt` fires once, early — usually before React has mounted — and is never fired again. A listener added from inside a component simply missed it. It is caught at the top of index.html now. - A `<link rel="manifest">` is fetched with credentials omitted unless it says otherwise. Behind a reverse proxy that authenticates in front of the app, that one request comes back as the proxy's login page and the browser reads it as an invalid manifest — while the app itself works perfectly, because its own requests carry the cookie. - The service worker cached whatever came back, so a lapsed session could be stored as the application itself. The app would then "work offline" by showing a login screen it could never get past, curable only by clearing the site's data. Only successful, unredirected responses are cached now, and the cache name is bumped to drop anything already stored under the old rules. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
211 lines
5.5 KiB
TypeScript
211 lines
5.5 KiB
TypeScript
/**
|
|
* The shapes that travel between the server, IndexedDB and the UI.
|
|
*
|
|
* Most bugs in this app have been shape bugs: a field that is sometimes absent,
|
|
* a body cached in the wrong format, a stale object adopted over a fresh one.
|
|
* These types exist to make that class of mistake fail at build time.
|
|
*/
|
|
|
|
/* ── Drawing ── */
|
|
|
|
export type StrokeTool = "pen" | "highlighter" | "eraser";
|
|
|
|
export interface Stroke {
|
|
/** Tool the stroke was drawn with. */
|
|
t: StrokeTool;
|
|
/** Colour, `#rrggbb`. */
|
|
c: string;
|
|
/** Width in canonical page units, not pixels. */
|
|
s: number;
|
|
/** Flat `[x0, y0, x1, y1, …]` in canonical page space. */
|
|
p: number[];
|
|
}
|
|
|
|
export interface StrokeDoc {
|
|
v: number;
|
|
/** Canonical page width the points are expressed in. */
|
|
w: number;
|
|
/** Canonical page height. */
|
|
h: number;
|
|
strokes: Stroke[];
|
|
/** Save token — lets the editor recognise its own write coming back. */
|
|
t?: string;
|
|
}
|
|
|
|
/* ── Notes ── */
|
|
|
|
export type NoteKind = "text" | "pdf";
|
|
|
|
/** Note metadata, as held in the notes list. */
|
|
export interface NoteMeta {
|
|
id: string;
|
|
title: string;
|
|
/** "pdf" notes render a document instead of the text editor. */
|
|
type?: NoteKind;
|
|
/** Attachment holding the PDF, for notes of type "pdf". */
|
|
pdfAttachmentId?: string | null;
|
|
folderId: string | null;
|
|
starred: boolean;
|
|
trashed: boolean;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
/** Position in the notebook's own order, used when sorting is set to manual. */
|
|
sortOrder?: number;
|
|
/** Set while a note created offline has not reached the server yet. */
|
|
_pendingCreate?: boolean;
|
|
}
|
|
|
|
/** The lazily loaded body of a note. */
|
|
export interface NoteContent {
|
|
/** Serialised document: `{"v":2,"doc":…}`, or a v1 `{html,tasks}` string. */
|
|
content: string;
|
|
/** Legacy raster drawing as a data URL, for notes predating vector strokes. */
|
|
drawingData: string | null;
|
|
drawingStrokes: StrokeDoc | null;
|
|
/** Server revision this copy came from, used to spot a stale cache. */
|
|
updatedAt?: string | null;
|
|
}
|
|
|
|
/**
|
|
* How a notebook's notes are ordered.
|
|
*
|
|
* "manual" is the user's own arrangement, kept in each note's `sortOrder`; the
|
|
* rest are derived from the notes themselves and need nothing stored.
|
|
*/
|
|
export type SortMode = "manual" | "updated" | "oldest" | "created" | "title";
|
|
|
|
/**
|
|
* A line drawn between notes in a notebook's own order.
|
|
*
|
|
* `order` is "how many notes come before me", so it stays meaningful as the
|
|
* notes around it are renamed, edited or opened.
|
|
*/
|
|
export interface Separator {
|
|
id: string;
|
|
label: string;
|
|
order: number;
|
|
}
|
|
|
|
export interface Notebook {
|
|
id: string;
|
|
name: string;
|
|
cover: number;
|
|
/** Pattern laid over the colour, chosen separately from it. */
|
|
coverPattern?: number;
|
|
sortOrder: number;
|
|
/** Versions kept per note here; null means the server's default. */
|
|
maxVersions?: number | null;
|
|
/** Null means the app default, newest first. */
|
|
sortMode?: SortMode | null;
|
|
/** Lines drawn between notes in the manual order. */
|
|
separators?: Separator[];
|
|
_pendingCreate?: boolean;
|
|
}
|
|
|
|
/** How the note body is set. Presentation only — the export is unaffected. */
|
|
export type NoteFont = "serif" | "sans" | "mono";
|
|
|
|
export interface UserPreferences {
|
|
noteFont: NoteFont;
|
|
}
|
|
|
|
export interface User {
|
|
id: string;
|
|
username: string;
|
|
displayName: string;
|
|
/** Admins may create, delete and reset the password of other accounts. */
|
|
isAdmin?: boolean;
|
|
/** Interface settings, kept on the account so they follow you to the phone. */
|
|
preferences?: UserPreferences;
|
|
/**
|
|
* How many versions a notebook keeps when it names no number of its own.
|
|
*
|
|
* Sent by the server rather than written into the interface: it is set with
|
|
* `MAX_VERSIONS_PER_NOTE`, and a dialog that said "50" would start lying the
|
|
* day that is changed.
|
|
*/
|
|
defaultMaxVersions?: number;
|
|
}
|
|
|
|
/** A user row as the admin panel sees it. */
|
|
export interface ManagedUser extends User {
|
|
createdAt: string;
|
|
noteCount: number;
|
|
/** True for the admin doing the looking — they may not delete themselves. */
|
|
isSelf: boolean;
|
|
}
|
|
|
|
/* ── Version history ── */
|
|
|
|
export type VersionReason = "edit" | "large-deletion" | "drawing" | "drawing-cleared" | "before-restore";
|
|
|
|
export interface NoteVersionSummary {
|
|
id: number;
|
|
title: string | null;
|
|
device_id: string | null;
|
|
reason: VersionReason | string;
|
|
created_at: string;
|
|
content_size: number | null;
|
|
has_drawing: boolean;
|
|
}
|
|
|
|
export interface NoteVersion {
|
|
id: number;
|
|
title: string | null;
|
|
created_at: string;
|
|
device_id: string | null;
|
|
reason: string;
|
|
content: string;
|
|
drawingStrokes: StrokeDoc | null;
|
|
}
|
|
|
|
/* ── Links ── */
|
|
|
|
export interface Backlink {
|
|
id: string;
|
|
title: string;
|
|
notebook_id: string | null;
|
|
notebook_name: string | null;
|
|
updated_at: string;
|
|
}
|
|
|
|
export type LinkKind = "note" | "notebook";
|
|
|
|
export interface LinkTarget {
|
|
id: string;
|
|
label: string;
|
|
kind: LinkKind;
|
|
}
|
|
|
|
/* ── Sync ── */
|
|
|
|
export type SyncStatus = "synced" | "syncing" | "pending" | "offline" | "error";
|
|
|
|
/** A change waiting to be pushed to the server. */
|
|
export interface QueueEntry {
|
|
queueId: number;
|
|
action: string;
|
|
data: Record<string, unknown>;
|
|
timestamp: number;
|
|
attempts?: number;
|
|
lastError?: string;
|
|
}
|
|
|
|
/* ── Attachments ── */
|
|
|
|
export interface AttachmentInfo {
|
|
id: string;
|
|
mime: string;
|
|
size: number;
|
|
deduplicated: boolean;
|
|
}
|
|
|
|
/* ── Search ── */
|
|
|
|
export interface SearchHit {
|
|
id: string;
|
|
score: number;
|
|
/** Excerpt around the first matching term. */
|
|
snippet: string;
|
|
}
|