bryanmanio_obsidian-lexophile/scripts/build-dictionary.py
Bryan Maniotakis cc355c5fbd Replace Dictionary API with a local SQLite dictionary
The plugin used to call dictionaryapi.dev for every lookup, which was
slow, rate-limited, and broke offline. It now ships a local SQLite
file (~23MB, ~167K English entries from Wiktionary via the
MattDodsonEnglish/english-dictionary project) and queries it locally
via sql.js.

Plumbing:
- DictionaryStore loads the SQLite from
  <vault>/.obsidian/plugins/lexophile/dictionary.sqlite, downloads
  on demand from a configurable URL (default: a release asset on this
  repo), and exposes a synchronous lookup() that returns a WordEntry
  or null.
- lookupWord is now an async wrapper around store.lookup that throws
  WordNotFoundError on null. DictionaryNotReadyError surfaces the
  pre-download state so modals can fail fast with a helpful message.
- sql.js initialization moved to its own module (src/sqlite.ts) and
  reused by both the Kobo import and the new dictionary store.
- AddWordModal / MassImportModal / KoboImportModal each take the
  store via constructor and guard against the not-ready state before
  starting work.
- Settings tab gains a "Local dictionary" section with a status pill
  (Ready / Downloaded-not-loaded / Not downloaded), an entry count and
  size summary, a Download/Re-download button, and a configurable URL.
- Removed the inter-request rate limit (was 350ms for API throttling) —
  local lookups are sub-millisecond, so a 0ms yield is enough to let
  the progress line repaint.

Bundled dictionary data is hosted as a release asset on
bryanmanio/obsidian-lexophile (tag: dictionary-v1). The plugin does
not call dictionaryapi.dev at any point during normal use. The Chrome
extension still uses the API for its own lookups — out of scope here.

Build artifacts:
- scripts/build-dictionary.py converts the upstream parquet (167,585
  entries) to the compact SQLite schema we ship; documented for
  reproducibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 02:44:09 -06:00

149 lines
4.3 KiB
Python

#!/usr/bin/env python3
"""
Builds the compact dictionary.sqlite that the plugin ships.
Source: entries.parquet from MattDodsonEnglish/english-dictionary v1.0.0
(167,585 English entries swept from dictionaryapi.dev, themselves derived
from Wiktionary, licensed CC BY-SA 3.0).
We keep only the first meaning / first definition of each word's first
entry, plus a phonetic if available — enough to render a Lexophile note
without the synonym/antonym/audio/license overhead of the full schema.
Usage:
pip install duckdb pyarrow
python3 scripts/build-dictionary.py [--output dictionary.sqlite]
The output file is intended to ship as a release asset and is downloaded
by the plugin at first run — too large (~23MB) to bundle in main.js.
"""
import argparse
import json
import os
import sqlite3
import sys
import urllib.request
PARQUET_URL = (
"https://github.com/MattDodsonEnglish/english-dictionary/"
"releases/download/v1.0.0/entries.parquet"
)
def download_parquet(dest: str) -> None:
print(f"Downloading {PARQUET_URL} -> {dest}")
urllib.request.urlretrieve(PARQUET_URL, dest)
print(f" {os.path.getsize(dest) / 1024 / 1024:.1f}MB")
def convert(parquet_path: str, sqlite_path: str) -> None:
import duckdb # imported here so --help works without the dep
if os.path.exists(sqlite_path):
os.remove(sqlite_path)
sq = sqlite3.connect(sqlite_path)
sq.execute(
"""
CREATE TABLE entries (
word TEXT PRIMARY KEY,
part_of_speech TEXT,
definition TEXT NOT NULL,
example TEXT,
phonetic TEXT
)
"""
)
sq.execute("CREATE INDEX idx_word_lower ON entries(LOWER(word))")
con = duckdb.connect(":memory:")
rows = con.execute(
f"SELECT word, entries FROM '{parquet_path}'"
).fetchall()
inserted = 0
skipped = 0
for word, entries_raw in rows:
try:
entries = (
entries_raw
if isinstance(entries_raw, list)
else json.loads(entries_raw)
)
if not entries:
skipped += 1
continue
first = entries[0]
meanings = first.get("meanings") or []
if not meanings:
skipped += 1
continue
m = meanings[0]
defs = m.get("definitions") or []
if not defs:
skipped += 1
continue
d = defs[0]
phonetic = first.get("phonetic") or ""
if not phonetic:
for p in first.get("phonetics") or []:
if p.get("text"):
phonetic = p["text"]
break
sq.execute(
"INSERT OR IGNORE INTO entries "
"(word, part_of_speech, definition, example, phonetic) "
"VALUES (?, ?, ?, ?, ?)",
(
word,
m.get("partOfSpeech", ""),
d.get("definition", ""),
d.get("example", ""),
phonetic,
),
)
inserted += 1
except Exception as e:
print(f" skipped {word!r}: {e}", file=sys.stderr)
skipped += 1
sq.commit()
sq.execute("VACUUM")
sq.close()
size_mb = os.path.getsize(sqlite_path) / 1024 / 1024
print(f"Inserted {inserted:,} entries (skipped {skipped:,}); "
f"{sqlite_path} = {size_mb:.1f}MB")
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--output",
default="dictionary.sqlite",
help="Output SQLite path (default: dictionary.sqlite)",
)
parser.add_argument(
"--parquet",
default=None,
help="Path to entries.parquet (downloaded if omitted)",
)
args = parser.parse_args()
parquet_path = args.parquet
cleanup_parquet = False
if not parquet_path:
parquet_path = "/tmp/lexophile-entries.parquet"
if not os.path.exists(parquet_path):
download_parquet(parquet_path)
cleanup_parquet = True
convert(parquet_path, args.output)
if cleanup_parquet:
os.remove(parquet_path)
if __name__ == "__main__":
main()