diff --git a/README.md b/README.md
new file mode 100644
index 0000000..bd3865a
--- /dev/null
+++ b/README.md
@@ -0,0 +1,155 @@
+
+

+
+ # Lexophile
+
+ **Build your vocabulary one word at a time.**
+
+ An Obsidian plugin and Chrome extension that turn any word you read into a permanent, searchable dictionary note in your vault.
+
+ [Install](#installation) · [How it works](#how-it-works) · [Privacy](#privacy) · [Support](#support)
+
+
+---
+
+## What it does
+
+You're reading something on the web — an article, a book on your Kobo, anything — and you hit a word you want to remember. With Lexophile you can:
+
+- **Right-click on the web** → "Add ** to Lexophile" → a definition note appears in your Obsidian vault.
+- **Inside Obsidian** → run `Lexophile: Add word to lexicon` from the command palette and paste the word.
+- **Plug in your Kobo** → import every word you saved from "My Words" in one shot, with each word linked back to the book it came from.
+
+Every word becomes a note in `Dictionary/` with the part of speech, definition, example sentence, and a clickable source link. A Bases view auto-generates so you can see your whole vocabulary in one table.
+
+## Features
+
+- 📖 **One-click capture** from the web via Chrome extension
+- ✍️ **Manual entry** from inside Obsidian
+- 📱 **Kobo import** — pull "My Words" from `KoboReader.sqlite` and link each word back to its book
+- 🗂️ **Auto-generated Bases view** of your full lexicon, with display columns (Word, Word class, Definition, Example, Source, Date added)
+- 🔗 **Books library integration** — Kobo imports become `[[wikilinks]]` to book notes you (or the plugin) create
+- 🔒 **Local-only** — your words never leave your machine except for the dictionary lookup itself
+- 🎨 **Customizable note template** with frontmatter properties for everything
+
+## How it works
+
+Lexophile has two halves:
+
+| Component | What it is | What it does |
+|---|---|---|
+| **Obsidian plugin** | Runs inside Obsidian | Listens on `localhost:27124`, looks up definitions, writes notes, manages the Bases view, handles Kobo import |
+| **Chrome extension** | Manifest V3 service worker | Adds the right-click menu, fetches the definition, posts it to the local plugin |
+
+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.
+
+```
+┌──────────────┐ definition ┌────────────────┐
+│ Chrome │ ←───────────────── │ Free │
+│ extension │ │ Dictionary │
+└──────┬───────┘ │ API │
+ │ └────────────────┘
+ │ POST /word
+ │ Bearer
+ ▼
+┌──────────────────────────────┐
+│ Obsidian plugin │
+│ (HTTP server on :27124) │
+│ │
+│ creates note in vault → │ → 📓 Dictionary/serendipity.md
+└──────────────────────────────┘
+```
+
+## Installation
+
+Lexophile isn't in the Obsidian community store yet. Install both components manually.
+
+### Obsidian plugin
+
+1. Clone or download this repo.
+2. Build the plugin:
+ ```bash
+ cd obsidian-plugin
+ npm install
+ npm run build
+ ```
+3. Copy the built files into your vault:
+ ```bash
+ cp main.js manifest.json /path/to/vault/.obsidian/plugins/lexophile/
+ ```
+4. In Obsidian, **Settings → Community plugins** → toggle **Lexophile - Personal Dictionary** on.
+
+### Chrome extension
+
+1. In Chrome, open `chrome://extensions`.
+2. Toggle **Developer mode** on.
+3. Click **Load unpacked** and select the `chrome-extension/` folder from this repo.
+4. Pin the extension to your toolbar.
+
+## 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.
+
+## Usage
+
+### From the web
+
+Highlight a word on any page, right-click, and choose **Add "" to Lexophile**. A toast confirms it was saved. The page URL is captured as the word's source.
+
+### Inside Obsidian
+
+`Cmd+P` → **Lexophile: Add word to lexicon** → type or paste a word, hit Enter. The source for these is `manual`.
+
+### From your Kobo eReader
+
+1. **Settings → Lexophile** → enable **Kobo import**, set your books folder.
+2. Plug in your Kobo via USB.
+3. `Cmd+P` → **Lexophile: Import words from Kobo**.
+4. The default path (`/Volumes/KOBOeReader/.kobo/KoboReader.sqlite` on macOS) is pre-filled. Click **Read words**.
+5. A preview lists each book with the Kobo title, an editable Title Case name, a word count, and autocomplete against existing book notes in your library.
+6. Edit any name or pick an existing book, then **Import**.
+
+Every imported word gets `source: "[[Book Name]]"` in its frontmatter. If the book note doesn't exist yet, the plugin creates a stub (configurable).
+
+## Settings reference
+
+| Setting | Default | Notes |
+|---|---|---|
+| Dictionary folder | `Dictionary` | Auto-created if missing |
+| Note naming | As-is | `lowercase` and `Title Case` also available |
+| Duplicate handling | Skip | Or append, or overwrite |
+| Auto-create dictionary base | On | Creates `_Dictionary List.base` for the table view |
+| Local server port | `27124` | Change if it conflicts with another plugin |
+| API token | _(auto-generated)_ | Must match the Chrome extension |
+| Enable Kobo import | Off | Reveals the books library settings |
+| Books folder | `Books` | Where book notes live |
+| When a book isn't in your library | Auto-create stub | Or link without creating, or use plain text |
+
+## 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.
+
+## Development
+
+```bash
+# Plugin (TypeScript + esbuild)
+cd obsidian-plugin
+npm install
+npm run dev # watch mode
+npm run build # production bundle
+
+# Chrome extension — no build step, just load unpacked.
+```
+
+The plugin bundles [sql.js](https://github.com/sql-js/sql.js) (used by the Kobo import) which is why `main.js` is ~1 MB.
+
+## Support
+
+Need help, hit a bug, or have an idea?
+
+- 🐛 **Bug or feature request** → [open an issue](https://github.com/bryanmanio/obsidian-lexophile/issues/new)
+- ☕ **Like it?** → [Buy me a coffee](https://buymeacoffee.com/bryanmanio)
+
+## License
+
+MIT © [Bryan Maniotakis](https://github.com/bryanmanio)
diff --git a/chrome-extension/background.js b/chrome-extension/background.js
index c22d366..f0d3eef 100644
--- a/chrome-extension/background.js
+++ b/chrome-extension/background.js
@@ -9,7 +9,7 @@ const MENU_ID = 'lexophile-add-word';
chrome.runtime.onInstalled.addListener((details) => {
chrome.contextMenus.create({
id: MENU_ID,
- title: 'Add "%s" to Lexophile',
+ title: 'Add word to Obsidian',
contexts: ['selection'],
});
diff --git a/chrome-extension/icons/icon128.png b/chrome-extension/icons/icon128.png
new file mode 100644
index 0000000..f1a59fd
Binary files /dev/null and b/chrome-extension/icons/icon128.png differ
diff --git a/chrome-extension/icons/icon16.png b/chrome-extension/icons/icon16.png
new file mode 100644
index 0000000..ed5b871
Binary files /dev/null and b/chrome-extension/icons/icon16.png differ
diff --git a/chrome-extension/icons/icon32.png b/chrome-extension/icons/icon32.png
new file mode 100644
index 0000000..22ff830
Binary files /dev/null and b/chrome-extension/icons/icon32.png differ
diff --git a/chrome-extension/icons/icon48.png b/chrome-extension/icons/icon48.png
new file mode 100644
index 0000000..b857460
Binary files /dev/null and b/chrome-extension/icons/icon48.png differ
diff --git a/chrome-extension/manifest.json b/chrome-extension/manifest.json
index a557074..025ee74 100644
--- a/chrome-extension/manifest.json
+++ b/chrome-extension/manifest.json
@@ -19,6 +19,18 @@
"open_in_tab": false
},
"action": {
- "default_title": "Lexophile — open options"
+ "default_title": "Lexophile — open options",
+ "default_icon": {
+ "16": "icons/icon16.png",
+ "32": "icons/icon32.png",
+ "48": "icons/icon48.png",
+ "128": "icons/icon128.png"
+ }
+ },
+ "icons": {
+ "16": "icons/icon16.png",
+ "32": "icons/icon32.png",
+ "48": "icons/icon48.png",
+ "128": "icons/icon128.png"
}
}
diff --git a/obsidian-plugin/.gitignore b/obsidian-plugin/.gitignore
index 0640d15..2ab0828 100644
--- a/obsidian-plugin/.gitignore
+++ b/obsidian-plugin/.gitignore
@@ -2,3 +2,4 @@ node_modules/
main.js
*.js.map
data.json
+vault-path.txt
diff --git a/obsidian-plugin/esbuild.config.mjs b/obsidian-plugin/esbuild.config.mjs
index 0381524..3c2a716 100644
--- a/obsidian-plugin/esbuild.config.mjs
+++ b/obsidian-plugin/esbuild.config.mjs
@@ -1,9 +1,48 @@
import esbuild from 'esbuild';
import process from 'process';
+import path from 'path';
+import { existsSync, readFileSync } from 'fs';
+import { copyFile, mkdir } from 'fs/promises';
import builtins from 'builtin-modules';
const prod = process.argv[2] === 'production';
+// Auto-deploy main.js + manifest.json into your vault on every build.
+// Resolution order:
+// 1. vault-path.txt (gitignored) — single line containing your plugin folder path
+// 2. LEXOPHILE_VAULT_PLUGIN_DIR env var
+// 3. (none — skip deploy)
+function resolveVaultPluginDir() {
+ if (existsSync('vault-path.txt')) {
+ const p = readFileSync('vault-path.txt', 'utf8').trim();
+ if (p) return p;
+ }
+ return process.env.LEXOPHILE_VAULT_PLUGIN_DIR ?? '';
+}
+
+const VAULT_PLUGIN_DIR = resolveVaultPluginDir();
+
+const deployToVault = {
+ name: 'deploy-to-vault',
+ setup(build) {
+ build.onEnd(async (result) => {
+ if (result.errors.length > 0) return;
+ if (!VAULT_PLUGIN_DIR) {
+ console.log('[deploy] no vault-path.txt or LEXOPHILE_VAULT_PLUGIN_DIR — skipping');
+ return;
+ }
+ try {
+ await mkdir(VAULT_PLUGIN_DIR, { recursive: true });
+ await copyFile('main.js', path.join(VAULT_PLUGIN_DIR, 'main.js'));
+ await copyFile('manifest.json', path.join(VAULT_PLUGIN_DIR, 'manifest.json'));
+ console.log(`[deploy] copied main.js + manifest.json → ${VAULT_PLUGIN_DIR}`);
+ } catch (err) {
+ console.error('[deploy] failed:', err.message);
+ }
+ });
+ },
+};
+
const context = await esbuild.context({
entryPoints: ['src/main.ts'],
bundle: true,
@@ -29,6 +68,8 @@ const context = await esbuild.context({
sourcemap: prod ? false : 'inline',
treeShaking: true,
outfile: 'main.js',
+ loader: { '.wasm': 'binary' },
+ plugins: [deployToVault],
});
if (prod) {
diff --git a/obsidian-plugin/manifest.json b/obsidian-plugin/manifest.json
index 011a6c1..0df3466 100644
--- a/obsidian-plugin/manifest.json
+++ b/obsidian-plugin/manifest.json
@@ -1,10 +1,10 @@
{
"id": "lexophile",
- "name": "Lexophile",
+ "name": "Lexophile - Personal Dictionary",
"version": "0.1.0",
"minAppVersion": "0.15.0",
- "description": "Receive word definitions from the Chrome extension and create dictionary notes.",
- "author": "Bryan",
- "authorUrl": "",
+ "description": "Build your vocabulary one word at a time. Add manually, or grab it instantly with the Chrome extension.",
+ "author": "Bryan Maniotakis",
+ "authorUrl": "https://github.com/bryanmanio",
"isDesktopOnly": true
}
diff --git a/obsidian-plugin/package-lock.json b/obsidian-plugin/package-lock.json
index b690262..3907fa6 100644
--- a/obsidian-plugin/package-lock.json
+++ b/obsidian-plugin/package-lock.json
@@ -1,17 +1,19 @@
{
- "name": "obsidian-dictionary",
+ "name": "lexophile",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "obsidian-dictionary",
+ "name": "lexophile",
"version": "0.1.0",
"devDependencies": {
"@types/node": "^20.0.0",
+ "@types/sql.js": "^1.4.11",
"builtin-modules": "^3.3.0",
"esbuild": "^0.20.0",
"obsidian": "latest",
+ "sql.js": "^1.14.1",
"tslib": "^2.6.0",
"typescript": "^5.4.0"
}
@@ -450,6 +452,13 @@
"@types/tern": "*"
}
},
+ "node_modules/@types/emscripten": {
+ "version": "1.41.5",
+ "resolved": "https://registry.npmjs.org/@types/emscripten/-/emscripten-1.41.5.tgz",
+ "integrity": "sha512-cMQm7pxu6BxtHyqJ7mQZ2kXWV5SLmugybFdHCBbJ5eHzOo6VhBckEgAT3//rP5FwPHNPeEiq4SmQ5ucBwsOo4Q==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -467,6 +476,17 @@
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/sql.js": {
+ "version": "1.4.11",
+ "resolved": "https://registry.npmjs.org/@types/sql.js/-/sql.js-1.4.11.tgz",
+ "integrity": "sha512-QXIx38p2ZThJaK9vP5ZdqdlRe1FG9I8SmCZOS7FHfB/2qPAjZwkL7/vlfPg6N/oWHuuOaGg/P/IRwfP2W0kWVQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/emscripten": "*",
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/tern": {
"version": "0.23.9",
"resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz",
@@ -562,6 +582,13 @@
"@codemirror/view": "6.38.6"
}
},
+ "node_modules/sql.js": {
+ "version": "1.14.1",
+ "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.1.tgz",
+ "integrity": "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
diff --git a/obsidian-plugin/package.json b/obsidian-plugin/package.json
index 5cb511f..cb5526a 100644
--- a/obsidian-plugin/package.json
+++ b/obsidian-plugin/package.json
@@ -1,7 +1,7 @@
{
"name": "lexophile",
"version": "0.1.0",
- "description": "Obsidian plugin that receives word definitions from the Chrome extension",
+ "description": "Build your vocabulary one word at a time. Add manually, or grab it instantly with the Chrome extension.",
"main": "main.js",
"scripts": {
"dev": "node esbuild.config.mjs",
@@ -9,9 +9,11 @@
},
"devDependencies": {
"@types/node": "^20.0.0",
+ "@types/sql.js": "^1.4.11",
"builtin-modules": "^3.3.0",
"esbuild": "^0.20.0",
"obsidian": "latest",
+ "sql.js": "^1.14.1",
"tslib": "^2.6.0",
"typescript": "^5.4.0"
}
diff --git a/obsidian-plugin/src/base.ts b/obsidian-plugin/src/base.ts
index b029571..bdd1229 100644
--- a/obsidian-plugin/src/base.ts
+++ b/obsidian-plugin/src/base.ts
@@ -15,6 +15,13 @@ export async function ensureDictionaryBase(app: App, settings: DictionarySetting
and:
- file.inFolder("${folderPath}")
- file.ext != "base"
+properties:
+ file.name:
+ displayName: Word
+ word-class:
+ displayName: Word class
+ date-added:
+ displayName: Date added
views:
- type: table
name: All words
diff --git a/obsidian-plugin/src/books.ts b/obsidian-plugin/src/books.ts
new file mode 100644
index 0000000..e0a2a87
--- /dev/null
+++ b/obsidian-plugin/src/books.ts
@@ -0,0 +1,66 @@
+import { App, TFile, TFolder, normalizePath } from 'obsidian';
+
+const LOWERCASE_WORDS = new Set([
+ 'a', 'an', 'and', 'as', 'at', 'but', 'by', 'for', 'from', 'in',
+ 'into', 'nor', 'of', 'on', 'onto', 'or', 'over', 'so', 'the',
+ 'to', 'up', 'with', 'yet',
+]);
+
+export function titleCase(input: string): string {
+ const trimmed = (input || '').trim();
+ if (!trimmed) return '';
+
+ const words = trimmed.split(/\s+/);
+ return words
+ .map((word, i) => {
+ const lower = word.toLowerCase();
+ const isFirst = i === 0;
+ const isLast = i === words.length - 1;
+ if (!isFirst && !isLast && LOWERCASE_WORDS.has(lower)) return lower;
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
+ })
+ .join(' ');
+}
+
+// Strip characters Obsidian can't use in filenames or wikilinks.
+export function cleanBookTitle(title: string): string {
+ return (title || '').replace(/[\\/:*?"<>|#^[\]]/g, '').trim();
+}
+
+export function listBookNotes(app: App, folder: string): string[] {
+ const folderPath = normalizePath(folder);
+ const f = app.vault.getAbstractFileByPath(folderPath);
+ if (!(f instanceof TFolder)) return [];
+
+ return f.children
+ .filter((child): child is TFile => child instanceof TFile && child.extension === 'md')
+ .map((file) => file.basename)
+ .sort((a, b) => a.localeCompare(b));
+}
+
+export function bookNoteExists(app: App, folder: string, title: string): boolean {
+ const cleaned = cleanBookTitle(title);
+ if (!cleaned) return false;
+ const filePath = normalizePath(`${normalizePath(folder)}/${cleaned}.md`);
+ return app.vault.getAbstractFileByPath(filePath) instanceof TFile;
+}
+
+export async function ensureBookStub(app: App, folder: string, title: string): Promise {
+ const cleaned = cleanBookTitle(title);
+ if (!cleaned) return;
+
+ const folderPath = normalizePath(folder);
+ const filePath = normalizePath(`${folderPath}/${cleaned}.md`);
+
+ if (app.vault.getAbstractFileByPath(filePath)) return;
+
+ const today = new Date().toISOString().split('T')[0];
+ const content = `---
+type: book
+date-added: ${today}
+---
+
+# ${cleaned}
+`;
+ await app.vault.create(filePath, content);
+}
diff --git a/obsidian-plugin/src/kobo.ts b/obsidian-plugin/src/kobo.ts
new file mode 100644
index 0000000..61e40a4
--- /dev/null
+++ b/obsidian-plugin/src/kobo.ts
@@ -0,0 +1,92 @@
+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';
+
+export interface KoboWord {
+ word: string;
+ bookTitle: string | null;
+ dateCreated: string;
+}
+
+let sqlJsPromise: Promise | null = null;
+
+// Lazy-init so plugin load is unaffected for users who never run the import.
+function getSqlJs(): Promise {
+ 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 {
+ try {
+ const stat = await fs.stat(filePath);
+ return stat.isFile();
+ } catch {
+ return false;
+ }
+}
+
+export async function readKoboWords(filePath: string): Promise {
+ let buf: Buffer;
+ try {
+ buf = await fs.readFile(filePath);
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException).code;
+ if (code === 'ENOENT') {
+ throw new Error('File not found. Is your Kobo plugged in and mounted?');
+ }
+ if (code === 'EACCES') {
+ throw new Error('Permission denied reading the database file.');
+ }
+ throw new Error(`Could not read file: ${(err as Error).message}`);
+ }
+
+ // SQLite files start with the literal "SQLite format 3\0".
+ if (buf.length < 16 || buf.slice(0, 15).toString('utf8') !== 'SQLite format 3') {
+ throw new Error('That file is not a SQLite database.');
+ }
+
+ const SQL = await getSqlJs();
+ const db = new SQL.Database(new Uint8Array(buf));
+
+ try {
+ const stmt = db.prepare(`
+ SELECT
+ wl.Text AS word,
+ wl.DictSuffix AS lang,
+ wl.DateCreated AS dateCreated,
+ c.Title AS bookTitle
+ FROM WordList wl
+ LEFT JOIN content c ON wl.VolumeId = c.ContentID
+ WHERE wl.DictSuffix = '-en' OR wl.DictSuffix IS NULL
+ ORDER BY wl.DateCreated DESC
+ `);
+
+ const rows: KoboWord[] = [];
+ while (stmt.step()) {
+ const row = stmt.getAsObject() as { word?: string; bookTitle?: string | null; dateCreated?: string };
+ if (row.word) {
+ rows.push({
+ word: String(row.word).trim(),
+ bookTitle: row.bookTitle ? String(row.bookTitle) : null,
+ dateCreated: row.dateCreated ? String(row.dateCreated) : '',
+ });
+ }
+ }
+ stmt.free();
+ return rows;
+ } catch (err) {
+ const msg = (err as Error).message;
+ if (/no such table/i.test(msg)) {
+ throw new Error('This SQLite file does not look like a Kobo database (no WordList table).');
+ }
+ throw err;
+ } finally {
+ db.close();
+ }
+}
diff --git a/obsidian-plugin/src/koboImportModal.ts b/obsidian-plugin/src/koboImportModal.ts
new file mode 100644
index 0000000..8ff29df
--- /dev/null
+++ b/obsidian-plugin/src/koboImportModal.ts
@@ -0,0 +1,352 @@
+import { AbstractInputSuggest, App, Modal, Notice, Setting } from 'obsidian';
+import { fileExists, readKoboWords, type KoboWord } from './kobo';
+import { lookupWord } from './dictionary';
+import { createWordNote } from './lexicon';
+import { bookNoteExists, cleanBookTitle, ensureBookStub, listBookNotes, titleCase } from './books';
+import type { DictionarySettings } from './settings';
+
+const DEFAULT_KOBO_PATH = '/Volumes/KOBOeReader/.kobo/KoboReader.sqlite';
+const RATE_LIMIT_MS = 150;
+
+type State = 'path' | 'preview' | 'progress' | 'done';
+
+interface ImportSummary {
+ imported: number;
+ skipped: number;
+ notFound: number;
+ errors: number;
+}
+
+class BookSuggest extends AbstractInputSuggest {
+ private books: string[];
+ private onPicked: (value: string) => void;
+
+ constructor(app: App, inputEl: HTMLInputElement, books: string[], onPicked: (value: string) => void) {
+ super(app, inputEl);
+ this.books = books;
+ this.onPicked = onPicked;
+ }
+
+ protected getSuggestions(query: string): string[] {
+ const q = query.trim().toLowerCase();
+ if (!q) return this.books.slice(0, 8);
+ return this.books.filter((b) => b.toLowerCase().includes(q)).slice(0, 8);
+ }
+
+ renderSuggestion(book: string, el: HTMLElement) {
+ el.setText(book);
+ }
+
+ selectSuggestion(book: string) {
+ this.setValue(book);
+ this.onPicked(book);
+ this.close();
+ }
+}
+
+export class KoboImportModal extends Modal {
+ private getSettings: () => DictionarySettings;
+
+ private state: State = 'path';
+
+ // Path state
+ private filePath = DEFAULT_KOBO_PATH;
+ private pathError = '';
+ private reading = false;
+
+ // Preview state — keyed by original Kobo title (or '__unknown__')
+ private bookGroups = new Map();
+ private bookNameOverrides = new Map();
+
+ // Progress state
+ private progressLine = '';
+ private progressEl: HTMLElement | null = null;
+ private summary: ImportSummary = { imported: 0, skipped: 0, notFound: 0, errors: 0 };
+ private cancelRequested = false;
+
+ constructor(app: App, getSettings: () => DictionarySettings) {
+ super(app);
+ this.getSettings = getSettings;
+ }
+
+ onOpen() {
+ this.modalEl.style.maxWidth = '720px';
+ this.render();
+ }
+
+ onClose() {
+ this.cancelRequested = true;
+ this.contentEl.empty();
+ }
+
+ // ── Render dispatch ─────────────────────────────────────────────
+
+ private render() {
+ this.contentEl.empty();
+ if (this.state === 'path') return this.renderPathState();
+ if (this.state === 'preview') return this.renderPreviewState();
+ if (this.state === 'progress') return this.renderProgressState();
+ this.renderDoneState();
+ }
+
+ // ── State 1: pick the SQLite file ──────────────────────────────
+
+ private renderPathState() {
+ this.contentEl.createEl('h3', { text: 'Import words from Kobo' });
+ this.contentEl.createEl('p', {
+ text: "Plug in your Kobo eReader, then point Lexophile at its database file. The default works on macOS when the device is mounted.",
+ cls: 'setting-item-description',
+ });
+
+ new Setting(this.contentEl)
+ .setName('Database path')
+ .addText((text) => {
+ text
+ .setPlaceholder(DEFAULT_KOBO_PATH)
+ .setValue(this.filePath)
+ .onChange((v) => (this.filePath = v));
+ text.inputEl.style.fontFamily = 'monospace';
+ text.inputEl.style.fontSize = '12px';
+ });
+
+ if (this.pathError) {
+ const err = this.contentEl.createEl('p');
+ err.style.cssText = 'color: var(--text-error); font-size: 13px; margin-top: 4px;';
+ err.textContent = this.pathError;
+ }
+
+ new Setting(this.contentEl)
+ .addButton((btn) => btn.setButtonText('Cancel').onClick(() => this.close()))
+ .addButton((btn) =>
+ btn
+ .setButtonText(this.reading ? 'Reading…' : 'Read words')
+ .setCta()
+ .setDisabled(this.reading)
+ .onClick(() => void this.readWords())
+ );
+ }
+
+ private async readWords() {
+ this.pathError = '';
+ this.reading = true;
+ this.render();
+
+ try {
+ const path = this.filePath.trim();
+ if (!path) throw new Error('Please enter a path.');
+ if (!(await fileExists(path))) {
+ throw new Error('File not found at that path.');
+ }
+
+ const words = await readKoboWords(path);
+ if (words.length === 0) {
+ this.pathError = 'No English words found in this database.';
+ this.reading = false;
+ this.render();
+ return;
+ }
+
+ this.bookGroups = new Map();
+ this.bookNameOverrides = new Map();
+ for (const w of words) {
+ const key = w.bookTitle ?? '__unknown__';
+ if (!this.bookGroups.has(key)) {
+ this.bookGroups.set(key, []);
+ const initial = w.bookTitle ? titleCase(w.bookTitle) : '';
+ this.bookNameOverrides.set(key, initial);
+ }
+ this.bookGroups.get(key)!.push(w);
+ }
+
+ this.reading = false;
+ this.state = 'preview';
+ this.render();
+ } catch (err) {
+ this.pathError = (err as Error).message;
+ this.reading = false;
+ this.render();
+ }
+ }
+
+ // ── State 2: preview & per-book name resolution ────────────────
+
+ private renderPreviewState() {
+ const settings = this.getSettings();
+ const totalWords = Array.from(this.bookGroups.values()).reduce((acc, arr) => acc + arr.length, 0);
+ const existingBooks = listBookNotes(this.app, settings.booksFolder);
+
+ this.contentEl.createEl('h3', {
+ text: `${totalWords} word${totalWords === 1 ? '' : 's'} across ${this.bookGroups.size} book${this.bookGroups.size === 1 ? '' : 's'}`,
+ });
+ this.contentEl.createEl('p', {
+ text: 'Pick a name for each book. Existing notes in your books folder will autocomplete. Each word gets a [[wikilink]] back to whatever you choose.',
+ cls: 'setting-item-description',
+ });
+
+ const list = this.contentEl.createDiv();
+ list.style.cssText =
+ 'max-height: 360px; overflow-y: auto; margin: 12px 0; border: 1px solid var(--background-modifier-border); border-radius: 6px;';
+
+ // Sort books by word count, descending
+ const entries = Array.from(this.bookGroups.entries()).sort(
+ (a, b) => b[1].length - a[1].length
+ );
+
+ for (const [key, words] of entries) {
+ const row = list.createDiv();
+ row.style.cssText =
+ 'display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-bottom: 1px solid var(--background-modifier-border);';
+
+ const original = row.createDiv();
+ original.style.cssText =
+ 'flex: 0 0 200px; color: var(--text-muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;';
+ original.textContent = key === '__unknown__' ? '(no title in Kobo)' : key;
+ original.title = original.textContent;
+
+ const inputEl = row.createEl('input');
+ inputEl.type = 'text';
+ inputEl.placeholder = 'Book name (or leave blank for no link)';
+ inputEl.style.cssText = 'flex: 1; padding: 6px 8px; font-size: 13px;';
+ inputEl.value = this.bookNameOverrides.get(key) ?? '';
+ inputEl.addEventListener('input', () => {
+ this.bookNameOverrides.set(key, inputEl.value);
+ });
+
+ new BookSuggest(this.app, inputEl, existingBooks, (picked) => {
+ inputEl.value = picked;
+ this.bookNameOverrides.set(key, picked);
+ });
+
+ const count = row.createDiv();
+ count.style.cssText =
+ 'flex: 0 0 70px; color: var(--text-muted); font-size: 12px; text-align: right;';
+ count.textContent = `${words.length} word${words.length === 1 ? '' : 's'}`;
+ }
+
+ new Setting(this.contentEl)
+ .addButton((btn) =>
+ btn.setButtonText('Back').onClick(() => {
+ this.state = 'path';
+ this.render();
+ })
+ )
+ .addButton((btn) =>
+ btn
+ .setButtonText(`Import ${totalWords} word${totalWords === 1 ? '' : 's'}`)
+ .setCta()
+ .onClick(() => void this.runImport())
+ );
+ }
+
+ // ── State 3: progress ──────────────────────────────────────────
+
+ private renderProgressState() {
+ this.contentEl.createEl('h3', { text: 'Importing…' });
+ this.progressEl = this.contentEl.createEl('p');
+ this.progressEl.style.cssText =
+ 'font-family: monospace; font-size: 13px; padding: 8px 0; color: var(--text-muted);';
+ this.progressEl.textContent = this.progressLine || 'Starting…';
+ }
+
+ private updateProgressLine(line: string) {
+ this.progressLine = line;
+ if (this.progressEl) this.progressEl.textContent = line;
+ }
+
+ private async runImport() {
+ this.state = 'progress';
+ this.summary = { imported: 0, skipped: 0, notFound: 0, errors: 0 };
+ this.cancelRequested = false;
+ this.render();
+
+ const settings = this.getSettings();
+ const existingLower = new Set(
+ listBookNotes(this.app, settings.booksFolder).map((s) => s.toLowerCase())
+ );
+
+ // Flatten words with their resolved book name
+ const queue: { kobo: KoboWord; bookName: string }[] = [];
+ for (const [key, words] of this.bookGroups.entries()) {
+ const bookName = cleanBookTitle((this.bookNameOverrides.get(key) ?? '').trim());
+ for (const w of words) queue.push({ kobo: w, bookName });
+ }
+
+ for (let i = 0; i < queue.length; i++) {
+ if (this.cancelRequested) break;
+ const { kobo, bookName } = queue[i];
+
+ this.updateProgressLine(`Looking up "${kobo.word}" (${i + 1} of ${queue.length})…`);
+
+ try {
+ const entry = await lookupWord(kobo.word);
+
+ let source: string;
+ if (!bookName) {
+ source = 'kobo';
+ } else if (existingLower.has(bookName.toLowerCase())) {
+ source = `[[${bookName}]]`;
+ } else {
+ switch (settings.unmatchedBookHandling) {
+ case 'create':
+ await ensureBookStub(this.app, settings.booksFolder, bookName);
+ existingLower.add(bookName.toLowerCase());
+ source = `[[${bookName}]]`;
+ break;
+ case 'linkOnly':
+ source = `[[${bookName}]]`;
+ break;
+ case 'plainText':
+ source = `Kobo: ${bookName}`;
+ break;
+ }
+ }
+
+ entry.source = source;
+ const result = await createWordNote(this.app, settings, entry);
+ if (result.action === 'skipped') this.summary.skipped++;
+ else this.summary.imported++;
+ } catch (err) {
+ const msg = (err as Error).message;
+ if (/no definition found/i.test(msg) || /empty response/i.test(msg)) {
+ this.summary.notFound++;
+ } else {
+ this.summary.errors++;
+ }
+ console.warn(`[Lexophile] "${kobo.word}":`, msg);
+ }
+
+ // Throttle to be kind to the API
+ if (i < queue.length - 1) {
+ await new Promise((r) => setTimeout(r, RATE_LIMIT_MS));
+ }
+ }
+
+ this.state = 'done';
+ this.render();
+ }
+
+ // ── State 4: done ──────────────────────────────────────────────
+
+ private renderDoneState() {
+ this.contentEl.createEl('h3', { text: this.cancelRequested ? 'Import cancelled' : 'Done' });
+
+ const { imported, skipped, notFound, errors } = this.summary;
+ const lines: string[] = [];
+ lines.push(`Imported ${imported} word${imported === 1 ? '' : 's'}`);
+ if (skipped) lines.push(`${skipped} skipped (already in lexicon)`);
+ if (notFound) lines.push(`${notFound} not found in dictionary`);
+ if (errors) lines.push(`${errors} error${errors === 1 ? '' : 's'} (see console)`);
+
+ const summaryEl = this.contentEl.createEl('p');
+ summaryEl.style.cssText = 'font-size: 14px; line-height: 1.6;';
+ summaryEl.innerHTML = lines.map((l) => `• ${l}`).join('
');
+
+ new Setting(this.contentEl).addButton((btn) =>
+ btn.setButtonText('Close').setCta().onClick(() => this.close())
+ );
+
+ if (imported > 0) {
+ new Notice(`Lexophile: imported ${imported} word${imported === 1 ? '' : 's'} from Kobo.`);
+ }
+ }
+}
diff --git a/obsidian-plugin/src/main.ts b/obsidian-plugin/src/main.ts
index 02974dc..fd77f8b 100644
--- a/obsidian-plugin/src/main.ts
+++ b/obsidian-plugin/src/main.ts
@@ -1,6 +1,7 @@
-import { App, Notice, Plugin, PluginSettingTab, Setting } from 'obsidian';
+import { App, Notice, Plugin, PluginSettingTab, Setting, TFolder, normalizePath } from 'obsidian';
import { DictionaryServer } from './server';
import { AddWordModal } from './wordModal';
+import { KoboImportModal } from './koboImportModal';
import { DEFAULT_SETTINGS, DEFAULT_TEMPLATE } from './settings';
import type { DictionarySettings } from './settings';
@@ -29,6 +30,18 @@ export default class DictionaryPlugin extends Plugin {
},
});
+ this.addCommand({
+ id: 'import-kobo',
+ name: 'Import words from Kobo',
+ callback: () => {
+ if (!this.settings.enableKoboImport) {
+ new Notice('Lexophile: enable Kobo import in Settings → Lexophile first.');
+ return;
+ }
+ new KoboImportModal(this.app, () => this.settings).open();
+ },
+ });
+
this.addCommand({
id: 'restart-server',
name: 'Restart local server',
@@ -170,6 +183,104 @@ class DictionarySettingTab extends PluginSettingTab {
);
}
+ // ── Kobo eReader import ──────────────────────────────────────
+
+ containerEl.createEl('h3', { text: 'Kobo eReader import' });
+
+ const koboIntro = containerEl.createEl('p', { cls: 'setting-item-description' });
+ koboIntro.appendText('Import words you saved on your Kobo. Plug your Kobo into your computer, then run ');
+ koboIntro.createEl('strong', { text: 'Lexophile: Import words from Kobo' });
+ koboIntro.appendText(' from the command palette. Each word becomes a dictionary note; its source links back to the book it came from in your library.');
+
+ new Setting(containerEl)
+ .setName('Enable Kobo import')
+ .setDesc('Reveals the Kobo settings and unlocks the import command.')
+ .addToggle((toggle) =>
+ toggle.setValue(this.plugin.settings.enableKoboImport).onChange(async (value) => {
+ this.plugin.settings.enableKoboImport = value;
+ await this.plugin.saveSettings();
+ this.display();
+ })
+ );
+
+ if (this.plugin.settings.enableKoboImport) {
+ const booksFolderPath = normalizePath(this.plugin.settings.booksFolder || 'Books');
+ const folderExists = this.app.vault.getAbstractFileByPath(booksFolderPath) instanceof TFolder;
+
+ // Build the dropdown options: every existing folder in the vault, plus
+ // "Books" (the default) and the current setting value, even if those
+ // don't exist yet (so they remain selectable).
+ const existingFolders: string[] = [];
+ const walk = (folder: TFolder) => {
+ if (folder.path) existingFolders.push(folder.path);
+ for (const child of folder.children) {
+ if (child instanceof TFolder) walk(child);
+ }
+ };
+ walk(this.app.vault.getRoot());
+ existingFolders.sort((a, b) => a.localeCompare(b));
+
+ const dropdownOptions = new Set(existingFolders);
+ dropdownOptions.add('Books');
+ if (this.plugin.settings.booksFolder) {
+ dropdownOptions.add(this.plugin.settings.booksFolder);
+ }
+ const sortedOptions = Array.from(dropdownOptions).sort((a, b) => a.localeCompare(b));
+
+ new Setting(containerEl)
+ .setName('Books folder')
+ .setDesc(
+ folderExists
+ ? `✓ Folder exists at "${booksFolderPath}". New book notes will be created here.`
+ : `"${booksFolderPath}" doesn't exist yet. Create it below or pick another folder.`
+ )
+ .addDropdown((drop) => {
+ for (const folder of sortedOptions) {
+ const exists = this.app.vault.getAbstractFileByPath(normalizePath(folder)) instanceof TFolder;
+ drop.addOption(folder, exists ? folder : `${folder} (will be created)`);
+ }
+ drop
+ .setValue(this.plugin.settings.booksFolder || 'Books')
+ .onChange(async (value) => {
+ this.plugin.settings.booksFolder = value;
+ await this.plugin.saveSettings();
+ this.display();
+ });
+ });
+
+ if (!folderExists) {
+ new Setting(containerEl)
+ .setName('Create books folder')
+ .setDesc(`Creates "${booksFolderPath}" so wikilinks resolve.`)
+ .addButton((btn) =>
+ btn.setButtonText('Create folder').setCta().onClick(async () => {
+ try {
+ await this.app.vault.createFolder(booksFolderPath);
+ new Notice(`Lexophile: created "${booksFolderPath}".`);
+ this.display();
+ } catch (err) {
+ new Notice(`Lexophile: ${(err as Error).message}`);
+ }
+ })
+ );
+ }
+
+ new Setting(containerEl)
+ .setName("When a book isn't in your library")
+ .setDesc('What to do during import if the chosen book name has no matching note in the books folder.')
+ .addDropdown((drop) =>
+ drop
+ .addOption('create', 'Auto-create a stub book note')
+ .addOption('linkOnly', 'Link without creating (red wikilinks)')
+ .addOption('plainText', 'Use plain text source instead')
+ .setValue(this.plugin.settings.unmatchedBookHandling)
+ .onChange(async (value) => {
+ this.plugin.settings.unmatchedBookHandling = value as DictionarySettings['unmatchedBookHandling'];
+ await this.plugin.saveSettings();
+ })
+ );
+ }
+
// ── Server ───────────────────────────────────────────────────
containerEl.createEl('h3', { text: 'Local server' });
@@ -245,12 +356,24 @@ class DictionarySettingTab extends PluginSettingTab {
// ── Feedback ─────────────────────────────────────────────────
- containerEl.createEl('h3', { text: 'Feedback & support' });
- const feedback = containerEl.createEl('p', { cls: 'setting-item-description' });
- feedback.appendText('Feature requests or support: ');
- feedback.createEl('a', {
- text: 'lexophile@fastmail.com',
- href: 'mailto:lexophile@fastmail.com',
+ const support = containerEl.createEl('p', { cls: 'setting-item-description' });
+ support.appendText('Need support? ');
+ support.createEl('a', {
+ text: 'File an issue on GitHub',
+ href: 'https://github.com/bryanmanio/obsidian-lexophile/issues/new',
});
+ support.appendText('.');
+
+ const bmcWrap = containerEl.createEl('p');
+ bmcWrap.style.marginTop = '14px';
+ const bmcLink = bmcWrap.createEl('a', {
+ href: 'https://buymeacoffee.com/bryanmanio',
+ });
+ bmcLink.setAttr('target', '_blank');
+ bmcLink.setAttr('rel', 'noopener');
+ const bmcImg = bmcLink.createEl('img');
+ bmcImg.src = 'https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png';
+ bmcImg.alt = 'Buy Me A Coffee';
+ bmcImg.style.cssText = 'height: 40px; width: auto; border-radius: 8px;';
}
}
diff --git a/obsidian-plugin/src/settings.ts b/obsidian-plugin/src/settings.ts
index fde7f57..db8283c 100644
--- a/obsidian-plugin/src/settings.ts
+++ b/obsidian-plugin/src/settings.ts
@@ -1,5 +1,6 @@
export type NamingConvention = 'asis' | 'lowercase' | 'titlecase';
export type DuplicateHandling = 'skip' | 'append' | 'overwrite';
+export type UnmatchedBookHandling = 'create' | 'linkOnly' | 'plainText';
export interface DictionarySettings {
folder: string;
@@ -10,6 +11,9 @@ export interface DictionarySettings {
duplicateHandling: DuplicateHandling;
autoCreateBase: boolean;
baseName: string;
+ enableKoboImport: boolean;
+ booksFolder: string;
+ unmatchedBookHandling: UnmatchedBookHandling;
}
export const DEFAULT_TEMPLATE = `---
@@ -29,11 +33,14 @@ definition: "{{definition}}"
export const DEFAULT_SETTINGS: DictionarySettings = {
folder: 'Dictionary',
- namingConvention: 'asis',
+ namingConvention: 'titlecase',
template: DEFAULT_TEMPLATE,
port: 27124,
apiToken: '',
duplicateHandling: 'skip',
autoCreateBase: true,
baseName: '_Dictionary List',
+ enableKoboImport: false,
+ booksFolder: 'Books',
+ unmatchedBookHandling: 'create',
};
diff --git a/obsidian-plugin/src/types.d.ts b/obsidian-plugin/src/types.d.ts
new file mode 100644
index 0000000..730ee5d
--- /dev/null
+++ b/obsidian-plugin/src/types.d.ts
@@ -0,0 +1,4 @@
+declare module '*.wasm' {
+ const binary: Uint8Array;
+ export default binary;
+}