mirror of
https://github.com/bryanmanio/obsidian-lexophile.git
synced 2026-07-22 06:56:30 +00:00
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:
parent
2a16aa0283
commit
fb0e640a01
19 changed files with 906 additions and 17 deletions
155
README.md
Normal file
155
README.md
Normal 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)
|
||||
|
|
@ -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'],
|
||||
});
|
||||
|
||||
|
|
|
|||
BIN
chrome-extension/icons/icon128.png
Normal file
BIN
chrome-extension/icons/icon128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 947 B |
BIN
chrome-extension/icons/icon16.png
Normal file
BIN
chrome-extension/icons/icon16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
BIN
chrome-extension/icons/icon32.png
Normal file
BIN
chrome-extension/icons/icon32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
BIN
chrome-extension/icons/icon48.png
Normal file
BIN
chrome-extension/icons/icon48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1
obsidian-plugin/.gitignore
vendored
1
obsidian-plugin/.gitignore
vendored
|
|
@ -2,3 +2,4 @@ node_modules/
|
|||
main.js
|
||||
*.js.map
|
||||
data.json
|
||||
vault-path.txt
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
31
obsidian-plugin/package-lock.json
generated
31
obsidian-plugin/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
66
obsidian-plugin/src/books.ts
Normal file
66
obsidian-plugin/src/books.ts
Normal 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);
|
||||
}
|
||||
92
obsidian-plugin/src/kobo.ts
Normal file
92
obsidian-plugin/src/kobo.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
352
obsidian-plugin/src/koboImportModal.ts
Normal file
352
obsidian-plugin/src/koboImportModal.ts
Normal 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.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
4
obsidian-plugin/src/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare module '*.wasm' {
|
||||
const binary: Uint8Array;
|
||||
export default binary;
|
||||
}
|
||||
Loading…
Reference in a new issue