Initial release: dict.cc sidebar plugin

This commit is contained in:
JPurple 2026-06-14 01:05:14 +02:00
commit 00fcb8a6af
10 changed files with 757 additions and 0 deletions

47
.github/workflows/release.yml vendored Normal file
View file

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

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
node_modules
main.js
*.js.map
.hotreload

22
LICENSE Normal file
View file

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

42
README.md Normal file
View file

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

47
esbuild.config.mjs Normal file
View file

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

10
manifest.json Normal file
View file

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

17
package.json Normal file
View file

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

427
src/main.ts Normal file
View file

@ -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(/<br\s*\/?>/gi, "\n")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/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(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<a[^>]*>([\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<DictResult> {
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 = /<a[^>]+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 = /<a[^>]+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 = /<tr[^>]*>([\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(/<td[^>]*colspan=["']?\d+["']?[^>]*>([\s\S]*?)<\/td>/i);
if (sectionOnly && !/<td[^>]*>[\s\S]*?<td/i.test(rowHtml)) {
const st = normalizeCell(sectionOnly[1]);
if (st && !/dict\.cc|Impressum|back to top/i.test(st)) currentSection = st;
continue;
}
const cells = [...rowHtml.matchAll(/<td[^>]*>([\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<void> { this.renderEmpty(); }
async onClose(): Promise<void> {}
async lookup(term: string): Promise<void> {
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<string, DictEntry[]>();
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 (5200).")
.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<void> {
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<void> {
this.app.workspace.detachLeavesOfType(VIEW_TYPE);
}
async openAndLookup(word: string): Promise<void> {
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<void> {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings(): Promise<void> {
await this.saveData(this.settings);
}
}

124
styles.css Normal file
View file

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

17
tsconfig.json Normal file
View file

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