mirror of
https://github.com/bryanmanio/obsidian-lexophile.git
synced 2026-07-22 06:56:30 +00:00
The plugin used to call dictionaryapi.dev for every lookup, which was slow, rate-limited, and broke offline. It now ships a local SQLite file (~23MB, ~167K English entries from Wiktionary via the MattDodsonEnglish/english-dictionary project) and queries it locally via sql.js. Plumbing: - DictionaryStore loads the SQLite from <vault>/.obsidian/plugins/lexophile/dictionary.sqlite, downloads on demand from a configurable URL (default: a release asset on this repo), and exposes a synchronous lookup() that returns a WordEntry or null. - lookupWord is now an async wrapper around store.lookup that throws WordNotFoundError on null. DictionaryNotReadyError surfaces the pre-download state so modals can fail fast with a helpful message. - sql.js initialization moved to its own module (src/sqlite.ts) and reused by both the Kobo import and the new dictionary store. - AddWordModal / MassImportModal / KoboImportModal each take the store via constructor and guard against the not-ready state before starting work. - Settings tab gains a "Local dictionary" section with a status pill (Ready / Downloaded-not-loaded / Not downloaded), an entry count and size summary, a Download/Re-download button, and a configurable URL. - Removed the inter-request rate limit (was 350ms for API throttling) — local lookups are sub-millisecond, so a 0ms yield is enough to let the progress line repaint. Bundled dictionary data is hosted as a release asset on bryanmanio/obsidian-lexophile (tag: dictionary-v1). The plugin does not call dictionaryapi.dev at any point during normal use. The Chrome extension still uses the API for its own lookups — out of scope here. Build artifacts: - scripts/build-dictionary.py converts the upstream parquet (167,585 entries) to the compact SQLite schema we ship; documented for reproducibility. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
572 lines
19 KiB
TypeScript
572 lines
19 KiB
TypeScript
import { App, Modal, Notice, Setting } from 'obsidian';
|
|
import { fileExists, readKoboWords, type KoboWord } from './kobo';
|
|
import { lookupWord, WordNotFoundError } from './dictionary';
|
|
import { DictionaryNotReadyError, type DictionaryStore } from './dictionaryStore';
|
|
import { createStubEntry, createWordNote, wordNoteExists, type WordEntry } from './lexicon';
|
|
import { bookNoteExists, cleanBookTitle, ensureBookStub, titleCase } from './books';
|
|
import type { DictionarySettings } from './settings';
|
|
|
|
const DEFAULT_KOBO_PATH = '/Volumes/KOBOeReader/.kobo/KoboReader.sqlite';
|
|
// Local SQLite lookups are sub-millisecond, but we still yield to the event
|
|
// loop between iterations so the progress line can repaint.
|
|
const YIELD_MS = 0;
|
|
|
|
type State = 'path' | 'wordlist' | 'progress' | 'done';
|
|
|
|
interface WordItem {
|
|
kobo: KoboWord;
|
|
bookName: string; // Title Case version of bookTitle, or '' for unknown
|
|
checked: boolean;
|
|
duplicate: boolean;
|
|
}
|
|
|
|
// Captures enough context to retroactively turn a not-found word into a stub
|
|
// note on the done screen — we already resolved its book source during import,
|
|
// so we don't need to recompute it.
|
|
interface NotFoundItem {
|
|
word: string;
|
|
source: string;
|
|
}
|
|
|
|
interface ImportSummary {
|
|
imported: number;
|
|
skipped: number;
|
|
stubbed: number;
|
|
notFound: NotFoundItem[];
|
|
errors: { word: string; reason: string }[];
|
|
}
|
|
|
|
export class KoboImportModal extends Modal {
|
|
private getSettings: () => DictionarySettings;
|
|
|
|
private state: State = 'path';
|
|
|
|
// Path state
|
|
private filePath = DEFAULT_KOBO_PATH;
|
|
private pathError = '';
|
|
private reading = false;
|
|
|
|
// Word list state
|
|
private items: WordItem[] = [];
|
|
private searchQuery = '';
|
|
private listEl: HTMLElement | null = null;
|
|
private countEl: HTMLElement | null = null;
|
|
private importBtn: HTMLButtonElement | null = null;
|
|
|
|
// Per-import override of settings.stubUnfoundWords. Initialized from
|
|
// settings in renderWordListState so each new import picks up the latest.
|
|
private stubUnfound = false;
|
|
|
|
// Progress state
|
|
private progressLine = '';
|
|
private progressEl: HTMLElement | null = null;
|
|
private summary: ImportSummary = { imported: 0, skipped: 0, stubbed: 0, notFound: [], errors: [] };
|
|
private cancelRequested = false;
|
|
|
|
private store: DictionaryStore;
|
|
|
|
constructor(app: App, getSettings: () => DictionarySettings, store: DictionaryStore) {
|
|
super(app);
|
|
this.getSettings = getSettings;
|
|
this.store = store;
|
|
}
|
|
|
|
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 === 'wordlist') return this.renderWordListState();
|
|
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 {
|
|
if (!this.store.isReady()) {
|
|
throw new Error('Local dictionary not loaded. Download it from Settings → Lexophile first.');
|
|
}
|
|
const path = this.filePath.trim();
|
|
if (!path) throw new Error('Please enter a path.');
|
|
if (!(await fileExists(path))) {
|
|
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;
|
|
}
|
|
|
|
const settings = this.getSettings();
|
|
this.items = words.map((kobo) => {
|
|
const dup = wordNoteExists(this.app, settings, kobo.word);
|
|
return {
|
|
kobo,
|
|
bookName: kobo.bookTitle ? titleCase(kobo.bookTitle) : '',
|
|
checked: !dup,
|
|
duplicate: dup,
|
|
};
|
|
});
|
|
|
|
this.searchQuery = '';
|
|
this.reading = false;
|
|
this.stubUnfound = settings.stubUnfoundWords;
|
|
this.state = 'wordlist';
|
|
this.render();
|
|
} catch (err) {
|
|
this.pathError = (err as Error).message;
|
|
this.reading = false;
|
|
this.render();
|
|
}
|
|
}
|
|
|
|
// ── State 2: word list with checkboxes ────────────────────────
|
|
|
|
private renderWordListState() {
|
|
const totalWords = this.items.length;
|
|
const dupCount = this.items.filter((i) => i.duplicate).length;
|
|
|
|
this.contentEl.createEl('h3', {
|
|
text: `${totalWords} word${totalWords === 1 ? '' : 's'} found on your Kobo`,
|
|
});
|
|
|
|
const desc = this.contentEl.createEl('p', { cls: 'setting-item-description' });
|
|
desc.appendText('Pick which words to import. ');
|
|
if (dupCount > 0) {
|
|
desc.appendText(`${dupCount} ${dupCount === 1 ? 'is' : 'are'} already in your lexicon and pre-unchecked. `);
|
|
}
|
|
desc.appendText('Sources will link to the book each word came from.');
|
|
|
|
const stubOpt = this.contentEl.createDiv();
|
|
stubOpt.style.cssText =
|
|
'display: flex; align-items: center; gap: 8px; margin: 10px 0 0; padding: 8px 12px; background: var(--background-secondary); border-radius: 6px; font-size: 13px;';
|
|
const stubCheckbox = stubOpt.createEl('input');
|
|
stubCheckbox.type = 'checkbox';
|
|
stubCheckbox.id = 'lex-kobo-stub';
|
|
stubCheckbox.checked = this.stubUnfound;
|
|
stubCheckbox.addEventListener('change', () => {
|
|
this.stubUnfound = stubCheckbox.checked;
|
|
});
|
|
const stubLabel = stubOpt.createEl('label');
|
|
stubLabel.htmlFor = 'lex-kobo-stub';
|
|
stubLabel.style.cssText = 'cursor: pointer; flex: 1;';
|
|
stubLabel.appendText('Create stub notes for words not found in the dictionary');
|
|
const stubHint = stubOpt.createEl('span');
|
|
stubHint.style.cssText = 'color: var(--text-muted); font-size: 12px;';
|
|
stubHint.textContent = '(names, slang, technical terms)';
|
|
|
|
// Toolbar: search + select-all + count
|
|
const toolbar = this.contentEl.createDiv();
|
|
toolbar.style.cssText = 'display: flex; align-items: center; gap: 10px; margin: 12px 0 8px;';
|
|
|
|
const searchEl = toolbar.createEl('input');
|
|
searchEl.type = 'text';
|
|
searchEl.placeholder = 'Search words…';
|
|
searchEl.style.cssText = 'flex: 1; padding: 6px 10px; font-size: 13px;';
|
|
searchEl.value = this.searchQuery;
|
|
searchEl.addEventListener('input', () => {
|
|
this.searchQuery = searchEl.value;
|
|
this.refreshList();
|
|
});
|
|
|
|
const allBtn = toolbar.createEl('button');
|
|
allBtn.textContent = 'Select all';
|
|
allBtn.addEventListener('click', () => {
|
|
const visible = this.visibleItems();
|
|
const allOn = visible.every((i) => i.checked);
|
|
for (const item of visible) item.checked = !allOn;
|
|
this.refreshList();
|
|
});
|
|
|
|
const noneBtn = toolbar.createEl('button');
|
|
noneBtn.textContent = 'Clear';
|
|
noneBtn.addEventListener('click', () => {
|
|
for (const item of this.visibleItems()) item.checked = false;
|
|
this.refreshList();
|
|
});
|
|
|
|
// List
|
|
this.listEl = this.contentEl.createDiv();
|
|
this.listEl.style.cssText =
|
|
'max-height: 380px; overflow-y: auto; margin-bottom: 12px; border: 1px solid var(--background-modifier-border); border-radius: 6px;';
|
|
|
|
this.refreshList();
|
|
|
|
// Footer
|
|
const footer = new Setting(this.contentEl);
|
|
footer.addButton((btn) =>
|
|
btn.setButtonText('Back').onClick(() => {
|
|
this.state = 'path';
|
|
this.render();
|
|
})
|
|
);
|
|
footer.addButton((btn) => {
|
|
this.importBtn = btn.buttonEl;
|
|
btn
|
|
.setButtonText('')
|
|
.setCta()
|
|
.onClick(() => void this.runImport());
|
|
});
|
|
|
|
this.updateImportButtonLabel();
|
|
}
|
|
|
|
private visibleItems(): WordItem[] {
|
|
const q = this.searchQuery.trim().toLowerCase();
|
|
if (!q) return this.items;
|
|
return this.items.filter(
|
|
(i) =>
|
|
i.kobo.word.toLowerCase().includes(q) ||
|
|
i.bookName.toLowerCase().includes(q) ||
|
|
(i.kobo.bookTitle ?? '').toLowerCase().includes(q)
|
|
);
|
|
}
|
|
|
|
private refreshList() {
|
|
if (!this.listEl) return;
|
|
this.listEl.empty();
|
|
|
|
const visible = this.visibleItems();
|
|
|
|
if (visible.length === 0) {
|
|
const empty = this.listEl.createDiv();
|
|
empty.style.cssText = 'padding: 24px; text-align: center; color: var(--text-muted); font-size: 13px;';
|
|
empty.textContent = 'No words match.';
|
|
this.updateImportButtonLabel();
|
|
return;
|
|
}
|
|
|
|
for (const item of visible) {
|
|
const row = this.listEl.createDiv();
|
|
// CSS grid keeps every column aligned regardless of which rows
|
|
// have the "already saved" tag.
|
|
row.style.cssText =
|
|
'display: grid; grid-template-columns: auto 1fr 220px 110px; align-items: center; gap: 12px; padding: 8px 12px; border-bottom: 1px solid var(--background-modifier-border); cursor: pointer;';
|
|
row.addEventListener('click', (e) => {
|
|
if ((e.target as HTMLElement).tagName === 'INPUT') return;
|
|
item.checked = !item.checked;
|
|
this.refreshList();
|
|
});
|
|
|
|
const checkbox = row.createEl('input');
|
|
checkbox.type = 'checkbox';
|
|
checkbox.checked = item.checked;
|
|
checkbox.addEventListener('change', () => {
|
|
item.checked = checkbox.checked;
|
|
this.updateImportButtonLabel();
|
|
});
|
|
|
|
const word = row.createDiv();
|
|
word.style.cssText = 'font-size: 14px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;';
|
|
word.textContent = item.kobo.word;
|
|
if (item.duplicate) {
|
|
word.style.color = 'var(--text-muted)';
|
|
word.style.textDecoration = 'line-through';
|
|
}
|
|
|
|
const book = row.createDiv();
|
|
book.style.cssText =
|
|
'color: var(--text-muted); font-size: 12px; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;';
|
|
book.textContent = item.bookName || '(no book)';
|
|
book.title = item.bookName || '(no book)';
|
|
|
|
// Always-present tag cell so the column grid stays aligned across rows.
|
|
const tagCell = row.createDiv();
|
|
tagCell.style.cssText = 'text-align: right;';
|
|
if (item.duplicate) {
|
|
const pill = tagCell.createSpan();
|
|
pill.style.cssText =
|
|
'font-size: 11px; padding: 2px 8px; background: var(--background-modifier-border); border-radius: 10px; color: var(--text-muted); white-space: nowrap;';
|
|
pill.textContent = 'already saved';
|
|
}
|
|
}
|
|
|
|
this.updateImportButtonLabel();
|
|
}
|
|
|
|
private updateImportButtonLabel() {
|
|
if (!this.importBtn) return;
|
|
const selected = this.items.filter((i) => i.checked).length;
|
|
this.importBtn.textContent = selected > 0
|
|
? `Import ${selected} word${selected === 1 ? '' : 's'}`
|
|
: 'Import';
|
|
this.importBtn.disabled = selected === 0;
|
|
}
|
|
|
|
// ── 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() {
|
|
const queue = this.items.filter((i) => i.checked);
|
|
if (queue.length === 0) return;
|
|
|
|
this.state = 'progress';
|
|
this.summary = { imported: 0, skipped: 0, stubbed: 0, notFound: [], errors: [] };
|
|
this.cancelRequested = false;
|
|
this.render();
|
|
|
|
const settings = this.getSettings();
|
|
const existingBooks = new Set<string>();
|
|
// Pre-warm with names already in the books folder so we don't re-create stubs.
|
|
for (const item of queue) {
|
|
if (item.bookName && bookNoteExists(this.app, settings.booksFolder, item.bookName)) {
|
|
existingBooks.add(item.bookName.toLowerCase());
|
|
}
|
|
}
|
|
|
|
for (let i = 0; i < queue.length; i++) {
|
|
if (this.cancelRequested) break;
|
|
const item = queue[i];
|
|
const word = item.kobo.word;
|
|
|
|
this.updateProgressLine(`Looking up "${word}" (${i + 1} of ${queue.length})…`);
|
|
|
|
const source = await this.resolveSource(item, settings, existingBooks);
|
|
|
|
let entry: WordEntry;
|
|
let stubbed = false;
|
|
try {
|
|
entry = await lookupWord(this.store, word);
|
|
} catch (err) {
|
|
if (err instanceof WordNotFoundError) {
|
|
if (this.stubUnfound) {
|
|
entry = createStubEntry(word);
|
|
stubbed = true;
|
|
} else {
|
|
this.summary.notFound.push({ word, source });
|
|
console.warn(`[Lexophile] "${word}": not found`);
|
|
if (i < queue.length - 1) await new Promise((r) => setTimeout(r, YIELD_MS));
|
|
continue;
|
|
}
|
|
} else {
|
|
this.summary.errors.push({ word, reason: (err as Error).message });
|
|
console.warn(`[Lexophile] "${word}":`, (err as Error).message);
|
|
if (i < queue.length - 1) await new Promise((r) => setTimeout(r, YIELD_MS));
|
|
continue;
|
|
}
|
|
}
|
|
|
|
entry.source = source;
|
|
try {
|
|
const result = await createWordNote(this.app, settings, entry);
|
|
if (result.action === 'skipped') this.summary.skipped++;
|
|
else if (stubbed) this.summary.stubbed++;
|
|
else this.summary.imported++;
|
|
} catch (err) {
|
|
this.summary.errors.push({ word, reason: (err as Error).message });
|
|
}
|
|
|
|
if (i < queue.length - 1) {
|
|
await new Promise((r) => setTimeout(r, YIELD_MS));
|
|
}
|
|
}
|
|
|
|
this.state = 'done';
|
|
this.render();
|
|
}
|
|
|
|
// Resolves the source frontmatter value for an item, creating a book stub
|
|
// if needed per settings.unmatchedBookHandling. Mutates `existingBooks` to
|
|
// remember any stubs it creates.
|
|
private async resolveSource(
|
|
item: WordItem,
|
|
settings: DictionarySettings,
|
|
existingBooks: Set<string>
|
|
): Promise<string> {
|
|
const bookName = cleanBookTitle(item.bookName);
|
|
if (!bookName) return 'kobo';
|
|
if (existingBooks.has(bookName.toLowerCase())) return `[[${bookName}]]`;
|
|
|
|
switch (settings.unmatchedBookHandling) {
|
|
case 'create':
|
|
await ensureBookStub(this.app, settings.booksFolder, bookName);
|
|
existingBooks.add(bookName.toLowerCase());
|
|
return `[[${bookName}]]`;
|
|
case 'linkOnly':
|
|
return `[[${bookName}]]`;
|
|
case 'plainText':
|
|
return `Kobo: ${bookName}`;
|
|
}
|
|
}
|
|
|
|
// ── State 4: done ──────────────────────────────────────────────
|
|
|
|
private renderDoneState() {
|
|
this.contentEl.createEl('h3', { text: this.cancelRequested ? 'Import cancelled' : 'Done' });
|
|
|
|
const { imported, skipped, stubbed, notFound, errors } = this.summary;
|
|
|
|
const totals = this.contentEl.createEl('p');
|
|
totals.style.cssText = 'font-size: 14px; line-height: 1.6;';
|
|
const totalLines: string[] = [];
|
|
totalLines.push(`✓ Imported ${imported} word${imported === 1 ? '' : 's'}`);
|
|
if (stubbed) totalLines.push(`✎ Created ${stubbed} stub${stubbed === 1 ? '' : 's'} for unknown words`);
|
|
if (skipped) totalLines.push(`${skipped} already in your lexicon (skipped)`);
|
|
totals.innerHTML = totalLines.map((l) => `• ${l}`).join('<br>');
|
|
|
|
if (notFound.length > 0) {
|
|
const words = notFound.map((n) => n.word);
|
|
this.renderWordListSection(
|
|
`${notFound.length} not found in the dictionary`,
|
|
words,
|
|
"These weren't in api.dictionaryapi.dev. They might be names, slang, or compounds."
|
|
);
|
|
|
|
const stubBtnWrap = this.contentEl.createDiv();
|
|
stubBtnWrap.style.cssText = 'margin-top: 8px;';
|
|
const stubBtn = stubBtnWrap.createEl('button');
|
|
stubBtn.textContent = `Create stub notes for these ${notFound.length} word${notFound.length === 1 ? '' : 's'}`;
|
|
stubBtn.addEventListener('click', () => void this.stubNotFound(stubBtn));
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
const errorList = errors.map((e) => `${e.word} — ${e.reason}`);
|
|
this.renderWordListSection(
|
|
`${errors.length} error${errors.length === 1 ? '' : 's'}`,
|
|
errorList,
|
|
'Likely network issues. Try the import again later.'
|
|
);
|
|
}
|
|
|
|
new Setting(this.contentEl).addButton((btn) =>
|
|
btn.setButtonText('Close').setCta().onClick(() => this.close())
|
|
);
|
|
|
|
if (imported > 0 || stubbed > 0) {
|
|
const parts: string[] = [];
|
|
if (imported > 0) parts.push(`imported ${imported}`);
|
|
if (stubbed > 0) parts.push(`stubbed ${stubbed}`);
|
|
new Notice(`Lexophile: ${parts.join(', ')} word${imported + stubbed === 1 ? '' : 's'} from Kobo.`);
|
|
}
|
|
}
|
|
|
|
// Retroactively turn each not-found word into a stub note. Sources were
|
|
// captured during the original import, so we can reuse them verbatim.
|
|
private async stubNotFound(triggerBtn: HTMLButtonElement) {
|
|
const pending = this.summary.notFound.slice();
|
|
if (pending.length === 0) return;
|
|
|
|
triggerBtn.disabled = true;
|
|
triggerBtn.textContent = 'Creating stubs…';
|
|
|
|
const settings = this.getSettings();
|
|
let created = 0;
|
|
const stillFailed: NotFoundItem[] = [];
|
|
|
|
for (const item of pending) {
|
|
try {
|
|
const entry = createStubEntry(item.word, item.source);
|
|
const result = await createWordNote(this.app, settings, entry);
|
|
if (result.action !== 'skipped') created++;
|
|
} catch (err) {
|
|
stillFailed.push(item);
|
|
console.warn(`[Lexophile] stub "${item.word}":`, (err as Error).message);
|
|
}
|
|
}
|
|
|
|
this.summary.stubbed += created;
|
|
this.summary.notFound = stillFailed;
|
|
new Notice(`Lexophile: created ${created} stub${created === 1 ? '' : 's'}.`);
|
|
this.render();
|
|
}
|
|
|
|
private renderWordListSection(title: string, words: string[], hint: string) {
|
|
const wrap = this.contentEl.createDiv();
|
|
wrap.style.cssText = 'margin-top: 16px;';
|
|
|
|
const heading = wrap.createEl('h4', { text: title });
|
|
heading.style.cssText = 'margin: 0 0 4px; font-size: 13px; font-weight: 600;';
|
|
|
|
const hintEl = wrap.createEl('p', { text: hint, cls: 'setting-item-description' });
|
|
hintEl.style.cssText = 'margin: 0 0 8px;';
|
|
|
|
const list = wrap.createDiv();
|
|
list.style.cssText =
|
|
'max-height: 160px; overflow-y: auto; border: 1px solid var(--background-modifier-border); border-radius: 6px; padding: 6px 10px; font-size: 13px; font-family: var(--font-monospace, monospace);';
|
|
for (const word of words) {
|
|
const row = list.createDiv();
|
|
row.style.cssText = 'padding: 2px 0;';
|
|
row.textContent = word;
|
|
}
|
|
|
|
const copyBtn = wrap.createEl('button');
|
|
copyBtn.textContent = 'Copy list';
|
|
copyBtn.style.cssText = 'margin-top: 8px;';
|
|
copyBtn.addEventListener('click', async () => {
|
|
try {
|
|
await navigator.clipboard.writeText(words.join('\n'));
|
|
const original = copyBtn.textContent;
|
|
copyBtn.textContent = 'Copied!';
|
|
setTimeout(() => (copyBtn.textContent = original), 1500);
|
|
} catch {
|
|
new Notice('Could not copy to clipboard.');
|
|
}
|
|
});
|
|
}
|
|
}
|