Skip to content

Latest commit

 

History

History
742 lines (613 loc) · 32.6 KB

rest-api.md

File metadata and controls

742 lines (613 loc) · 32.6 KB

Lexicon REST API v1

LexiconServer exposes JSON over HTTP and nothing else. It never serves HTML, CSS, JavaScript, images or any other web asset, and it has no SPA fallback: the web client is deployed separately as static files.

  • Base path: /api/v1
  • Content type: application/json; charset=utf-8 (UTF-8 everywhere)
  • Authentication: Authorization: Bearer <token> on every endpoint except GET /api/v1/health, POST /api/v1/auth/login, POST /api/v1/auth/refresh and POST /api/v1/auth/forget-device

Conventions

Enums are names, never numbers. The API uses stable symbolic values so it survives changes to the C++ enum order:

Concept Values
status None, Draft, Completed
understanding Unknown, Recognized, Understood, Practiced, Mastered
linkType None, IsA, PartOf, Uses, DependsOn, Implements, Related, Contrasts, AlternativeTo, ParentOf, Custom
dataType Integer, Float, Text, Date, Time, Timestamp, Boolean, Enum, Blob, Other, Image
sortOrder Ascending, Descending

An unknown name is rejected with HTTP 400.

Absent IDs are null, not -1 or 0. A type that is available in every group has "groupId": null; an item without a type has "itemTypeId": null.

IDs in the path win. PUT /api/v1/items/7 saves item 7 whatever the body says, and POST rejects a body that already carries an ID.

Errors

Every failure uses one envelope:

{ "error": { "code": "validation", "message": "Type name cannot be empty." } }
Status Typical code Meaning
400 validation, malformed_json Invalid body, unknown enum, malformed ID
401 unauthorized Missing, unknown or expired token; wrong credentials
403 origin_not_allowed The Origin header is not in the allowlist
404 not_found No such record or endpoint
409 conflict The item was saved by someone else after this client loaded it
413 payload_too_large Body over --max-json-bytes or --max-blob-bytes
415 unsupported_media_type Wrong Content-Type
429 too_many_requests Login rate limit; carries Retry-After
500 storage, internal The server could not complete the operation

Application errors map as Validation → 400, NotFound → 404, Conflict → 409, Storage → 500. Storage messages stay on the server: SQL, file system paths, stack traces, passwords and tokens never appear in a response.

Health

GET /api/v1/health
{ "status": "ok", "apiVersion": 1, "application": "Lexicon" }

Unauthenticated, and deliberately free of configuration detail. Clients check apiVersion before doing anything else.

Authentication

POST /api/v1/auth/login
{ "username": "...", "password": "..." }
{
  "token": "3Qv...43 characters of base64url...",
  "username": "robert",
  "apiVersion": 1,
  "idleTimeoutSeconds": 28800,
  "absoluteLifetimeSeconds": 604800
}

The token is 256 bits of cryptographically random material. Failures return a generic 401 that does not reveal whether the user name exists; repeated failures return 429 with Retry-After.

Android can add "rememberDevice": true to the login body. The response then also contains a nonempty refreshToken. This requires the persistent session file. Ordinary logins return an empty refreshToken and keep their existing expiry policy.

POST /api/v1/auth/refresh
{ "refreshToken": "..." }

This public endpoint returns a new token and refreshToken in the same shape as login. The previous refresh token stops working immediately. Reusing it revokes the remembered phone and its sessions. A remembered phone expires after 90 days without a refresh; regular use can renew it indefinitely. The server stores only token hashes. A changed password revokes all phones.

POST /api/v1/auth/forget-device
{ "refreshToken": "..." }

This public, idempotent endpoint returns 204 and revokes that phone. It works even when its ordinary session has expired.

POST /api/v1/auth/logout   → 204, the token stops working immediately
GET  /api/v1/auth/me       → { "username": "robert", "apiVersion": 1 }
GET  /api/v1/auth/sessions → { "sessions": [ { "id": "...", "createdAtSeconds": 0, "lastSeenSeconds": 0, "current": true } ] }
DELETE /api/v1/auth/sessions/{id} → 204
GET  /api/v1/auth/devices → { "devices": [ { "id": "...", "createdAtSeconds": 0, "lastUsedSeconds": 0, "current": true } ] }
DELETE /api/v1/auth/devices/{id} → 204
POST /api/v1/auth/change-password → 204
{ "currentPassword": "...", "newPassword": "..." }

The session ID is an opaque identifier used only for revocation. Changing the password requires the current password, writes the credential file atomically, and ends all sessions, including the caller's. Sign in again with the new password. A wrong current password returns 403.

Groups

GET    /api/v1/groups           → { "groups": [ ... ] }
GET    /api/v1/groups/default   → { "groupId": 1 }
POST   /api/v1/groups           → 201 { "group": { ... } }
PUT    /api/v1/groups/{id}      → 200 { "group": { ... } }
DELETE /api/v1/groups/{id}      → 204

GET /groups/default returns the Default group, creating it if a historical database has none. Deleting a group returns 400 validation while it contains items or group-specific types; move or delete those records first.

{ "id": 1, "name": "Default", "description": "...", "position": 0 }

Types and fields

GET    /api/v1/types?groupId=3        → { "types": [ ... ] }
POST   /api/v1/types                  → 201 { "type": { ... } }
PUT    /api/v1/types/{id}             → 200 { "type": { ... } }
DELETE /api/v1/types/{id}             → 204
GET    /api/v1/types/{id}/item-count  → { "count": 12 }

GET    /api/v1/types/{id}/fields      → { "fields": [ ... ] }
POST   /api/v1/types/{id}/fields      → 201 { "field": { ... } }
PUT    /api/v1/fields/{id}            → 200 { "field": { ... } }
DELETE /api/v1/fields/{id}            → 204
GET    /api/v1/fields/{id}/value-count → { "count": 4 }

groupId filters to the types usable in that group, which includes the types available in all groups. The two count endpoints exist so a client can warn before a destructive change, exactly as the desktop dialogs do: deleting a type clears the field values of every item using it, and changing a field's data type clears its stored values. Editing enumOptions never clears stored values. Adding or reordering options and removing unused options is allowed. Removing an option that is still stored by any item is rejected with an HTTP 400 validation error; change those item values first, then update the field definition.

{ "id": 4, "itemTypeId": 2, "name": "Difficulty",
  "description": "How difficult this concept is.", "dataType": "Enum",
  "position": 0, "enumOptions": ["easy", "hard"] }

description is optional when a field is created or updated and defaults to an empty string. All field responses include it. For ForeignKey, set targetItemTypeId to the ID of an existing item type. Its values are positive item IDs. Saving a value does not require that the target item exist; a missing item, or one of another type, displays as !missing! {id}. Field responses include targetItemTypeId (otherwise null).

Items

Filtering needs too many parameters for a query string, so the list endpoint is a POST with a body:

POST /api/v1/items/query
{
  "groupId": null,
  "typeId": null,
  "searchText": "",
  "columnFilters": { "id": "", "title": "", "disambiguation": "", "alias": "" },
  "propertyFilters": [{ "key": "source", "value": "folklore" }],
  "valueFilters": [{ "fieldId": 4, "value": "hard", "exact": true }],
  "tagFilter": "",
  "flagFilter": "",
  "understandingFilter": null,
  "statusFilter": null,
  "pinnedFilter": null,
  "limit": 20,
  "offset": 0,
  "sortColumn": 3,
  "sortOrder": "Ascending"
}

Every field is optional. The semantics are the ones the application already implements: property keys match exactly and their values are a contains match, an empty property value matches any value for that key, columnFilters.id is an exact ID, and the other column filters are contains matches.

searchText is a contains match on the title, disambiguation, aliases, tags and flags, and also searches the content: through the server's full-text index every word must occur as a word prefix, ignoring case and diacritics; text without a word of two letters or more, like C++, is a contains match on the content instead. While searchText is set, the results are ranked before the sort column applies: exact title, exact alias, title prefix, title contains, another field contains, content only. Each item found through its content carries a matchSnippet saying which part of it matched. sortColumn is the column index used by both clients - 0 Id, 1 Group, 2 Type, 3 Title, 4 Disambiguation, 5 Tags, 6 Flags, 7 Aliases, 8 Status, 9 Understanding, 10 Pinned, and 11 onwards the fields of the selected type in their display order. limit is capped at 1000.

The response carries the page and the total so clients need no second request:

{ "items": [ ... ], "totalCount": 137 }
GET    /api/v1/items/{id}                        → { "item": { ... } }
GET    /api/v1/items/{id}?include=links,backlinks
POST   /api/v1/items                             → 201 { "id": 7, "item": { ... } }
PUT    /api/v1/items/{id}                        → 200 { "id": 7, "item": { ... } }
DELETE /api/v1/items/{id}                        → 204
POST   /api/v1/items/{id}/read                   → 204 (records a read in the log)
GET    /api/v1/items/{id}/links                  → { "links": [ ... ] }
GET    /api/v1/items/{id}/backlinks              → { "backlinks": [ ... ] }
GET    /api/v1/items/resolve?title=&disambiguation= → { "itemId": 7 }
GET    /api/v1/items/{id}/history               → { "entries": [ ... ] }
GET    /api/v1/items/trash                      → { "entries": [ ... ] }
POST   /api/v1/items/history/{historyId}/restore → { "itemId": 7 }

Each history entry has id, itemId, operation (updated or deleted), happenedAt and the previous item. Restoring a deleted item creates a new ID. Its cards and links are restored when their other endpoint still exists. Restoring a previous version of a live item keeps its ID. The latest 100 saved versions per item are retained; Trash lists every unrestored deletion.

GET /api/v1/items/{id}/graph?depth=2&limit=100
→ { "nodes": [ { ...item..., "depth": 0 }, ... ], "edges": [ ...links... ], "truncated": false }

The items around one item, following links in both directions: depth 1 to 3 (default 2) links away, at most limit items (1 to 300, default 100). The nodes are list rows plus their depth, the centre first; the edges are the links among the nodes, in the shape of the link endpoints. truncated says that more items were in reach than limit allowed.

POST /api/v1/inbox                               → 201 { "id": 9, "item": { ... } }
{ "title": "Lock-free queue", "content": "Try a ring buffer." }

The Inbox of every client: an idea in one request, saved to the Default group with the type Inbox whatever the client shows. The type is the one called Inbox (ignoring ASCII case) available in all groups, or else the one of Default; without either, the first idea creates it, available in all groups, so an idea keeps its type when it moves to another group. content is optional plain text. A missing or blank title, or one already in Default, is refused with 400, and a refused idea leaves nothing behind, not even a new type.

Boards

GET /api/v1/boards
→ { "boards": [
  { "id": 1, "name": "Main", "content": "# Team Board\n\n- Ship it", "revision": 4 }
] }

POST /api/v1/boards
{ "name": "Work", "content": "# Work" }
→ 201 { "board": { "id": 2, "name": "Work", "content": "# Work", "revision": 1 } }

GET /api/v1/boards/2
→ { "board": { "id": 2, "name": "Work", "content": "# Work", "revision": 1 } }

PUT /api/v1/boards/2
{ "name": "Projects", "content": "# Projects\n\n- Ship it", "revision": 1 }
→ { "board": { "id": 2, "name": "Projects", "content": "# Projects\n\n- Ship it", "revision": 2 } }

DELETE /api/v1/boards/2
→ 204

Boards are named shared Markdown documents, not items: they have no group, type or item metadata. A new database starts with an empty Board named Main. Names are required and unique ignoring ASCII case, and at least one Board must remain. Send the revision returned by GET when saving; if another client saved meanwhile, PUT returns 409 instead of overwriting its work. A missing or zero revision requests an explicit unconditional overwrite.

GET and PUT /api/v1/board remain as compatibility endpoints for clients from the singleton-Board release. They read or update the first named Board, but new clients should use /boards.

resolve is what a [[Title]] link in item content uses. The first of these that finds an item wins: the exact title (preferring an item without a disambiguation when none is given), the title ignoring ASCII case, an exact alias, an alias ignoring ASCII case. With a disambiguation, only titles are considered, exactly and then ignoring ASCII case.

An item:

{
  "id": 7,
  "groupId": 1,
  "groupName": "Default",
  "itemTypeId": 2,
  "itemTypeName": "Concept",
  "fieldValues": { "4": "hard" },
  "properties": [{ "key": "source", "value": "folklore" }],
  "title": "Monoid",
  "disambiguation": "algebra",
  "aliases": ["Semigroup with unit"],
  "tags": ["algebra"],
  "flags": ["todo"],
  "status": "Draft",
  "understanding": "Practiced",
  "pinned": false,
  "content": "# Monoid\n\nMarkdown source.",
  "revision": 4,
  "reviewedAt": "2026-09-20T08:15:00Z",
  "reviewDueAt": "2026-10-02T08:15:00Z",
  "matchSnippet": null
}

matchSnippet says why a search found the item: the piece of its content around the match, on one line, with … where it was cut. It is null unless the request carried a searchText that the content matched - an item found by its title, an alias or a tag alone needs no explanation and gets none - and it is always null for an item fetched by ID.

reviewedAt and reviewDueAt are UTC and null for an item never reviewed, which is due at once. Saving an item keeps its review time; a new item may be created with one, which is how an import carries the review history.

fieldValues is keyed by field ID as a string, because JSON object keys are strings. Values keep the representation the database stores: YYYY-MM-DD for Date, HH:MM[:SS] for Time, YYYY-MM-DDTHH:MM[:SS] for Timestamp, true/false for Boolean, one of enumOptions for Enum, a 64 character lowercase SHA-256 for Blob, and <media type>:<SHA-256> for Image, such as image/png:6c7dbba2...99d98ca. For ForeignKey, the stored value is a positive item ID. Read responses also include fieldDisplayValues, keyed by field ID, with the target item's title or !missing! {id}. Clients keep fieldValues for edits and filtering.

An Image value is a stored file, like a Blob, that also says what kind of image it is: image/png, image/jpeg, image/gif, image/webp or image/bmp. SVG is not accepted, because it can carry script. Saving an item checks the file's first bytes against the declared type, so a value cannot call a text file, or a JPEG, a PNG; a mismatch is refused with 400 and names what the file is.

Revisions and conflicts

revision counts the changes to an item. It moves on whenever the item is saved, whenever one of its links or backlinks is added, changed or removed, and whenever a type or field change removes one of its values. A link that is sent again unchanged, as every save of the item at its other end does, leaves it alone.

A client that sends back the revision it loaded gets 409 conflict if the item has changed since, and nothing is written:

{ "error": { "code": "conflict", "message": "This item was changed elsewhere after you opened it." } }

The client then loads the item again and lets the person choose: save over the newer version by repeating the request with its revision, or drop the edit. A save without revision, or with 0, is not checked. All three Lexicon clients send it.

Saving an item with its links

POST /api/v1/items and PUT /api/v1/items/{id} accept the item together with both link directions and save them in one unit of work, the same transactional path the desktop item dialog uses:

{
  "item": { "groupId": 1, "title": "Group" },
  "links": [{ "id": null, "toItemId": 12, "linkType": "PartOf", "position": 1 }],
  "backlinks": [{ "id": 33, "fromItemId": 9, "linkType": "Uses", "position": 0 }]
}

The lists are the complete desired state: links present in the database but missing from the request are deleted, entries with an id are updated, and entries without one are created. fromItemId on outgoing links and toItemId on backlinks are filled in from the saved item. If any part fails, nothing is written. Custom links require a non-empty customValue; other types store none; None is not a persistable link type.

A group holds one item per title and disambiguation, compared exactly. A second one is refused with 400 validation and a message naming the title, for a new item and for an update that would rename an item onto another.

Links

Supplementary to the atomic save above:

POST   /api/v1/links       → 201 { "links": [ ... ] }   (links of the source item)
PUT    /api/v1/links/{id}  → 200 { "links": [ ... ] }
DELETE /api/v1/links/{id}  → 204

Search and usage

GET /api/v1/search/suggestions  → { "values": ["Monoid", "Semigroup with unit"] }
GET /api/v1/search/item-titles  → { "values": ["Monoid [algebra]"] }
GET /api/v1/usage/tags          → { "values": [{ "value": "algebra", "usageCount": 2 }] }
GET /api/v1/usage/flags         → { "values": [ ... ] }
GET /api/v1/usage/aliases       → { "values": [ ... ] }

Blobs

A browser cannot hand the server a local path, so blobs travel as bytes. The server stages uploads in a file it names itself inside the database directory, imports it through BlobService, and removes the staging file; no request ever names a server-side path.

POST /api/v1/blobs
Content-Type: application/octet-stream
<raw bytes>
{ "hash": "6c7dbba2...99d98ca", "mediaType": "image/png" }
GET /api/v1/blobs/{hash}
→ application/octet-stream, Content-Disposition: attachment, X-Content-Type-Options: nosniff

The hash is the value to store in a Blob field. mediaType is the image type the server recognises in the bytes, or null; an Image field stores <mediaType>:<hash>. Downloads stay opaque application/octet-stream attachments whatever the bytes are; a client showing an image gives the bytes the type from the value. Uploads over --max-blob-bytes are rejected with 413; a hash that is not 64 lowercase hex characters is rejected with 400; an unknown hash is 404.

Review

GET  /api/v1/review?groupId=3&limit=20   → { "items": [ ... ], "dueCount": 57 }
POST /api/v1/items/{id}/review           → 200 { "item": { ... } }
{ "rating": "Good" }

The queue holds the items due now, as list rows: those reviewed before, the most overdue first, then those never reviewed, oldest first. groupId is optional; limit is 1 to 200 and defaults to 20. dueCount counts every due item, not only the ones returned.

A review moves the understanding by its rating - Again one level down, Hard not at all, Good one up, Easy two up, within Unknown and Mastered - records the time, moves the item's revision on and logs the review. The item is due again after 1, 2, 5, 12 or 30 days for Unknown, Recognized, Understood, Practiced and Mastered; raising the understanding in the editor moves the next review out the same way.

Cards

A card is a question about an item and its answer, for active recall. Every item has zero or more cards; a card belongs to exactly one item, and deleting the item deletes its cards.

GET    /api/v1/items/{id}/cards        → { "cards": [ ... ] }
POST   /api/v1/items/{id}/cards        → 201 { "card": { ... } }
{ "question": "What does pointer provenance describe?", "answer": "Where a pointer came from." }
GET    /api/v1/cards/{id}              → { "card": { ... } }
PUT    /api/v1/cards/{id}              → 200 { "card": { ... } }
{ "question": "...", "answer": "..." }
DELETE /api/v1/cards/{id}              → 204
POST   /api/v1/cards/{id}/attempt      → 200 { "card": { ... } }
{ "success": true }
GET    /api/v1/items/{id}/quiz-cards?depth=0&limit=150
→ { "cards": [ { ...card..., "itemTitle": "pointer provenance" } ], "itemCount": 8, "truncated": false }
GET    /api/v1/cards/random-quiz?limit=20
→ { "cards": [ { ...card..., "itemTitle": "pointer provenance" } ], "itemCount": 1, "truncated": false }
{ "id": 12, "itemId": 42,
  "question": "What does pointer provenance describe?",
  "answer": "Where a pointer came from:\nthe object it may reach.",
  "successCount": 4, "failureCount": 2, "lastAttempt": "2026-09-24T14:00:00Z" }

question and answer are plain UTF-8 text and may run over several lines; neither may be blank. The list comes in the order the cards were added. successCount and failureCount count the quiz answers, and lastAttempt is the UTC time of the last one, null for a card never answered. The three are the system's: POST and PUT read the question and the answer and nothing else, so a new card always starts at 0, 0, null, and an edit never resets or sets a count. The item of a new card is the one in the path, and a card never moves to another item. A card for an item that does not exist is 404, as is every route of a missing card.

attempt records one quiz answer. "success": true is a Yes - the person knew the answer - and adds one to successCount; false is a No and adds one to failureCount. Either sets lastAttempt to the server's clock, never the client's. The count is incremented by the database in one statement, so answers sent at the same moment from several clients are all counted. The answer is the card as it is now. success must be true or false; anything else is 400.

quiz-cards gives the cards to go through: with depth=0 (the default) the item's own cards, and with depth 1 to 3 the cards of its relationship neighbourhood - exactly the items GET /items/{id}/graph shows at that depth, following links in both directions, the item itself included. limit (1 to 300, default 150) caps the number of items, and truncated says that more were in reach. itemCount is how many items the quiz covered, with or without cards; an item without cards simply adds none. The item's own cards come first, then those of the other items in the graph's breadth-first order, each item's in the order they were added, and no card twice even where links form a cycle. Every card carries itemTitle, the title of the item it asks about.

random-quiz draws a fresh sample from every card in the dictionary, without an item ID. limit caps cards (1 to 100, default 20); itemCount counts the distinct items represented, and truncated is always false. Each card appears at most once in a response.

Cards are not Review. A card answer never changes the item's understanding, reviewedAt, reviewDueAt or revision, and the review queue ignores cards; a review never changes a card. There is no card schedule or due date: a quiz goes through the cards in scope, and the clients count the Yes and No of one sitting only for their summary.

Alarms

GET    /api/v1/alarms        → { "alarms": [ ... ] }
GET    /api/v1/alarms/{id}   → { "alarm": { ... } }
POST   /api/v1/alarms        → 201 { "alarm": { ... } }
PUT    /api/v1/alarms/{id}   → 200 { "alarm": { ... } }
DELETE /api/v1/alarms/{id}   → 204
GET    /api/v1/alarms/due            → { "alarms": [ ... ], "now": "2026-10-02T08:31:07Z" }
POST   /api/v1/alarms/{id}/dismiss   → 200 { "alarm": { ... } }
POST   /api/v1/alarms/{id}/snooze    → 200 { "alarm": { ... } }
{ "minutes": 10 }
{ "id": 4, "title": "Dentist", "description": "Bring the card.",
  "firesAt": "2026-10-02T08:30:00Z", "dismissedAt": null,
  "repeatDays": 7, "itemId": 19, "asap": true, "group": "Health" }

An alarm is a reminder at a moment: a title (required, trimmed), a description (plain text, may be empty) and firesAt, the moment it goes off as UTC YYYY-MM-DDTHH:MM:SSZ. A time without seconds (2026-10-02T08:30Z) is accepted and stored with :00; a time without the Z, or an impossible date, is refused with 400. Clients show and edit the time in the viewer's own time zone. The list is ordered by firesAt, the soonest first, and includes alarms that have already gone off.

The clients ring alarms; the server keeps whether one still rings. An alarm whose firesAt has passed rings until someone dismisses or snoozes it, in any client. GET /alarms/due lists the ringing ones by the server's clock, the oldest first, and says what time the server has. dismiss sets dismissedAt (dismissing twice keeps the first time); snooze moves firesAt to 1 to 1440 minutes from now and makes it ring again then. Changing an alarm's time with PUT makes it ring again at the new time; changing only its title or description does not. dismissedAt is ignored on PUT, and read on POST only so that an imported alarm keeps its state.

repeatDays is zero for a one-time alarm or 1–365 for an interval in days. itemId is an optional linked item, returned as null when absent. Dismissing a repeating alarm schedules its next occurrence from the original time; snoozing does not shift that recurring schedule. Deleting the linked item leaves the alarm in place without a link. asap is a Boolean marker meaning the alarm should be handled as soon as possible. group is an optional free-text label for grouping alarms; an ungrouped alarm returns an empty string. These two fields classify the alarm and do not change when it rings.

Export and import

GET  /api/v1/export?blobs=true   → application/json, Content-Disposition: attachment
POST /api/v1/import              → 200 { "report": { ... } }

The export is the whole dictionary in the export format; blobs=true embeds the files that values refer to. The import takes such a document as its application/json body, up to --max-blob-bytes because it may carry files, merges it in one unit of work and answers with what it created and skipped:

{ "report": { "groupsCreated": 0, "typesCreated": 0, "fieldsCreated": 0,
  "itemsCreated": 12, "itemsSkipped": 3, "linksCreated": 9, "blobsImported": 2,
  "alarmsCreated": 1, "cardsCreated": 20,
  "warnings": ["Item 'Monoid': 1 value(s) do not fit its fields here and were left out."] } }

A document that is not an export, or has a format version this server does not read, is refused with 400 before anything is written.

CORS

The API is used from another origin, so CORS is explicit and exact:

  • --allowed-origin may be repeated; matching is exact, and * is never used.
  • An allowed origin gets Access-Control-Allow-Origin: <that origin> plus Vary: Origin.
  • OPTIONS preflights answer 204 with Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS and Access-Control-Allow-Headers: Authorization, Content-Type.
  • A request carrying an Origin that is not allowed is refused with 403.
  • Credentials are never allowed, because the session travels in an explicit Authorization header rather than an automatically sent cookie. That is also why the API needs no CSRF token.

Response headers

Every response carries X-Content-Type-Options: nosniff, Cache-Control: no-store, Referrer-Policy: no-referrer, X-Frame-Options: DENY and a restrictive Content-Security-Policy. When the server terminates TLS itself it adds Strict-Transport-Security.

Not exposed

  • Configuration. The desktop keeps UI preferences in the database; the web client keeps its own in the browser, so neither overwrites the other.
  • Blob maintenance. Scanning, full verification and garbage collection are local file system operations, not REST endpoints. Use the desktop client or run LexiconServer blobs scan|verify|collect --database /path/lexicon.db on the server machine.

Study Plans

Study Plans are calendar plans independent of Lexicon items. item is plain UTF-8 text, not an item ID. Clients send the person's local calendar date for an overview; the server never substitutes a UTC date.

GET    /api/v1/study-plans                    → { "studyPlans": [ ... ] }
GET    /api/v1/study-plans/{id}               → { "studyPlan": { ... } }
POST   /api/v1/study-plans                    → 201 { "studyPlan": { ... } }
PUT    /api/v1/study-plans/{id}               → 200 { "studyPlan": { ... } }
DELETE /api/v1/study-plans/{id}               → 204
GET    /api/v1/study-plans/overview?date=2026-09-27 → { "date": "2026-09-27", "plans": [ ... ] }

A request body for create or update:

{
  "item": "Effective Modern C++", "group": "Programming", "type": "Book", "unitType": "Page",
  "firstUnit": 1, "lastUnit": 334, "currentProgress": 50,
  "startDate": "2026-09-27", "endDate": "2026-11-30",
  "studyDaysMask": 127, "customUnit": "", "note": "Study carefully"
}

group is optional plain text, independent of Lexicon item groups; it defaults to an empty string on creation. An update that omits group keeps the saved value, while "group": "" clears it. type: Book, Course, Lesson, Documentation, Article, Video, Practice, Other. unitType: Page, Lesson, Chapter, Section, Module, Video, Exercise, Minute, Other. With unit Other, supply customUnit such as kata. currentProgress is the last completed absolute unit, or 0 before starting. It must be in [firstUnit, lastUnit] when nonzero. There is no stored unit count: lastUnit - firstUnit + 1 is the total. Dates are real YYYY-MM-DD calendar dates with both ends included.

studyDaysMask selects Monday=1, Tuesday=2, Wednesday=4, Thursday=8, Friday=16, Saturday=32, Sunday=64. It must select at least one weekday that occurs in the date range. 127 selects every day. A Monday–Friday week has five scheduled days, and no target is assigned on Saturday or Sunday.

Each overview entry contains plan plus upcoming, active, ended, complete, studyDay, totalUnits, completedUnits, remainingUnits, totalStudyDays, elapsedStudyDays, remainingStudyDays, plannedUnitsPerStudyDay, requiredUnitsPerRemainingStudyDay, expectedCompletedUnits, expectedProgress, expectedUnitStart, expectedUnitEnd, deficitUnits, recommendedFirst, recommendedLast, and status. expectedUnits aliases expectedCompletedUnits, and todayFirst and todayLast alias the expected daily range (using zero for no range) for older clients. The explicit range fields are null when no units are assigned to the requested date. requiredUnitsPerRemainingStudyDay is null when units remain but no configured study days remain, including after the deadline. A completed plan has zero required pace. Clients display an available pace to two decimal places without rounding server calculations first.

Current progress is the last absolute unit actually completed, or zero when not started. Expected progress is the last absolute unit the schedule says should be complete by the requested date. Expected unit range is the original schedule's assignment specifically for that date. Recommended today is a catch-up range derived from actual progress and remaining study days. For pages 101–300, 120 expected completed units means expected progress is page 220, not 120. If that day's expected range is pages 211–220, its start and end are separate from the cumulative expected progress. Before the start date expected progress is zero; after the end date it is page 300. These are calculated overview values and are not persisted in SQLite.

The original daily range uses cumulative integer division across selected study days, covering each unit exactly once. The recommended range appears when behind and uses the current progress and remaining pace; it does not replace the original target. status is Upcoming, On track, Behind, At risk, Completed, or Overdue. An active plan is behind if completed units fall below the original cumulative target through the requested day; it is at risk when finishing now needs more than 125% of the original daily pace, or no scheduled days remain. A finished plan is Completed even after its end date. An unfinished plan after its end date is Overdue.