mirror of
https://github.com/bryanmanio/obsidian-lexophile.git
synced 2026-07-22 06:56:30 +00:00
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:
parent
ce6ab40708
commit
cc355c5fbd
12 changed files with 768 additions and 207 deletions
38
README.md
38
README.md
|
|
@ -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
327
main.js
File diff suppressed because one or more lines are too long
149
scripts/build-dictionary.py
Normal file
149
scripts/build-dictionary.py
Normal 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()
|
||||
|
|
@ -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
159
src/dictionaryStore.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
17
src/kobo.ts
17
src/kobo.ts
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
109
src/main.ts
109
src/main.ts
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
17
src/sqlite.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue