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 exceptGET /api/v1/health,POST /api/v1/auth/login,POST /api/v1/auth/refreshandPOST /api/v1/auth/forget-device
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.
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.
GET /api/v1/health{ "status": "ok", "apiVersion": 1, "application": "Lexicon" }Unauthenticated, and deliberately free of configuration detail. Clients check
apiVersion before doing anything else.
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.
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} → 204GET /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 }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).
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.
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
→ 204Boards 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.
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.
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.
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} → 204GET /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": [ ... ] }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: nosniffThe 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.
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.
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.
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.
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.
The API is used from another origin, so CORS is explicit and exact:
--allowed-originmay be repeated; matching is exact, and*is never used.- An allowed origin gets
Access-Control-Allow-Origin: <that origin>plusVary: Origin. OPTIONSpreflights answer 204 withAccess-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONSandAccess-Control-Allow-Headers: Authorization, Content-Type.- A request carrying an
Originthat is not allowed is refused with 403. - Credentials are never allowed, because the session travels in an explicit
Authorizationheader rather than an automatically sent cookie. That is also why the API needs no CSRF token.
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.
- 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.dbon the server machine.
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.