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>
This commit is contained in:
Bryan Maniotakis 2026-05-11 02:44:09 -06:00
parent ce6ab40708
commit cc355c5fbd
12 changed files with 768 additions and 207 deletions

View file

@ -32,25 +32,26 @@ Every word becomes a note in your `Dictionary/` folder with the part of speech,
## How it works
The Chrome extension and the plugin authenticate to each other with a shared secret token (auto-generated during onboarding) so random websites can't write to your vault.
The plugin ships with a **local SQLite dictionary** (~23MB, ~167K entries). Lookups happen entirely offline — the dictionary file downloads once on first use, then everything stays on your machine.
```
┌──────────────┐ definition ┌────────────────┐
│ Chrome │ ←───────────────── │ Free │
│ extension │ │ Dictionary │
└──────┬───────┘ │ API │
│ └────────────────┘
│ POST /word
│ Bearer <token>
┌──────────────────────────────┐
│ Obsidian plugin │
│ (HTTP server on :27124) │
│ │
│ creates note in vault → │ → 📓 Dictionary/serendipity.md
└──────────────────────────────┘
┌──────────────┐ POST /word ┌────────────────────────────────┐
│ Chrome │ ───────────────► │ Obsidian plugin │
│ extension │ Bearer <tok> │ (HTTP server on :27124) │
└──────────────┘ │ │
│ ┌──────────────────────┐ │
│ │ dictionary.sqlite │ │
Mass-import / Kobo / manual ►──┼──►│ (167K entries, local)│ │
│ └──────────────────────┘ │
│ │
│ creates note in vault → │ → 📓 Dictionary/serendipity.md
└────────────────────────────────┘
```
Dictionary data is derived from [MattDodsonEnglish/english-dictionary](https://github.com/MattDodsonEnglish/english-dictionary) (CC BY-SA 3.0, originally from Wiktionary via dictionaryapi.dev). The Chrome extension still calls dictionaryapi.dev for its own lookups; the plugin itself makes no network calls during normal use.
The Chrome extension and the plugin authenticate to each other with a shared secret token (auto-generated during onboarding) so random websites can't write to your vault.
## Installation
### From the Obsidian community store *(coming soon)*
@ -70,7 +71,8 @@ Install the companion extension from the [lexophile-chrome-extension](https://gi
## Setup
After installing, the Chrome extension opens a welcome tab. It auto-generates an API token; copy it and paste it into **Obsidian → Settings → Lexophile → API token**, then click the welcome page's **Test connection** button. Once it goes green, you're done.
1. Open **Settings → Lexophile → Local dictionary** and click **Download**. The plugin fetches ~23MB once and stores it inside your vault's plugin folder. The "Ready" pill confirms it loaded.
2. *(Optional, for web capture)* The Chrome extension opens a welcome tab on install. It auto-generates an API token; copy it and paste it into **Settings → Lexophile → API token**, then click **Test connection**.
## Usage
@ -109,7 +111,9 @@ Every imported word gets `source: "[[Book Name]]"` in its frontmatter. If the bo
## Privacy
Everything runs on your machine. The only network call is to the [Free Dictionary API](https://dictionaryapi.dev/) (`api.dictionaryapi.dev`) to fetch the actual definition for a word. No analytics, no telemetry, no account, no cloud.
The plugin is fully local after the initial dictionary download. No analytics, no telemetry, no account, no cloud. The only network call after setup is when you click **Re-download** in settings.
The Chrome extension does call [dictionaryapi.dev](https://dictionaryapi.dev/) when you right-click to capture a word — that's outside the plugin and only happens when you use the extension.
## Development

327
main.js

File diff suppressed because one or more lines are too long

149
scripts/build-dictionary.py Normal file
View file

@ -0,0 +1,149 @@
#!/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()

View file

@ -1,10 +1,8 @@
import { requestUrl } from 'obsidian';
import { DictionaryNotReadyError, DictionaryStore } from './dictionaryStore';
import type { WordEntry } from './lexicon';
const API_BASE = 'https://api.dictionaryapi.dev/api/v2/entries/en/';
// Distinguishes "the dictionary API has no entry for this word" from genuine
// errors (network, 5xx, malformed response). Callers can use this to decide
// Distinguishes "the dictionary has no entry for this word" from genuine
// errors (not-ready store, IO failure). Callers can use this to decide
// whether to fall back to a stub note.
export class WordNotFoundError extends Error {
constructor(public readonly word: string) {
@ -13,78 +11,13 @@ export class WordNotFoundError extends Error {
}
}
interface ApiPhonetic {
text?: string;
audio?: string;
}
interface ApiDefinition {
definition: string;
example?: string;
}
interface ApiMeaning {
partOfSpeech: string;
definitions: ApiDefinition[];
}
interface ApiEntry {
word: string;
phonetic?: string;
phonetics?: ApiPhonetic[];
meanings: ApiMeaning[];
}
// dictionaryapi.dev returns 429 (Too Many Requests) and occasionally 503 under
// load. Both are transient — back off and retry. Other non-200 statuses
// (besides 404, which is handled separately as "word not found") are surfaced
// to the caller.
const MAX_RETRIES = 4;
const BASE_BACKOFF_MS = 1500;
export async function lookupWord(word: string): Promise<WordEntry> {
const url = API_BASE + encodeURIComponent(word.trim());
let res = await requestUrl({ url, method: 'GET', throw: false });
let attempt = 0;
while ((res.status === 429 || res.status === 503) && attempt < MAX_RETRIES) {
const delay = BASE_BACKOFF_MS * Math.pow(2, attempt);
await new Promise((r) => setTimeout(r, delay));
attempt++;
res = await requestUrl({ url, method: 'GET', throw: false });
}
if (res.status === 404) {
throw new WordNotFoundError(word);
}
if (res.status === 429) {
throw new Error(`Rate-limited by Dictionary API after ${attempt} retries. Try a smaller batch or wait a minute.`);
}
if (res.status !== 200) {
throw new Error(`Dictionary API returned status ${res.status}.`);
}
const data = res.json as ApiEntry[];
if (!Array.isArray(data) || data.length === 0) {
throw new WordNotFoundError(word);
}
const first = data[0];
const meaning = first.meanings?.[0];
const definition = meaning?.definitions?.[0];
if (!meaning || !definition) {
throw new WordNotFoundError(word);
}
const phonetic = first.phonetic ?? first.phonetics?.find((p) => p.text)?.text ?? '';
return {
word: first.word,
partOfSpeech: meaning.partOfSpeech,
definition: definition.definition,
example: definition.example ?? '',
phonetic,
source: '',
};
export { DictionaryNotReadyError };
// Looks up a single word against the local SQLite dictionary. The store is
// passed in (rather than being a module global) so tests and modal callers
// can use the same singleton the plugin instantiated.
export async function lookupWord(store: DictionaryStore, word: string): Promise<WordEntry> {
const result = store.lookup(word);
if (!result) throw new WordNotFoundError(word);
return result;
}

159
src/dictionaryStore.ts Normal file
View file

@ -0,0 +1,159 @@
import { App, Plugin, requestUrl } from 'obsidian';
import type { Database } from 'sql.js';
import { getSqlJs } from './sqlite';
import type { WordEntry } from './lexicon';
// The local SQLite shipped via release asset has roughly this shape:
// CREATE TABLE entries (
// word TEXT PRIMARY KEY,
// part_of_speech TEXT,
// definition TEXT NOT NULL,
// example TEXT,
// phonetic TEXT
// );
// CREATE INDEX idx_word_lower ON entries(LOWER(word));
const DB_FILENAME = 'dictionary.sqlite';
// Default download URL — a release asset on the plugin repo. Users can
// override this in settings if they prefer to host their own mirror.
export const DEFAULT_DICTIONARY_URL =
'https://github.com/bryanmanio/obsidian-lexophile/releases/download/dictionary-v1/dictionary.sqlite';
export class DictionaryNotReadyError extends Error {
constructor() {
super('Local dictionary not loaded. Download it from Settings → Lexophile.');
this.name = 'DictionaryNotReadyError';
}
}
export interface DictionaryStatus {
ready: boolean;
sizeBytes: number | null; // bytes on disk, or null if missing
entryCount: number | null; // null if not loaded
}
// Loads and queries the bundled dictionary SQLite. Lazy: nothing happens
// until init() succeeds, and a missing file leaves the store in "not ready"
// mode rather than throwing. Callers detect not-ready via isReady() and
// surface a download prompt.
export class DictionaryStore {
private db: Database | null = null;
private entryCount: number | null = null;
constructor(private app: App, private plugin: Plugin) {}
// Resolves vault-relative path to the SQLite, e.g.
// ".obsidian/plugins/lexophile/dictionary.sqlite".
private dbPath(): string {
// configDir is "<vault>/.obsidian" (or user-renamed) — normalize the
// path manually so we don't need the obsidian normalizePath helper here.
const configDir = this.app.vault.configDir;
const id = this.plugin.manifest.id;
return `${configDir}/plugins/${id}/${DB_FILENAME}`;
}
// Attempts to load the file from disk. Safe to call repeatedly; subsequent
// calls are no-ops once loaded. Returns true if the dictionary is ready.
async init(): Promise<boolean> {
if (this.db) return true;
const path = this.dbPath();
const exists = await this.app.vault.adapter.exists(path);
if (!exists) return false;
const buf = await this.app.vault.adapter.readBinary(path);
const SQL = await getSqlJs();
this.db = new SQL.Database(new Uint8Array(buf));
this.entryCount = this.countEntries();
return true;
}
isReady(): boolean {
return this.db !== null;
}
async status(): Promise<DictionaryStatus> {
const path = this.dbPath();
const exists = await this.app.vault.adapter.exists(path);
let sizeBytes: number | null = null;
if (exists) {
const stat = await this.app.vault.adapter.stat(path);
sizeBytes = stat?.size ?? null;
}
return { ready: this.isReady(), sizeBytes, entryCount: this.entryCount };
}
// Downloads the dictionary SQLite from the given URL into the plugin
// folder, then loads it. Caller is responsible for surfacing progress —
// requestUrl doesn't expose chunk events, so we report start and end only.
async download(url: string): Promise<void> {
const res = await requestUrl({ url, method: 'GET', throw: false });
if (res.status !== 200) {
throw new Error(`Download failed: HTTP ${res.status}`);
}
// requestUrl returns arrayBuffer for binary content.
const buf: ArrayBuffer = res.arrayBuffer;
if (buf.byteLength < 16 || !this.looksLikeSqlite(new Uint8Array(buf))) {
throw new Error('Downloaded file does not look like a SQLite database.');
}
await this.app.vault.adapter.writeBinary(this.dbPath(), buf);
// Close any previously-loaded DB so init() picks up the new file.
this.close();
await this.init();
}
// Looks up a word case-insensitively. Returns null if not found. The
// caller (lookupWord in dictionary.ts) translates null into
// WordNotFoundError so existing call sites keep working.
lookup(word: string): WordEntry | null {
if (!this.db) throw new DictionaryNotReadyError();
const stmt = this.db.prepare(
'SELECT word, part_of_speech, definition, example, phonetic ' +
'FROM entries WHERE LOWER(word) = LOWER(?) LIMIT 1'
);
try {
stmt.bind([word.trim()]);
if (!stmt.step()) return null;
const row = stmt.getAsObject() as {
word?: string;
part_of_speech?: string;
definition?: string;
example?: string;
phonetic?: string;
};
return {
word: String(row.word ?? word),
partOfSpeech: row.part_of_speech ?? '',
definition: String(row.definition ?? ''),
example: row.example ?? '',
phonetic: row.phonetic ?? '',
source: '',
};
} finally {
stmt.free();
}
}
close(): void {
if (this.db) {
this.db.close();
this.db = null;
this.entryCount = null;
}
}
private countEntries(): number | null {
if (!this.db) return null;
const res = this.db.exec('SELECT COUNT(*) FROM entries');
return Number(res?.[0]?.values?.[0]?.[0] ?? 0);
}
private looksLikeSqlite(buf: Uint8Array): boolean {
// SQLite files start with the literal "SQLite format 3\0".
const header = new TextDecoder().decode(buf.slice(0, 15));
return header === 'SQLite format 3';
}
}

View file

@ -1,8 +1,5 @@
import { promises as fs } from 'fs';
import initSqlJs, { type SqlJsStatic } from 'sql.js';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — esbuild's binary loader resolves this to a Uint8Array
import wasmBinary from 'sql.js/dist/sql-wasm.wasm';
import { getSqlJs } from './sqlite';
export interface KoboWord {
word: string;
@ -21,18 +18,6 @@ export function cleanKoboWord(text: string): string {
return text.replace(TRIM_CHARS, '').trim();
}
let sqlJsPromise: Promise<SqlJsStatic> | null = null;
// Lazy-init so plugin load is unaffected for users who never run the import.
function getSqlJs(): Promise<SqlJsStatic> {
if (!sqlJsPromise) {
// esbuild's binary loader hands us a Uint8Array; sql.js types want
// ArrayBuffer but accept either at runtime. Cast through unknown.
sqlJsPromise = initSqlJs({ wasmBinary: wasmBinary as unknown as ArrayBuffer });
}
return sqlJsPromise;
}
export async function fileExists(filePath: string): Promise<boolean> {
try {
const stat = await fs.stat(filePath);

View file

@ -1,15 +1,15 @@
import { App, Modal, Notice, Setting } from 'obsidian';
import { fileExists, readKoboWords, type KoboWord } from './kobo';
import { lookupWord, WordNotFoundError } from './dictionary';
import { DictionaryNotReadyError, type DictionaryStore } from './dictionaryStore';
import { createStubEntry, createWordNote, wordNoteExists, type WordEntry } from './lexicon';
import { bookNoteExists, cleanBookTitle, ensureBookStub, titleCase } from './books';
import type { DictionarySettings } from './settings';
const DEFAULT_KOBO_PATH = '/Volumes/KOBOeReader/.kobo/KoboReader.sqlite';
// Spacing between consecutive lookups. dictionaryapi.dev is a free public API
// with an undocumented rate limit; ~3 req/s is safe in practice. lookupWord()
// itself handles transient 429s by backing off and retrying.
const RATE_LIMIT_MS = 350;
// Local SQLite lookups are sub-millisecond, but we still yield to the event
// loop between iterations so the progress line can repaint.
const YIELD_MS = 0;
type State = 'path' | 'wordlist' | 'progress' | 'done';
@ -63,9 +63,12 @@ export class KoboImportModal extends Modal {
private summary: ImportSummary = { imported: 0, skipped: 0, stubbed: 0, notFound: [], errors: [] };
private cancelRequested = false;
constructor(app: App, getSettings: () => DictionarySettings) {
private store: DictionaryStore;
constructor(app: App, getSettings: () => DictionarySettings, store: DictionaryStore) {
super(app);
this.getSettings = getSettings;
this.store = store;
}
onOpen() {
@ -131,6 +134,9 @@ export class KoboImportModal extends Modal {
this.render();
try {
if (!this.store.isReady()) {
throw new Error('Local dictionary not loaded. Download it from Settings → Lexophile first.');
}
const path = this.filePath.trim();
if (!path) throw new Error('Please enter a path.');
if (!(await fileExists(path))) {
@ -386,7 +392,7 @@ export class KoboImportModal extends Modal {
let entry: WordEntry;
let stubbed = false;
try {
entry = await lookupWord(word);
entry = await lookupWord(this.store, word);
} catch (err) {
if (err instanceof WordNotFoundError) {
if (this.stubUnfound) {
@ -395,13 +401,13 @@ export class KoboImportModal extends Modal {
} else {
this.summary.notFound.push({ word, source });
console.warn(`[Lexophile] "${word}": not found`);
if (i < queue.length - 1) await new Promise((r) => setTimeout(r, RATE_LIMIT_MS));
if (i < queue.length - 1) await new Promise((r) => setTimeout(r, YIELD_MS));
continue;
}
} else {
this.summary.errors.push({ word, reason: (err as Error).message });
console.warn(`[Lexophile] "${word}":`, (err as Error).message);
if (i < queue.length - 1) await new Promise((r) => setTimeout(r, RATE_LIMIT_MS));
if (i < queue.length - 1) await new Promise((r) => setTimeout(r, YIELD_MS));
continue;
}
}
@ -417,7 +423,7 @@ export class KoboImportModal extends Modal {
}
if (i < queue.length - 1) {
await new Promise((r) => setTimeout(r, RATE_LIMIT_MS));
await new Promise((r) => setTimeout(r, YIELD_MS));
}
}

View file

@ -1,5 +1,6 @@
import { App, Notice, Plugin, PluginSettingTab, Setting, TFolder, normalizePath } from 'obsidian';
import { DictionaryServer } from './server';
import { DictionaryStore } from './dictionaryStore';
import { AddWordModal } from './wordModal';
import { KoboImportModal } from './koboImportModal';
import { MassImportModal } from './massImportModal';
@ -9,11 +10,21 @@ import type { DictionarySettings } from './settings';
export default class DictionaryPlugin extends Plugin {
settings: DictionarySettings;
store: DictionaryStore;
private server: DictionaryServer;
async onload() {
await this.loadSettings();
this.store = new DictionaryStore(this.app, this);
// Eager-load is best-effort: a missing file just leaves the store in
// "not ready" mode, which the settings tab and import modals handle.
try {
await this.store.init();
} catch (err) {
console.error('[Lexophile] dictionary init failed:', err);
}
this.server = new DictionaryServer(
this.app,
() => this.settings,
@ -28,7 +39,7 @@ export default class DictionaryPlugin extends Plugin {
id: 'add-word',
name: 'Add word to lexicon',
callback: () => {
new AddWordModal(this.app, () => this.settings).open();
new AddWordModal(this.app, () => this.settings, this.store).open();
},
});
@ -36,7 +47,7 @@ export default class DictionaryPlugin extends Plugin {
id: 'mass-import',
name: 'Mass-import words from a list',
callback: () => {
new MassImportModal(this.app, () => this.settings).open();
new MassImportModal(this.app, () => this.settings, this.store).open();
},
});
@ -48,7 +59,7 @@ export default class DictionaryPlugin extends Plugin {
new Notice('Lexophile: enable Kobo import in Settings → Lexophile first.');
return;
}
new KoboImportModal(this.app, () => this.settings).open();
new KoboImportModal(this.app, () => this.settings, this.store).open();
},
});
@ -64,6 +75,7 @@ export default class DictionaryPlugin extends Plugin {
async onunload() {
await this.stopServer();
this.store?.close();
}
async startServer() {
@ -122,6 +134,39 @@ class DictionarySettingTab extends PluginSettingTab {
fromBrowser.createEl('strong', { text: 'Add "<word>" to Lexophile' });
fromBrowser.appendText('.');
// ── Local dictionary ─────────────────────────────────────────
containerEl.createEl('h3', { text: 'Local dictionary' });
const dictIntro = containerEl.createEl('p', { cls: 'setting-item-description' });
dictIntro.appendText(
'Lexophile looks up words against a local SQLite dictionary (~23MB, ~167K English entries from Wiktionary via '
);
dictIntro.createEl('a', {
text: 'MattDodsonEnglish/english-dictionary',
href: 'https://github.com/MattDodsonEnglish/english-dictionary',
});
dictIntro.appendText('). It downloads once on first use — no network calls during normal use after that.');
const dictStatus = containerEl.createDiv();
dictStatus.style.cssText =
'padding: 10px 12px; margin: 4px 0 12px; background: var(--background-secondary); border-radius: 6px; font-size: 13px;';
this.renderDictionaryStatus(dictStatus);
new Setting(containerEl)
.setName('Dictionary download URL')
.setDesc('Where the dictionary SQLite is downloaded from. Override only if mirroring or using a custom build.')
.addText((text) => {
text
.setPlaceholder('https://…/dictionary.sqlite')
.setValue(this.plugin.settings.dictionaryUrl)
.onChange(async (value) => {
this.plugin.settings.dictionaryUrl = value;
await this.plugin.saveSettings();
});
text.inputEl.style.cssText = 'width: 100%; font-family: monospace; font-size: 12px;';
});
// ── Note creation ────────────────────────────────────────────
new Setting(containerEl)
@ -376,4 +421,62 @@ class DictionarySettingTab extends PluginSettingTab {
bmcImg.alt = 'Buy Me A Coffee';
bmcImg.style.cssText = 'height: 40px; width: auto; border-radius: 8px;';
}
// Renders the dictionary status pill + download/redownload button into the
// given container. Kept on the settings tab class so it can re-render
// itself after a download finishes without rebuilding the whole pane.
private async renderDictionaryStatus(container: HTMLElement) {
container.empty();
const status = await this.plugin.store.status();
const row = container.createDiv();
row.style.cssText = 'display: flex; align-items: center; gap: 12px;';
const pill = row.createSpan();
pill.style.cssText = 'font-weight: 600; padding: 2px 10px; border-radius: 12px; font-size: 12px;';
if (status.ready) {
pill.style.background = 'var(--background-modifier-success)';
pill.style.color = 'var(--text-on-accent)';
pill.textContent = 'Ready';
} else if (status.sizeBytes !== null) {
pill.style.background = 'var(--background-modifier-border)';
pill.style.color = 'var(--text-muted)';
pill.textContent = 'Downloaded, not loaded';
} else {
pill.style.background = 'var(--background-modifier-error)';
pill.style.color = 'var(--text-on-accent)';
pill.textContent = 'Not downloaded';
}
const detail = row.createDiv();
detail.style.cssText = 'flex: 1; color: var(--text-muted); font-size: 12px;';
if (status.ready && status.entryCount !== null) {
const mb = status.sizeBytes ? (status.sizeBytes / 1024 / 1024).toFixed(1) : '?';
detail.textContent = `${status.entryCount.toLocaleString()} entries · ${mb}MB on disk`;
} else {
detail.textContent = 'Click Download to fetch the dictionary file.';
}
const btn = row.createEl('button');
btn.textContent = status.ready ? 'Re-download' : 'Download';
if (status.ready) btn.style.background = 'transparent';
btn.addEventListener('click', async () => {
const url = this.plugin.settings.dictionaryUrl;
if (!url) {
new Notice('Lexophile: set a Dictionary download URL first.');
return;
}
btn.disabled = true;
btn.textContent = 'Downloading…';
try {
await this.plugin.store.download(url);
new Notice('Lexophile: dictionary downloaded and loaded.');
} catch (err) {
new Notice(`Lexophile: download failed — ${(err as Error).message}`);
} finally {
await this.renderDictionaryStatus(container);
}
});
}
}

View file

@ -1,12 +1,12 @@
import { App, Modal, Notice, Setting } from 'obsidian';
import { lookupWord, WordNotFoundError } from './dictionary';
import { DictionaryNotReadyError, type DictionaryStore } from './dictionaryStore';
import { createStubEntry, createWordNote, wordNoteExists, type WordEntry } from './lexicon';
import type { DictionarySettings } from './settings';
// Spacing between consecutive lookups. dictionaryapi.dev is a free public API
// with an undocumented rate limit; ~3 req/s is safe in practice. lookupWord()
// itself handles transient 429s by backing off and retrying.
const RATE_LIMIT_MS = 350;
// Local SQLite lookups are sub-millisecond, but we still yield to the event
// loop between iterations so the progress line can repaint.
const YIELD_MS = 0;
type State = 'input' | 'wordlist' | 'progress' | 'done';
@ -63,9 +63,12 @@ export class MassImportModal extends Modal {
private summary: ImportSummary = { imported: 0, skipped: 0, stubbed: 0, notFound: [], errors: [] };
private cancelRequested = false;
constructor(app: App, getSettings: () => DictionarySettings) {
private store: DictionaryStore;
constructor(app: App, getSettings: () => DictionarySettings, store: DictionaryStore) {
super(app);
this.getSettings = getSettings;
this.store = store;
}
onOpen() {
@ -137,6 +140,11 @@ export class MassImportModal extends Modal {
private parseInput() {
this.inputError = '';
if (!this.store.isReady()) {
this.inputError = 'Local dictionary not loaded. Download it from Settings → Lexophile first.';
this.render();
return;
}
const words = parseWordList(this.rawInput);
if (words.length === 0) {
this.inputError = 'No words detected. Paste a comma- or newline-separated list above.';
@ -337,7 +345,7 @@ export class MassImportModal extends Modal {
let entry: WordEntry;
let stubbed = false;
try {
entry = await lookupWord(word);
entry = await lookupWord(this.store, word);
} catch (err) {
if (err instanceof WordNotFoundError) {
if (this.stubUnfound) {
@ -345,12 +353,12 @@ export class MassImportModal extends Modal {
stubbed = true;
} else {
this.summary.notFound.push(word);
if (i < queue.length - 1) await new Promise((r) => setTimeout(r, RATE_LIMIT_MS));
if (i < queue.length - 1) await new Promise((r) => setTimeout(r, YIELD_MS));
continue;
}
} else {
this.summary.errors.push({ word, reason: (err as Error).message });
if (i < queue.length - 1) await new Promise((r) => setTimeout(r, RATE_LIMIT_MS));
if (i < queue.length - 1) await new Promise((r) => setTimeout(r, YIELD_MS));
continue;
}
}
@ -365,7 +373,7 @@ export class MassImportModal extends Modal {
this.summary.errors.push({ word, reason: (err as Error).message });
}
if (i < queue.length - 1) await new Promise((r) => setTimeout(r, RATE_LIMIT_MS));
if (i < queue.length - 1) await new Promise((r) => setTimeout(r, YIELD_MS));
}
this.state = 'done';

View file

@ -12,6 +12,7 @@ export interface DictionarySettings {
autoCreateBase: boolean;
baseName: string;
stubUnfoundWords: boolean;
dictionaryUrl: string;
enableKoboImport: boolean;
booksFolder: string;
unmatchedBookHandling: UnmatchedBookHandling;
@ -43,6 +44,7 @@ export const DEFAULT_SETTINGS: DictionarySettings = {
autoCreateBase: true,
baseName: '_Dictionary List',
stubUnfoundWords: false,
dictionaryUrl: 'https://github.com/bryanmanio/obsidian-lexophile/releases/download/dictionary-v1/dictionary.sqlite',
enableKoboImport: false,
booksFolder: 'Books',
unmatchedBookHandling: 'create',

17
src/sqlite.ts Normal file
View file

@ -0,0 +1,17 @@
import initSqlJs, { type SqlJsStatic } from 'sql.js';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore — esbuild's binary loader resolves this to a Uint8Array
import wasmBinary from 'sql.js/dist/sql-wasm.wasm';
let sqlJsPromise: Promise<SqlJsStatic> | null = null;
// Lazy-initialize sql.js so the WASM compile only happens when something
// actually needs the SQLite runtime (Kobo import, local dictionary lookup).
export function getSqlJs(): Promise<SqlJsStatic> {
if (!sqlJsPromise) {
// esbuild's binary loader hands us a Uint8Array; sql.js types want
// ArrayBuffer but accept either at runtime. Cast through unknown.
sqlJsPromise = initSqlJs({ wasmBinary: wasmBinary as unknown as ArrayBuffer });
}
return sqlJsPromise;
}

View file

@ -1,5 +1,6 @@
import { App, Modal, Notice, Setting } from 'obsidian';
import { lookupWord, WordNotFoundError } from './dictionary';
import { DictionaryNotReadyError, type DictionaryStore } from './dictionaryStore';
import { createStubEntry, createWordNote } from './lexicon';
import type { DictionarySettings } from './settings';
@ -9,10 +10,12 @@ export class AddWordModal extends Modal {
private inputEl: HTMLInputElement | null = null;
private submitBtn: HTMLButtonElement | null = null;
private getSettings: () => DictionarySettings;
private store: DictionaryStore;
constructor(app: App, getSettings: () => DictionarySettings) {
constructor(app: App, getSettings: () => DictionarySettings, store: DictionaryStore) {
super(app);
this.getSettings = getSettings;
this.store = store;
}
onOpen() {
@ -67,8 +70,17 @@ export class AddWordModal extends Modal {
let entry;
let stubbed = false;
try {
entry = await lookupWord(word);
entry = await lookupWord(this.store, word);
} catch (err) {
if (err instanceof DictionaryNotReadyError) {
new Notice('Lexophile: download the local dictionary in Settings → Lexophile first.');
this.submitting = false;
if (this.submitBtn) {
this.submitBtn.disabled = false;
this.submitBtn.textContent = 'Look up & save';
}
return;
}
if (err instanceof WordNotFoundError && settings.stubUnfoundWords) {
entry = createStubEntry(word);
stubbed = true;