commit 00fcb8a6aff542e88fe195004fac4e7f5b234dbb Author: JPurple Date: Sun Jun 14 01:05:14 2026 +0200 Initial release: dict.cc sidebar plugin diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e7d8721 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,47 @@ +name: Release Obsidian Plugin + +on: + push: + tags: + - "*" + +env: + PLUGIN_NAME: dictcc-sidebar + +permissions: + contents: write + id-token: write + attestations: write + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: "18" + + - name: Install dependencies + run: npm ci + + - name: Build plugin + run: npm run build + + - name: Attest build artifacts + uses: actions/attest-build-provenance@v1 + with: + subject-path: | + main.js + manifest.json + styles.css + + - name: Create release + uses: softprops/action-gh-release@v2 + with: + files: | + main.js + manifest.json + styles.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e15105 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +main.js +*.js.map +.hotreload diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..742f282 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2026 Craig Irving +Portions copyright (c) 2026 Jonas Karneboge (wiktionary-sidebar) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a9db560 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# dict.cc Sidebar + +An Obsidian plugin that lets you look up any German or English word on [dict.cc](https://www.dict.cc/) without leaving your vault. + +**Right-click any selected word → "dict.cc: word" → bilingual translation table appears in the sidebar.** + +## Features + +- Works in both **editor view** and **reading view** +- Shows **grouped translation tables** with section headers (Verbs, Nouns, etc.) +- **Misspelling suggestions** — click any suggestion to re-search instantly +- **Direction filter** in settings: DE ↔ EN (both), DE → EN, or EN → DE +- **Max results** slider to control how many rows are displayed +- Respects Obsidian's **light and dark themes** via CSS variables +- Desktop only + +## Installation + +### From the Community Plugin Browser (recommended) +1. Open Obsidian → Settings → Community Plugins → Browse +2. Search for **dict.cc Sidebar** +3. Install and enable + +### Manual installation +1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](../../releases/latest) +2. Copy them to `.obsidian/plugins/dictcc-sidebar/` in your vault +3. Enable the plugin in Settings → Community Plugins + +## Usage +1. Select a word in any note (editor or reading view) +2. Right-click → **dict.cc: "word"** +3. The sidebar opens with bilingual translation results + +Or use the Command Palette: **Look up selection in dict.cc** + +## Settings +Open Settings → dict.cc Sidebar: +- **Translation direction**: show both directions, only DE→EN, or only EN→DE +- **Max results**: limit how many translation rows are shown + +## License +MIT — forked from [wiktionary-sidebar](https://github.com/jonas-karneboge/wiktionary-sidebar) by Jonas Karneboge diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..5cf5959 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,47 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const banner = `/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ +`; + +const prod = (process.argv[2] === "production"); + +const context = await esbuild.context({ + banner: { js: banner }, + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtins + ], + format: "cjs", + target: "es2018", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + minify: prod, +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..866c3bd --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "dictcc-sidebar", + "name": "dict.cc Sidebar", + "version": "1.0.0", + "minAppVersion": "0.15.0", + "description": "Right-click any selected word to look it up on dict.cc (German ↔ English). Shows bilingual translation tables in a sidebar with misspelling suggestions.", + "author": "Craig Irving", + "authorUrl": "https://github.com/craigirving", + "isDesktopOnly": true +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a120e87 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "dictcc-sidebar", + "version": "1.0.0", + "description": "Obsidian plugin: dict.cc sidebar for German-English translation", + "main": "main.js", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc --noEmit -p tsconfig.json && node esbuild.config.mjs production" + }, + "devDependencies": { + "@types/node": "^18", + "obsidian": "latest", + "esbuild": "0.17.3", + "builtin-modules": "3.3.0", + "typescript": "5.0.0" + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..ab48031 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,427 @@ +import { + App, + Editor, + ItemView, + Menu, + MarkdownView, + Plugin, + PluginSettingTab, + Setting, + WorkspaceLeaf, +} from "obsidian"; + +// ── Types ─────────────────────────────────────────────────────────────────── + +type Direction = "both" | "de-en" | "en-de"; + +interface DictEntry { + section: string; + english: string; + german: string; +} + +interface DictResult { + term: string; + entries: DictEntry[]; + suggestions: string[]; + notFound: boolean; +} + +interface DictCCSettings { + direction: Direction; + maxResults: number; +} + +const DEFAULT_SETTINGS: DictCCSettings = { + direction: "both", + maxResults: 40, +}; + +const VIEW_TYPE = "dictcc-sidebar"; + +// ── HTML helpers ───────────────────────────────────────────────────────────── + +function decodeHtml(str: string): string { + return str + .replace(//gi, "\n") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&#(\d+);/g, (_: string, n: string) => String.fromCharCode(Number(n))) + .replace(/&#x([0-9a-f]+);/gi, (_: string, n: string) => String.fromCharCode(parseInt(n, 16))); +} + +function stripTags(str: string): string { + return decodeHtml(str.replace(/<[^>]*>/g, " ")).replace(/\s+/g, " ").trim(); +} + +function normalizeCell(cellHtml: string): string { + let s = cellHtml + .replace(//gi, " ") + .replace(//gi, " ") + .replace(/]*>([\s\S]*?)<\/a>/gi, "$1"); + return stripTags(s) + .replace(/\s*\[\s*/g, " [") + .replace(/\s*\]\s*/g, "] ") + .replace(/\s+([,;:.!?])/g, "$1") + .replace(/\(\s+/g, "(") + .replace(/\s+\)/g, ")") + .replace(/\s+/g, " ") + .trim(); +} + +// ── dict.cc fetch + parse ──────────────────────────────────────────────────── + +async function fetchDictCC(term: string): Promise { + const url = `https://www.dict.cc/?s=${encodeURIComponent(term)}`; + + let html: string; + try { + const res = await fetch(url, { + headers: { "accept-language": "en,de;q=0.9" }, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + html = await res.text(); + } catch { + return { term, entries: [], suggestions: [], notFound: true }; + } + + // ── Extract suggestions from c1Entry / "Ähnliche Begriffe" / failed_kw ── + const suggestions: string[] = []; + + // failed_kw in URL means this is a corrected search — the original bad term + const failedKw = html.match(/failed_kw=([^&"'>\s]+)/i); + if (failedKw) { + const bad = decodeURIComponent(failedKw[1].replace(/\+/g, " ")); + suggestions.push(bad); + } + + // Suggestion links appearing in similar-words blocks + const suggLinkRe = /]+href="[^"]*\?s=([^"&]+)[^"]*"[^>]*class="[^"]*(?:col1Entry|did-you-mean)[^"]*"[^>]*>([\s\S]*?)<\/a>/gi; + let sm: RegExpExecArray | null; + while ((sm = suggLinkRe.exec(html))) { + const t2 = stripTags(sm[2]).replace(/^[•·\-]\s*/, ""); + if (t2 && !suggestions.includes(t2)) suggestions.push(t2); + } + + // Also scan the "Ähnliche Begriffe" / similar section for plain word links + const simBlock = html.match(/hnliche Begriffe([\s\S]{0,3000}?)(?:back to top|home|©)/i)?.[1] ?? ""; + const simRe = /]+href="[^"]*\?s=([^"&]+)[^"]*"[^>]*>([\s\S]*?)<\/a>/gi; + let sg: RegExpExecArray | null; + while ((sg = simRe.exec(simBlock))) { + const t3 = stripTags(sg[2]).replace(/^[•·\-]\s*/, ""); + if (t3 && t3.length < 50 && !suggestions.includes(t3)) suggestions.push(t3); + } + + // ── Extract translation rows ───────────────────────────────────────────── + // dict.cc embeds translations in a JS variable: var c1Arr = [...]; + // Each entry is: [id, [en_html, de_html], pos, tags] + // Fallback: parse HTML table rows + const entries: DictEntry[] = []; + let currentSection = "Entries"; + + // Try JS variable extraction first (most reliable) + const jsArr = html.match(/var\s+c1Arr\s*=\s*(\[[\s\S]*?\]);\s*\n/); + if (jsArr) { + try { + // eslint-disable-next-line no-eval + const arr: Array<[number, [string, string], string, string[]]> = JSON.parse(jsArr[1]); + for (const row of arr) { + if (!Array.isArray(row) || !Array.isArray(row[1])) continue; + const [en, de] = row[1]; + const sec = typeof row[2] === "string" && row[2] ? row[2] : "Entries"; + const english = normalizeCell(en); + const german = normalizeCell(de); + if (english && german) entries.push({ section: sec, english, german }); + } + } catch { + // fallthrough to HTML table parse + } + } + + // HTML table fallback + if (!entries.length) { + const rowRe = /]*>([\s\S]*?)<\/tr>/gi; + let rm: RegExpExecArray | null; + while ((rm = rowRe.exec(html))) { + const rowHtml = rm[1]; + // Section header rows (colspan) + const sectionOnly = rowHtml.match(/]*colspan=["']?\d+["']?[^>]*>([\s\S]*?)<\/td>/i); + if (sectionOnly && !/]*>[\s\S]*?]*>([\s\S]*?)<\/td>/gi)].map((m) => m[1]); + if (cells.length < 2) continue; + const cleaned = cells.map(normalizeCell); + const english = cleaned[0]; + const german = cleaned[1]; + if (!english || !german) continue; + if (/^English$/i.test(english) || /^German$/i.test(german)) continue; + if (/dict\.cc|Impressum|©/.test(english)) continue; + entries.push({ section: currentSection, english, german }); + } + } + + const notFound = entries.length === 0; + return { term, entries, suggestions: suggestions.slice(0, 10), notFound }; +} + +// ── ItemView (Sidebar) ──────────────────────────────────────────────────────── + +class DictCCSidebarView extends ItemView { + private plugin: DictCCPlugin; + private currentSuggestions: string[] = []; + private currentTerm = ""; + + constructor(leaf: WorkspaceLeaf, plugin: DictCCPlugin) { + super(leaf); + this.plugin = plugin; + } + + getViewType(): string { return VIEW_TYPE; } + getDisplayText(): string { return "dict.cc"; } + getIcon(): string { return "languages"; } + + async onOpen(): Promise { this.renderEmpty(); } + async onClose(): Promise {} + + async lookup(term: string): Promise { + this.currentTerm = term; + this.renderLoading(term); + const result = await fetchDictCC(term); + this.renderResult(result); + } + + private container(): HTMLElement { + return this.containerEl.children[1] as HTMLElement; + } + + private renderEmpty(): void { + const c = this.container(); + c.empty(); + c.addClass("dictcc-container"); + c.createEl("p", { cls: "dictcc-placeholder", text: "Select a word and right-click → dict.cc lookup" }); + } + + private renderLoading(term: string): void { + const c = this.container(); + c.empty(); + c.addClass("dictcc-container"); + c.createEl("p", { cls: "dictcc-loading", text: `Looking up "${term}"…` }); + } + + private renderResult(result: DictResult): void { + const c = this.container(); + c.empty(); + c.addClass("dictcc-container"); + + // Header + const header = c.createEl("div", { cls: "dictcc-header" }); + header.createEl("h2", { cls: "dictcc-word", text: result.term }); + header.createEl("a", { + cls: "dictcc-source-link", + text: "dict.cc ↗", + href: `https://www.dict.cc/?s=${encodeURIComponent(result.term)}`, + }); + + // Suggestions (misspelling / similar terms) + if (result.suggestions.length > 0) { + const sugBlock = c.createEl("div", { cls: "dictcc-suggestions" }); + sugBlock.createEl("p", { cls: "dictcc-sug-label", text: "Did you mean:" }); + const sugList = sugBlock.createEl("div", { cls: "dictcc-sug-list" }); + for (const sug of result.suggestions) { + const btn = sugList.createEl("button", { cls: "dictcc-sug-btn", text: sug }); + btn.addEventListener("click", () => this.lookup(sug)); + } + } + + if (result.notFound) { + c.createEl("p", { cls: "dictcc-not-found", text: `No translations found for "${result.term}".` }); + return; + } + + // Filter by direction + let entries = result.entries; + const dir = this.plugin.settings.direction; + if (dir === "de-en") { + entries = entries.filter( + (e) => e.german.toLowerCase().includes(result.term.toLowerCase()) + ); + } else if (dir === "en-de") { + entries = entries.filter( + (e) => e.english.toLowerCase().includes(result.term.toLowerCase()) + ); + } + + const maxR = this.plugin.settings.maxResults; + entries = entries.slice(0, maxR); + + // Group by section + const sections = new Map(); + for (const e of entries) { + if (!sections.has(e.section)) sections.set(e.section, []); + sections.get(e.section)!.push(e); + } + + for (const [sec, rows] of sections) { + const secEl = c.createEl("div", { cls: "dictcc-section" }); + secEl.createEl("h4", { cls: "dictcc-section-title", text: sec }); + const table = secEl.createEl("table", { cls: "dictcc-table" }); + const thead = table.createEl("thead"); + const hrow = thead.createEl("tr"); + hrow.createEl("th", { text: "English" }); + hrow.createEl("th", { text: "Deutsch" }); + const tbody = table.createEl("tbody"); + for (const row of rows) { + const tr = tbody.createEl("tr"); + tr.createEl("td", { text: row.english }); + tr.createEl("td", { text: row.german }); + } + } + } +} + +// ── Settings Tab ───────────────────────────────────────────────────────────── + +class DictCCSettingTab extends PluginSettingTab { + private plugin: DictCCPlugin; + constructor(app: App, plugin: DictCCPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + containerEl.createEl("h2", { text: "dict.cc Sidebar Settings" }); + + new Setting(containerEl) + .setName("Translation direction") + .setDesc("Filter which direction to show results for.") + .addDropdown((drop) => + drop + .addOption("both", "DE ↔ EN (both)") + .addOption("de-en", "DE → EN only") + .addOption("en-de", "EN → DE only") + .setValue(this.plugin.settings.direction) + .onChange(async (val) => { + this.plugin.settings.direction = val as Direction; + await this.plugin.saveSettings(); + }) + ); + + new Setting(containerEl) + .setName("Max results") + .setDesc("Maximum number of translation rows to display (5–200).") + .addSlider((s) => + s + .setLimits(5, 200, 5) + .setValue(this.plugin.settings.maxResults) + .setDynamicTooltip() + .onChange(async (val) => { + this.plugin.settings.maxResults = val; + await this.plugin.saveSettings(); + }) + ); + } +} + +// ── Main Plugin ─────────────────────────────────────────────────────────────── + +export default class DictCCPlugin extends Plugin { + settings!: DictCCSettings; + + async onload(): Promise { + await this.loadSettings(); + this.registerView(VIEW_TYPE, (leaf) => new DictCCSidebarView(leaf, this)); + this.addSettingTab(new DictCCSettingTab(this.app, this)); + + // Editor right-click menu + this.registerEvent( + (this.app.workspace as any).on( + "editor-menu", + (menu: Menu, editor: Editor, _view: MarkdownView) => { + const word = editor.getSelection().trim().split(/\s+/)[0]; + if (!word) return; + menu.addItem((item) => + item + .setTitle(`dict.cc: "${word}"`) + .setIcon("languages") + .onClick(() => this.openAndLookup(word)) + ); + } + ) + ); + + // Reading view right-click + this.registerDomEvent(document, "contextmenu", (evt: MouseEvent) => { + const target = evt.target as HTMLElement; + if (!target.closest(".markdown-preview-view")) return; + const selection = window.getSelection(); + if (!selection || selection.isCollapsed) return; + const selectedText = selection.toString().trim(); + const word = selectedText.split(/\s+/)[0]; + if (!word) return; + evt.preventDefault(); + const menu = new Menu(); + menu.addItem((item) => + item + .setTitle("Copy") + .setIcon("copy") + .onClick(() => navigator.clipboard.writeText(selectedText)) + ); + menu.addSeparator(); + menu.addItem((item) => + item + .setTitle(`dict.cc: "${word}"`) + .setIcon("languages") + .onClick(() => this.openAndLookup(word)) + ); + menu.showAtMouseEvent(evt); + }); + + // Command palette + this.addCommand({ + id: "dictcc-lookup-selection", + name: "Look up selection in dict.cc", + editorCallback: (editor: Editor) => { + const word = editor.getSelection().trim().split(/\s+/)[0]; + if (word) this.openAndLookup(word); + }, + }); + } + + async onunload(): Promise { + this.app.workspace.detachLeavesOfType(VIEW_TYPE); + } + + async openAndLookup(word: string): Promise { + const { workspace } = this.app; + let leaf: WorkspaceLeaf | null; + const existing = workspace.getLeavesOfType(VIEW_TYPE); + if (existing.length > 0) { + leaf = existing[0]; + } else { + leaf = workspace.getRightLeaf(false); + if (!leaf) return; + await leaf.setViewState({ type: VIEW_TYPE, active: true }); + } + workspace.revealLeaf(leaf); + if (leaf.view instanceof DictCCSidebarView) { + await leaf.view.lookup(word); + } + } + + async loadSettings(): Promise { + this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..6a81a5c --- /dev/null +++ b/styles.css @@ -0,0 +1,124 @@ +/* dict.cc Sidebar – Obsidian Plugin */ + +.dictcc-container { + padding: 12px; + font-size: var(--font-ui-small); + line-height: 1.5; +} + +.dictcc-header { + display: flex; + align-items: baseline; + gap: 10px; + margin-bottom: 12px; + border-bottom: 1px solid var(--background-modifier-border); + padding-bottom: 8px; +} + +.dictcc-word { + font-size: var(--font-ui-large); + font-weight: 600; + margin: 0; + color: var(--text-normal); +} + +.dictcc-source-link { + font-size: var(--font-ui-smaller); + color: var(--text-accent); + text-decoration: none; + margin-left: auto; + white-space: nowrap; +} + +.dictcc-source-link:hover { + text-decoration: underline; +} + +.dictcc-placeholder, +.dictcc-loading, +.dictcc-not-found { + color: var(--text-muted); + font-style: italic; + padding: 8px 0; +} + +/* Suggestions / Did you mean */ +.dictcc-suggestions { + background: var(--background-secondary); + border-radius: 6px; + padding: 8px 10px; + margin-bottom: 12px; +} + +.dictcc-sug-label { + color: var(--text-muted); + font-size: var(--font-ui-smaller); + margin: 0 0 6px 0; +} + +.dictcc-sug-list { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.dictcc-sug-btn { + background: var(--background-modifier-hover); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + padding: 2px 10px; + font-size: var(--font-ui-smaller); + color: var(--text-accent); + cursor: pointer; + transition: background 120ms ease; +} + +.dictcc-sug-btn:hover { + background: var(--background-modifier-active-hover); +} + +/* Section heading */ +.dictcc-section { + margin-bottom: 16px; +} + +.dictcc-section-title { + font-size: var(--font-ui-small); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-muted); + margin: 0 0 6px 0; +} + +/* Table */ +.dictcc-table { + width: 100%; + border-collapse: collapse; + font-size: var(--font-ui-small); +} + +.dictcc-table th { + text-align: left; + padding: 4px 6px; + font-weight: 600; + color: var(--text-muted); + font-size: var(--font-ui-smaller); + border-bottom: 1px solid var(--background-modifier-border); +} + +.dictcc-table td { + padding: 4px 6px; + vertical-align: top; + color: var(--text-normal); + border-bottom: 1px solid var(--background-modifier-border-hover); + word-break: break-word; +} + +.dictcc-table tr:last-child td { + border-bottom: none; +} + +.dictcc-table tr:hover td { + background: var(--background-modifier-hover); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..97a9298 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES2018", + "allowImportingTsExtensions": true, + "allowSyntheticDefaultImports": true, + "moduleResolution": "bundler", + "importHelpers": true, + "isolatedModules": true, + "strictNullChecks": true, + "lib": ["ES2018", "DOM"] + }, + "include": ["src/**/*.ts"] +}