commit 7fae06cc06c1baaf4bc8402813d09f4a19473260 Author: Chris Teplovs Date: Wed Apr 22 17:23:14 2026 -0400 Initial release: Jupyter Notebook Viewer v1.0.0 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d44c85f --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 cteplovs + +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..decda27 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Jupyter Notebook Viewer for Obsidian + +Renders `.ipynb` (Jupyter notebook) files natively inside Obsidian — markdown cells as prose, code cells with syntax highlighting and execution-count badges, and outputs (text, tables, errors) inline. + +## Features + +- **Markdown cells** rendered using Obsidian's own renderer — wikilinks, bold, italics, and all standard formatting work +- **Code cells** with Python syntax highlighting and `[N]` execution-count badges +- **Stream output** (`stdout`/`stderr`) displayed as monospace text +- **Rich output** — pandas and polars DataFrame HTML tables rendered with scrollable, theme-aware styling +- **Error tracebacks** with ANSI escape codes stripped for clean display +- **Theme-aware** — all colors use Obsidian CSS variables; works in light mode, dark mode, and any community theme +- **No build step** — plugin is plain JavaScript, no Node.js or npm required + +## Installation + +### From the Community Plugin Directory + +Search for **"Jupyter Notebook Viewer"** in **Settings → Community plugins → Browse**. + +### Manual installation + +1. Download `main.js`, `manifest.json`, and `styles.css` from the [latest release](../../releases/latest). +2. Create a folder `/.obsidian/plugins/jupyter-viewer/`. +3. Place the three downloaded files into that folder. +4. Open Obsidian, go to **Settings → Community plugins**, disable Restricted Mode if prompted, then enable **Jupyter Notebook Viewer**. + +## Usage + +Click any `.ipynb` file in the Obsidian file explorer. It opens in the notebook viewer automatically. + +To switch back to raw JSON (e.g., for copying cell contents), right-click the file and choose **Open with → Default editor**. + +## Supported output types + +| Output type | Rendered as | +|---|---| +| `stream` (stdout) | Monospace preformatted text | +| `stream` (stderr) | Monospace text in error color | +| `execute_result` / `display_data` with `text/html` | Scrollable HTML table | +| `execute_result` / `display_data` with `text/plain` | Monospace preformatted text | +| `error` | Exception header + cleaned traceback | +| `image/png` | Inline image (base64) | + +## Compatibility + +- Obsidian 1.0.0+ +- Desktop only (uses Obsidian's desktop file APIs) +- Jupyter notebook format v4 (nbformat 4) + +## License + +MIT — see [LICENSE](LICENSE). diff --git a/main.js b/main.js new file mode 100644 index 0000000..de0abef --- /dev/null +++ b/main.js @@ -0,0 +1,166 @@ +'use strict'; + +const { Plugin, FileView, MarkdownRenderer } = require('obsidian'); + +const VIEW_TYPE = 'jupyter-viewer'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function normalizeSource(source) { + if (Array.isArray(source)) return source.join(''); + if (typeof source === 'string') return source; + return ''; +} + +function stripAnsi(str) { + return str.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ''); +} + +// Returns { type: 'html'|'plain', content } or null +function mimeToHtml(data) { + if (data['text/html']) { + const raw = data['text/html']; + return { type: 'html', content: Array.isArray(raw) ? raw.join('') : raw }; + } + if (data['text/plain']) { + const raw = data['text/plain']; + return { type: 'plain', content: Array.isArray(raw) ? raw.join('') : raw }; + } + // image/png: not present in current corpus; add here if needed: + // if (data['image/png']) return { type: 'img', content: data['image/png'] }; + return null; +} + +function renderOutput(output, container) { + const wrap = container.createDiv({ cls: 'jv-output' }); + + switch (output.output_type) { + case 'stream': { + const text = Array.isArray(output.text) + ? output.text.join('') + : (output.text || ''); + const pre = wrap.createEl('pre', { cls: 'jv-stream' }); + if (output.name === 'stderr') pre.addClass('jv-stderr'); + pre.setText(text); + break; + } + + case 'execute_result': + case 'display_data': { + const data = output.data || {}; + const result = mimeToHtml(data); + if (!result) break; + wrap.addClass('jv-rich-output'); + if (result.type === 'html') { + wrap.innerHTML = result.content; + } else { + wrap.createEl('pre', { cls: 'jv-plain-output', text: result.content }); + } + break; + } + + case 'error': { + const errorWrap = wrap.createDiv({ cls: 'jv-error' }); + const header = [output.ename, output.evalue].filter(Boolean).join(': '); + if (header) { + errorWrap.createEl('div', { cls: 'jv-error-header', text: header }); + } + const tb = (output.traceback || []).map(stripAnsi).join('\n'); + if (tb) { + errorWrap.createEl('pre', { cls: 'jv-traceback', text: tb }); + } + break; + } + + default: + break; + } +} + +async function renderCell(cell, container, plugin, file) { + const source = normalizeSource(cell.source); + + if (cell.cell_type === 'markdown') { + if (!source.trim()) return; + const mdWrap = container.createDiv({ cls: 'jv-cell jv-markdown-cell' }); + await MarkdownRenderer.renderMarkdown(source, mdWrap, file.path, plugin); + return; + } + + if (cell.cell_type === 'code') { + const codeWrap = container.createDiv({ cls: 'jv-cell jv-code-cell' }); + + const execCount = cell.execution_count; + const badge = execCount != null ? `[${execCount}]` : '[ ]'; + codeWrap.createDiv({ cls: 'jv-exec-count', text: badge }); + + const codeBlock = codeWrap.createDiv({ cls: 'jv-code-source' }); + if (source.trim()) { + const fenced = '```python\n' + source + '\n```'; + await MarkdownRenderer.renderMarkdown(fenced, codeBlock, file.path, plugin); + } + + const outputs = cell.outputs || []; + if (outputs.length > 0) { + const outputWrap = codeWrap.createDiv({ cls: 'jv-outputs' }); + for (const output of outputs) { + renderOutput(output, outputWrap); + } + } + return; + } + + // raw cells and unknown types — skip silently +} + +// ── View ────────────────────────────────────────────────────────────────────── + +class JupyterView extends FileView { + constructor(leaf, plugin) { + super(leaf); + this.plugin = plugin; + } + + getViewType() { + return VIEW_TYPE; + } + + getDisplayText() { + return this.file ? this.file.basename : 'Jupyter Notebook'; + } + + async onLoadFile(file) { + const container = this.containerEl.children[1]; + container.empty(); + container.addClass('jv-container'); + + try { + const raw = await this.app.vault.read(file); + const nb = JSON.parse(raw); + const cells = nb.cells || []; + for (const cell of cells) { + await renderCell(cell, container, this.plugin, file); + } + } catch (err) { + container.createEl('p', { + cls: 'jv-parse-error', + text: `Failed to parse notebook: ${err.message}`, + }); + } + } +} + +// ── Plugin ──────────────────────────────────────────────────────────────────── + +class JupyterViewerPlugin extends Plugin { + async onload() { + this.registerView(VIEW_TYPE, (leaf) => new JupyterView(leaf, this)); + this.registerExtensions(['ipynb'], VIEW_TYPE); + } + + onunload() { + this.app.workspace.detachLeavesOfType(VIEW_TYPE); + } +} + +module.exports = JupyterViewerPlugin; diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..793949c --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "id": "jupyter-viewer", + "name": "Jupyter Notebook Viewer", + "version": "1.0.0", + "minAppVersion": "1.0.0", + "description": "Renders .ipynb files natively inside Obsidian.", + "author": "cteplovs", + "authorUrl": "", + "isDesktopOnly": true +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..adf46db --- /dev/null +++ b/styles.css @@ -0,0 +1,157 @@ +/* ── Container ──────────────────────────────────────────────────────────────── */ + +.jv-container { + padding: 1.5rem 2rem; + max-width: 900px; + margin: 0 auto; + font-family: var(--font-text); +} + +/* ── Cells ───────────────────────────────────────────────────────────────────── */ + +.jv-cell { + margin-bottom: 1.25rem; +} + +/* Markdown cells render as normal prose — no border needed */ +.jv-markdown-cell { + line-height: var(--line-height-normal); +} + +/* Code cells: 2-column grid — exec badge | source — outputs span both */ +.jv-code-cell { + display: grid; + grid-template-columns: 3rem 1fr; + background-color: var(--background-secondary); + border-left: 3px solid var(--color-blue); + border-radius: 4px; + overflow: hidden; +} + +.jv-exec-count { + grid-column: 1; + grid-row: 1; + padding: 0.6rem 0.25rem 0; + color: var(--text-muted); + font-size: 0.72rem; + font-family: var(--font-monospace); + text-align: center; + line-height: 1.4; + user-select: none; +} + +.jv-code-source { + grid-column: 2; + grid-row: 1; + min-width: 0; /* prevent grid blowout */ +} + +/* Strip the extra margin/background from Obsidian's rendered code blocks */ +.jv-code-source pre { + margin: 0; + border-radius: 0; + background: transparent; +} + +.jv-code-source pre code { + font-size: 0.85rem; +} + +/* ── Outputs ─────────────────────────────────────────────────────────────────── */ + +.jv-outputs { + grid-column: 1 / -1; + border-top: 1px solid var(--background-modifier-border); +} + +.jv-output { + padding: 0.4rem 0.75rem; +} + +/* Stream (stdout / stderr) */ +.jv-stream { + font-family: var(--font-monospace); + font-size: 0.83rem; + color: var(--text-normal); + white-space: pre-wrap; + word-break: break-all; + margin: 0; +} + +.jv-stderr { + color: var(--text-error); +} + +/* Rich output: pandas / polars DataFrame tables */ +.jv-rich-output { + overflow-x: auto; + font-size: 0.83rem; +} + +.jv-rich-output table { + border-collapse: collapse; + color: var(--text-normal); +} + +.jv-rich-output th, +.jv-rich-output td { + border: 1px solid var(--background-modifier-border); + padding: 3px 8px; + text-align: right; +} + +.jv-rich-output th { + background-color: var(--background-secondary-alt); + font-weight: 600; + text-align: center; +} + +/* Hide the embedded