Kobo import, dictionary base, README, plus extension polish

Major plugin features:
- Kobo eReader import: SQL.js bundled (~1MB), reads My Words from
  KoboReader.sqlite, three-state modal (path picker → per-book name
  resolution with autocomplete → progress), per-word [[wikilink]]
  source pointing at a books library folder.
- Auto-created Dictionary base view with custom column display names
  (Word / Word class / Definition / Source / Date added).
- Books folder dropdown of existing vault folders, defaulting to Books
  with auto-create when missing.
- "Word class" replaces "part of speech" everywhere user-facing.
- Title Case is the new default note naming convention.
- Recursive folder creation handles nested paths.

Plugin polish:
- Manifest name now "Lexophile - Personal Dictionary" with full author.
- BMC button + GitHub issues link replace the old email contact.
- Manual entry instructions callout at the top of settings.

Chrome extension:
- Right-click menu text simplified to "Add word to Obsidian".
- Brand icon (bookmark-plus) shipped at 16/32/48/128.
- Welcome onboarding auto-generates an API token.

Build pipeline:
- esbuild auto-deploys main.js + manifest.json into the vault via a
  gitignored vault-path.txt (or LEXOPHILE_VAULT_PLUGIN_DIR env var).

Repo:
- README at root with install, setup, usage, and architecture.
- chrome-extension/icons/ directory.

Co-Authored-By: Claude Sonnet 4.7 <noreply@anthropic.com>
This commit is contained in:
Bryan Maniotakis 2026-05-09 02:24:21 -06:00
parent 2a16aa0283
commit fb0e640a01
19 changed files with 906 additions and 17 deletions

155
README.md Normal file
View file

@ -0,0 +1,155 @@
<div align="center">
<img src="chrome-extension/icons/icon128.png" alt="Lexophile" width="96" height="96" />
# 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)
</div>
---
## 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 *<word>* 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 <token>
┌──────────────────────────────┐
│ 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 "<word>" 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)

View file

@ -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'],
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 947 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

@ -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"
}
}

View file

@ -2,3 +2,4 @@ node_modules/
main.js
*.js.map
data.json
vault-path.txt

View file

@ -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) {

View file

@ -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
}

View file

@ -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",

View file

@ -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"
}

View file

@ -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

View file

@ -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<void> {
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);
}

View file

@ -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<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);
return stat.isFile();
} catch {
return false;
}
}
export async function readKoboWords(filePath: string): Promise<KoboWord[]> {
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();
}
}

View file

@ -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<string> {
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<string, KoboWord[]>();
private bookNameOverrides = new Map<string, string>();
// 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('<br>');
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.`);
}
}
}

View file

@ -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<string>(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;';
}
}

View file

@ -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',
};

4
obsidian-plugin/src/types.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module '*.wasm' {
const binary: Uint8Array;
export default binary;
}