Architecture
A framework-independent C++ backend with two composition roots — the Qt desktop client and LexiconServer — and two REST clients, the web client and the Android app.
Targets and dependencies
| Target | Responsibility and dependencies |
|---|---|
lexicon-core | Records, validation, comparison policy, review intervals, wiki link syntax, Image values and Board records. Standard C++ only. |
lexicon-application | Services and the Repository interface. Depends on core only. |
lexicon-storage-sqlite | Native SQLite implementation of Repository: migrations, transactions, the search index, Blob files. Depends on application, SQLite C API, and OpenSSL Crypto. No Qt. |
lexicon-json | The JSON form of records, shared by the REST API and the export format; export and import. No Qt. |
lexicon-http | Routes, authentication, sessions, CORS, TLS. Depends on application, lexicon-json, cpp-httplib, OpenSSL. No Qt. |
lexicon-backup | The server's automatic backups. Depends on application, storage, lexicon-json. No Qt. |
LexiconServer (lexicon-server/) | Server composition root: injects the SQLite repository into the application, serves lexicon-http, runs backups. |
lexicon-qt-bridge | Qt and UTF-8 standard C++ value conversions. Depends on core and QtCore. |
lexicon-markdown | The desktop's Markdown to HTML conversion with md4qt and wiki links. Depends on core and QtCore. |
Lexicon (lexicon-qt/) | Qt Widgets client and desktop composition root. Contains no SQL. |
lexicon-web/ | Static web client. Talks only REST. |
lexicon-android/ | Kotlin and Compose client, a separate Gradle project. Talks only REST; keeps no copy of the dictionary. |
lexicon-core
↑
lexicon-application
↑ ↑
lexicon-storage-sqlite lexicon-json
↑ ↑ ↑ ↑
│ lexicon-backup lexicon-http
│ ↑ ↑
│ └── LexiconServer ──── REST ──┬── lexicon-web
│ └── lexicon-android
Lexicon (Qt Widgets; opens the database itself)
Everything except the desktop client configures, builds, and tests without Qt. CMake rejects Qt links in the Qt-free targets. The desktop client and the server may open the same database file at the same time; SQLite's locks coordinate them.
Repository and transaction ownership
Application services use the Repository interface and return std::expected<T, Error>. Only the storage implementation owns sqlite3 connections and prepared statements. RAII finalizes statements and rolls back unfinished savepoints. The application defines the item-and-links unit of work in ItemService::saveItemWithLinks; repository writes use nested savepoints.
Every item has a revision that moves on with each change to it, its values or its links. A save based on an older revision is refused with a conflict, and every client then shows what differs and offers to overwrite or reload — so two clients never silently overwrite each other.
Cards and the quiz
A card is a question and an answer about one item, with two counts and the time of the last attempt. CardService adds, edits and deletes cards, records a Yes or a No, and gathers the cards of a quiz: those of one item, or of its relationship neighbourhood at 1 to 3 links, found by LinkService::neighborhood — the same breadth-first walk the graph draws, so a quiz covers exactly the items the graph shows. An answer is one UPDATE that increments the count and stamps the database clock, so concurrent answers are never lost. Cards stay out of the item's revision, its full-text index and its Review schedule: a quiz answer never changes understanding, reviewedAt or reviewDueAt, and every client manages cards in a dialog or screen of their own rather than in the item editor's Save.
Named Boards
BoardService exposes named BoardRecords with an ID, Markdown content and a revision. They are deliberately independent of items: there is no item group, type, field value, search index or Review state. A new database starts with Main; clients can create, rename and delete Boards while the final one is protected. Per-Board compare-and-swap revision checks prevent clients from silently replacing each other's text. REST exposes CRUD at /api/v1/boards, while the old singular endpoint remains for compatibility. Export format version 6 introduced all named Boards; import matches them by name and preserves different existing content.
Search
Where SQLite has FTS5, item content, titles, aliases, tags and flags are indexed with unicode61 remove_diacritics 2, so a search folds case and diacritics in every script. The index keeps the revision it last saw of each item and catches up lazily before a search, so changes made by another program are found too. Without FTS5 the search falls back to substring matching. Results rank exact titles first, then aliases, prefixes and matches in the content.
Files and images
Blob bytes live beside the database under blobs/, named by their SHA-256; identical files are stored once. An Image value stores <media type>:<SHA-256> — PNG, JPEG, GIF, WebP or BMP, never SVG — and the server checks the file's first bytes against the declared type. Files are kept after their last reference goes. The desktop's Blob maintenance and the local LexiconServer blobs scan|verify|collect commands inspect or remove unreferenced files; no maintenance operation is exposed over REST.
UTF-8 and comparison
Core, application, and storage use UTF-8 std::string. Qt converts at the desktop boundary. Uniqueness constraints and exact lookups fold ASCII letters only and compare non-ASCII bytes exactly, matching SQLite NOCASE, LOWER, and default LIKE. The full-text search is the one place that folds case and diacritics beyond ASCII.
Persisted enums
These integers are stored in the database; values are only ever appended, never renumbered.
| Enum | Values |
|---|---|
ItemStatus | 0 None, 1 Draft, 2 Completed |
UnderstandingLevel | 0 Unknown, 1 Recognized, 2 Understood, 3 Practiced, 4 Mastered |
LinkType | 0 None, 1 IsA, 2 PartOf, 3 Uses, 4 DependsOn, 5 Implements, 6 Related, 7 Contrasts, 8 AlternativeTo, 9 ParentOf, 10 Custom |
FieldDataType | 0 Integer, 1 Float, 2 Text, 3 Date, 4 Time, 5 Timestamp, 6 Boolean, 7 Enum, 8 Blob, 9 Other, 10 Image |
The REST API and the export format use the names, never the numbers.
The server
LexiconServer is a single-user server: one account, created on the server machine with auth set-user, a scrypt password hash, and bearer-token sessions that survive a restart. It serves JSON under /api/v1, and web assets only when asked: --web-dir mounts one directory — a copy of lexicon-web — read-only under /web, where the client is same-origin and needs no CORS. Deployed anywhere else, a browser reaches the API only from the origins given with --allowed-origin. With --backup-dir it backs itself up. See docs/rest-api.md and docs/server.md.
The clients
The desktop, web and Android clients show and edit the same stored things — items, types, links, review, cards and their quiz, alarms, images and named shared Boards — each in its own idiom. Desktop and web additionally provide Mass Insert as a client-side workflow: they create ordinary items one at a time through the existing service or REST endpoint, while continuously saving the unfinished worksheet in local settings or browser storage. There is no Mass Insert table or batch REST endpoint, and Android deliberately has no worksheet. Alarms ring in every client, and the server keeps which ones were dismissed, their recurrence, ASAP marker and optional Group, so a dismissal anywhere stops them everywhere. The Android app keeps no copy of the dictionary; only Inbox ideas caught without a connection wait on the phone until the server takes them.