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 + + # 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; +}