The text.db format¶
An indexed dictionary is an SQLite 3 database, text.db, with an optional
second database for its media pack, media.db. This page specifies both, for
programs that read or write them.
~/.dmshlabs/dictshelf/db/Webster/
text.db articles and indexes
media.db media pack: images and audio (optional)
media.link.db a cache; can be deleted (optional)
info.txt source and build details
res/ resource overrides (optional)
text.db alone is a complete dictionary; the other files are optional or
derived.
PRAGMA user_version is 1; DictShelf does not open a database with another
value. Tables have been added without changing it (see
Compatibility), so a reader must check that a table exists
before using it.
Schema¶
PRAGMA user_version = 1;
CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT);
CREATE TABLE entry(id INTEGER PRIMARY KEY,
w TEXT NOT NULL, -- display headword
m TEXT NOT NULL); -- article body (see below)
CREATE INDEX idx_entry_w ON entry(w COLLATE NOCASE);
CREATE TABLE alias(w TEXT NOT NULL, -- an alternative spelling
entry_id INTEGER NOT NULL REFERENCES entry(id));
CREATE INDEX idx_alias_w ON alias(w COLLATE NOCASE);
CREATE VIRTUAL TABLE entry_fts USING fts5( -- rowid = entry.id
w, txt, content='', columnsize=0,
tokenize='unicode61 remove_diacritics 2');
CREATE VIRTUAL TABLE entry_trigram USING fts5(-- only with the contains index
w, content='', columnsize=0, tokenize='trigram');
entry holds the whole dictionary. The other tables are indexes and can be
rebuilt from it.
Reading articles¶
entry.m is either plain HTML text or a DEFLATE-compressed BLOB, decided
per row by its first byte:
| First byte | What the row holds |
|---|---|
0x00 |
raw DEFLATE stream, starting at byte 1 |
| anything else | the article HTML itself, as text |
Article HTML never begins with NUL. Both forms occur in the same table — short articles (under 120 bytes) and ones that did not compress are stored literally — so a reader must handle both.
import sqlite3, zlib
db = sqlite3.connect("file:text.db?mode=ro", uri=True)
for w, m in db.execute("SELECT w, m FROM entry ORDER BY id"):
blob = m if isinstance(m, bytes) else m.encode()
html = zlib.decompress(blob[1:], -15).decode() if blob[:1] == b"\x00" \
else blob.decode()
print(w, html[:80])
-15 is raw DEFLATE with no zlib or gzip header — zlib.decompress(blob[1:])
without it will fail.
Each row is compressed on its own, without a shared dictionary, so any row can be read alone. Short or incompressible rows are stored literally; reading handles both forms, including databases written before compression was added.
Headwords, aliases and sub-entries¶
entry.wis the headword as it is displayed. Lookups compareCOLLATE NOCASE, which is the collation both indexes are built with.aliasholds every other spelling that must find this entry: StarDict.synentries, MDX@@@LINKredirects, Slob aliases, DSL variant headwords. Redirects are resolved during indexing: an alias always points at an existingentry.id, never at another alias.- A headword beginning with
@(@examples_woman) is an MDict-style sub-entry: a fragment an article pulls in by link, never a word. It stays reachable by exact lookup, is excluded from browsing and contains search, and is not counted inmeta.entry_count(meta.sub_entriescounts them). Exporters should normally skipw LIKE '@_%'.
The search indexes¶
Both FTS5 tables are contentless (content='') and keyed by
rowid = entry.id; they hold no text that needs exporting.
| Table | Column | Holds | Present when |
|---|---|---|---|
entry_fts |
w |
the headword | always |
entry_fts |
txt |
the article with tags stripped | with the full-text index; otherwise '' |
entry_trigram |
w |
the folded headword | with the contains index |
Folded means lowercased, NFD-normalised, with combining marks dropped — so
Café is indexed as cafe. meta.fold_version records the rules used; a
mismatch marks the contains index outdated and does not affect the articles.
entry_fts exists in every text.db: accent-insensitive exact and prefix
lookup use it.
meta¶
A flat key/value table. Unknown keys must be ignored; absent keys must be treated as empty rather than as an error.
| Key | Meaning |
|---|---|
name |
The title shown to the reader. |
description |
The text under About this dictionary. Plain text or a small HTML fragment; a tag marks it as HTML. |
format |
Where it came from: mdx, stardict, slob, dsl, bgl, zim, … |
entry_count |
Entries excluding sub-entries |
sub_entries |
How many @-prefixed fragments there are |
ingest_level |
text = article text is indexed, headwords = only headwords |
has_trigram |
1 when entry_trigram was built (still feature-detect the table) |
body_encoding |
deflate or plain — a note for humans; the read path decides per row |
dict_uuid |
32 hex characters; what pairs this file with its media.db |
index_lang, contents_lang |
Languages, only when the source declared them |
source_path, source_size, source_mtime, source_sha256_1M |
The dictionary file it was indexed from, for detecting changes |
created |
RFC 3339, UTC |
fold_version, markup_version |
Which text-folding and article-markup rules built this file |
name and description are HTML-unescaped on the way out, so a title stored
as A & B displays as A & B.
Article HTML¶
Bodies are stored as the dictionary wrote them, converted to HTML where the source format was not HTML. In particular, references to media are left in their original spelling:
DictShelf rewrites them to /res/<dictionary id>/… when it serves an article, not
in the database. An exporter gets the original names and looks each one up in
media.db (below) or beside the dictionary file.
media.db¶
Written when the media pack is added. Same conventions and user_version as
text.db.
PRAGMA user_version = 1;
CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT); -- dict_uuid, name, format
CREATE TABLE resource(name TEXT PRIMARY KEY, mime TEXT, data BLOB);
resource.nameis the name as articles reference it. A name is matched exactly, then case-insensitively, then in both Unicode normalization forms: MDX stores resource names in lower case, loose files keep their spelling, and an article may use either.resource.datais the file's bytes, not compressed by DictShelf.meta.dict_uuidmust equal thedict_uuidin thetext.dbbeside it. A mismatched pair is refused.
media.link.db caches where media is located inside the dictionary files. It
is derived, need not be copied, and can be deleted.
Exporting¶
Without SQL:
This writes out/Webster.csv: "#key","value" metadata rows, then one row
per entry (headword, article, alternative headwords), with resources in
Webster.csv_res. pyglossary converts
this layout to any format it supports.
The complete dictionary, with SQL:
SELECT e.id, e.w, e.m,
(SELECT group_concat(a.w, char(10)) FROM alias a WHERE a.entry_id = e.id) AS aliases
FROM entry e
WHERE e.w NOT LIKE '@_%' -- skip sub-entry fragments
ORDER BY e.id;
Decode m as shown above. Open the file read-only
(file:…?mode=ro) while DictShelf may be running.
Authoring a dictionary directly¶
A text.db written by another program is a DictShelf dictionary. Put its folder
in the library or in a dictionary folder.
Requirements:
-
PRAGMA user_version = 1. - Create
entry,aliasandentry_ftseven for a headwords-only dictionary; leaveentry_fts.txtas''when you do not index text. - Create both indexes exactly as shown, with
COLLATE NOCASE; otherwise SQLite does not use them and every lookup scans the table. -
entry_trigramstores the folded headword, not the raw one. - Article bodies are compressed when this reduces their stored size. Readers also accept literal bodies written by older versions.
- Set
meta.nameandmeta.description: they are the title and the About this dictionary text. - Set
meta.dict_uuid(16 random bytes, hex) if you also write amedia.db, and put the same value in both. - Leave out
meta.source_path. If it names an existing file, DictShelf takes the About text from that file and ignoresdescription. - Write to a temporary file and rename it into place, so an incomplete database is never found.
meta.format is a free label. Use your own (native), not dsl or
stardict: DictShelf treats those as built by itself and may offer to rebuild
them.
Compatibility¶
user_versionstays at 1. Tables have been added without changing it (entry_trigram); check that a table exists before using it:SELECT count(*) FROM sqlite_master WHERE type='table' AND name='entry_trigram'.- Unknown
metakeys are ignored, so you can add your own. - A database without an optional index opens with fewer search modes.
- FTS5 is required: an SQLite build without it cannot open a
text.db. The trigram tokenizer needs SQLite 3.34 or newer.