mirror of
https://github.com/cteplovs/obsidian-jupyter-viewer.git
synced 2026-07-22 06:55:19 +00:00
Initial release: Jupyter Notebook Viewer v1.0.0
This commit is contained in:
commit
7fae06cc06
5 changed files with 407 additions and 0 deletions
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -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.
|
||||
53
README.md
Normal file
53
README.md
Normal file
|
|
@ -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 `<your-vault>/.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).
|
||||
166
main.js
Normal file
166
main.js
Normal file
|
|
@ -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;
|
||||
10
manifest.json
Normal file
10
manifest.json
Normal file
|
|
@ -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
|
||||
}
|
||||
157
styles.css
Normal file
157
styles.css
Normal file
|
|
@ -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 <style scoped> blocks from pandas/polars — they override
|
||||
our theme-aware table rules and break dark mode */
|
||||
.jv-rich-output style {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Plain-text execute_result */
|
||||
.jv-plain-output {
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.83rem;
|
||||
color: var(--text-normal);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* ── Error outputs ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.jv-error {
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.jv-error-header {
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.88rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-error);
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.jv-traceback {
|
||||
font-family: var(--font-monospace);
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
background-color: var(--background-secondary);
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
margin: 0.25rem 0 0 0;
|
||||
}
|
||||
|
||||
/* ── Parse error ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
.jv-parse-error {
|
||||
color: var(--text-error);
|
||||
font-family: var(--font-monospace);
|
||||
padding: 1rem;
|
||||
}
|
||||
Loading…
Reference in a new issue