mrmd-jupyter-bridge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js ADDED
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * mrmd-jupyter-bridge CLI
5
+ *
6
+ * Syncs Jupyter notebooks with MRMD markdown via Yjs collaboration.
7
+ *
8
+ * Usage:
9
+ * mrmd-jupyter-bridge <notebook.ipynb> [options]
10
+ *
11
+ * Options:
12
+ * --sync-url, -s mrmd-sync WebSocket URL (default: ws://localhost:4444)
13
+ * --name, -n Name shown in collaboration (default: jupyter-bridge)
14
+ * --help, -h Show this help message
15
+ *
16
+ * Examples:
17
+ * mrmd-jupyter-bridge notebook.ipynb
18
+ * mrmd-jupyter-bridge notebook.ipynb --sync-url ws://localhost:5555
19
+ * mrmd-jupyter-bridge /path/to/notebook.ipynb -n "Jupyter"
20
+ */
21
+
22
+ import { resolve } from 'path';
23
+ import { existsSync } from 'fs';
24
+ import { createBridge } from '../src/index.js';
25
+
26
+ function printHelp() {
27
+ console.log(`
28
+ mrmd-jupyter-bridge - Sync Jupyter notebooks with MRMD
29
+
30
+ Usage:
31
+ mrmd-jupyter-bridge <notebook.ipynb> [options]
32
+
33
+ Options:
34
+ --sync-url, -s <url> mrmd-sync WebSocket URL (default: ws://localhost:4444)
35
+ --name, -n <name> Name shown in collaboration (default: jupyter-bridge)
36
+ --debounce <ms> Write debounce delay in ms (default: 500)
37
+ --help, -h Show this help message
38
+
39
+ Examples:
40
+ mrmd-jupyter-bridge notebook.ipynb
41
+ mrmd-jupyter-bridge notebook.ipynb --sync-url ws://localhost:5555
42
+ mrmd-jupyter-bridge ./notebooks/analysis.ipynb -n "Jupyter"
43
+
44
+ How it works:
45
+ 1. The bridge watches your .ipynb file for changes
46
+ 2. It connects to mrmd-sync as a Yjs collaborator
47
+ 3. Changes in Jupyter appear in MRMD as if typed by "jupyter-bridge"
48
+ 4. Changes in MRMD sync back to the .ipynb file
49
+
50
+ The markdown file will be created next to the notebook with the same name:
51
+ notebook.ipynb -> notebook.md
52
+ `);
53
+ }
54
+
55
+ function parseArgs(args) {
56
+ const options = {
57
+ ipynbPath: null,
58
+ syncUrl: 'ws://localhost:4444',
59
+ name: 'jupyter-bridge',
60
+ debounceMs: 500,
61
+ };
62
+
63
+ let i = 0;
64
+ while (i < args.length) {
65
+ const arg = args[i];
66
+
67
+ if (arg === '--help' || arg === '-h') {
68
+ printHelp();
69
+ process.exit(0);
70
+ } else if (arg === '--sync-url' || arg === '-s') {
71
+ options.syncUrl = args[++i];
72
+ } else if (arg === '--name' || arg === '-n') {
73
+ options.name = args[++i];
74
+ } else if (arg === '--debounce') {
75
+ options.debounceMs = parseInt(args[++i], 10);
76
+ } else if (!arg.startsWith('-')) {
77
+ options.ipynbPath = arg;
78
+ } else {
79
+ console.error(`Unknown option: ${arg}`);
80
+ process.exit(1);
81
+ }
82
+ i++;
83
+ }
84
+
85
+ return options;
86
+ }
87
+
88
+ async function main() {
89
+ const args = process.argv.slice(2);
90
+
91
+ if (args.length === 0) {
92
+ printHelp();
93
+ process.exit(1);
94
+ }
95
+
96
+ const options = parseArgs(args);
97
+
98
+ if (!options.ipynbPath) {
99
+ console.error('Error: Please provide a notebook path');
100
+ printHelp();
101
+ process.exit(1);
102
+ }
103
+
104
+ // Resolve to absolute path
105
+ const ipynbPath = resolve(options.ipynbPath);
106
+
107
+ if (!ipynbPath.endsWith('.ipynb')) {
108
+ console.error('Error: File must have .ipynb extension');
109
+ process.exit(1);
110
+ }
111
+
112
+ // Note: File doesn't need to exist - we can create it from MRMD content
113
+ if (!existsSync(ipynbPath)) {
114
+ console.log(`Note: ${ipynbPath} does not exist yet. Will create from MRMD content if available.`);
115
+ }
116
+
117
+ console.log(`
118
+ ╔════════════════════════════════════════════════════════════╗
119
+ ║ mrmd-jupyter-bridge ║
120
+ ╠════════════════════════════════════════════════════════════╣
121
+ ║ Notebook: ${options.ipynbPath.padEnd(46)}║
122
+ ║ Sync URL: ${options.syncUrl.padEnd(46)}║
123
+ ║ Name: ${options.name.padEnd(46)}║
124
+ ╚════════════════════════════════════════════════════════════╝
125
+ `);
126
+
127
+ try {
128
+ const bridge = await createBridge(options.syncUrl, ipynbPath, {
129
+ name: options.name,
130
+ debounceMs: options.debounceMs,
131
+ log: (msg) => console.log(msg),
132
+ });
133
+
134
+ console.log('Bridge is running. Press Ctrl+C to stop.\n');
135
+
136
+ // Handle graceful shutdown
137
+ const shutdown = () => {
138
+ console.log('\nShutting down...');
139
+ bridge.stop();
140
+ process.exit(0);
141
+ };
142
+
143
+ process.on('SIGINT', shutdown);
144
+ process.on('SIGTERM', shutdown);
145
+
146
+ // Keep the process running
147
+ await new Promise(() => {});
148
+ } catch (err) {
149
+ console.error(`\nFailed to start bridge: ${err.message}`);
150
+ process.exit(1);
151
+ }
152
+ }
153
+
154
+ main();
package/demo.ipynb ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Demo Notebook\n",
8
+ "\n",
9
+ "Testing live sync between Jupyter and MRMD!\n",
10
+ "\n",
11
+ "## Added from MRMD Editor!\n",
12
+ "\n",
13
+ "This text was typed directly in the MRMD editor and should sync to the .ipynb file.\n"
14
+ ]
15
+ },
16
+ {
17
+ "cell_type": "code",
18
+ "execution_count": 1,
19
+ "metadata": {},
20
+ "source": [
21
+ "print(\"Hello from Jupyter!\")"
22
+ ],
23
+ "outputs": [
24
+ {
25
+ "output_type": "stream",
26
+ "name": "stdout",
27
+ "text": [
28
+ "Hello from Jupyter!"
29
+ ]
30
+ }
31
+ ]
32
+ },
33
+ {
34
+ "cell_type": "code",
35
+ "execution_count": 2,
36
+ "metadata": {},
37
+ "source": [
38
+ "x = 42\n",
39
+ "print(f\"The answer is {x}\")"
40
+ ],
41
+ "outputs": [
42
+ {
43
+ "output_type": "stream",
44
+ "name": "stdout",
45
+ "text": [
46
+ "The answer is 42"
47
+ ]
48
+ }
49
+ ]
50
+ },
51
+ {
52
+ "cell_type": "markdown",
53
+ "metadata": {},
54
+ "source": [
55
+ "\n",
56
+ "## Live Sync Test!\n",
57
+ "\n",
58
+ "This line was added at the end to test the sync. If it appears in demo.ipynb, the bridge works!"
59
+ ]
60
+ }
61
+ ],
62
+ "metadata": {
63
+ "kernelspec": {
64
+ "display_name": "Python 3",
65
+ "language": "python",
66
+ "name": "python3"
67
+ },
68
+ "language_info": {
69
+ "name": "python",
70
+ "version": "3.10.0"
71
+ }
72
+ },
73
+ "nbformat": 4,
74
+ "nbformat_minor": 5
75
+ }
package/demo.md ADDED
@@ -0,0 +1,28 @@
1
+ # Demo Notebook
2
+
3
+ Testing live sync between Jupyter and MRMD!
4
+
5
+ ## Added from MRMD Editor!
6
+
7
+ This text was typed directly in the MRMD editor and should sync to the .ipynb file.
8
+
9
+ ```python
10
+ print("Hello from Jupyter!")
11
+ ```
12
+
13
+ ```output:jupyter-1
14
+ Hello from Jupyter!
15
+ ```
16
+
17
+ ```python
18
+ x = 42
19
+ print(f"The answer is {x}")
20
+ ```
21
+
22
+ ```output:jupyter-2
23
+ The answer is 42
24
+ ```
25
+
26
+ ## Live Sync Test!
27
+
28
+ This line was added at the end to test the sync. If it appears in demo.ipynb, the bridge works!
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "mrmd-jupyter-bridge",
3
+ "version": "0.1.0",
4
+ "description": "Bidirectional sync bridge between Jupyter notebooks (.ipynb) and MRMD markdown",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "mrmd-jupyter-bridge": "./bin/cli.js"
9
+ },
10
+ "scripts": {
11
+ "start": "node bin/cli.js",
12
+ "dev": "node --watch bin/cli.js",
13
+ "test": "node --test src/*.test.js"
14
+ },
15
+ "keywords": [
16
+ "mrmd",
17
+ "jupyter",
18
+ "notebook",
19
+ "ipynb",
20
+ "markdown",
21
+ "yjs",
22
+ "sync",
23
+ "collaboration"
24
+ ],
25
+ "author": "",
26
+ "license": "MIT",
27
+ "dependencies": {
28
+ "yjs": "^13.6.0",
29
+ "y-websocket": "^2.0.0",
30
+ "lib0": "^0.2.0",
31
+ "chokidar": "^3.5.0",
32
+ "diff": "^5.2.0"
33
+ },
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ }
37
+ }
package/src/bridge.js ADDED
@@ -0,0 +1,441 @@
1
+ /**
2
+ * Jupyter Bridge
3
+ *
4
+ * Bidirectional sync bridge between Jupyter notebooks (.ipynb) and MRMD markdown.
5
+ * Connects to mrmd-sync as a Yjs peer, appearing as a collaborator.
6
+ */
7
+
8
+ import * as Y from 'yjs';
9
+ import { WebsocketProvider } from 'y-websocket';
10
+ import { watch } from 'chokidar';
11
+ import { diffChars } from 'diff';
12
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
13
+ import { basename, dirname, join } from 'path';
14
+ import {
15
+ ipynbToMarkdown,
16
+ markdownToIpynb,
17
+ parseIpynb,
18
+ serializeIpynb,
19
+ } from './converter.js';
20
+
21
+ /**
22
+ * @typedef {Object} BridgeOptions
23
+ * @property {string} [name='jupyter-bridge'] - Name shown in awareness
24
+ * @property {string} [color='#f59e0b'] - Color for awareness
25
+ * @property {number} [debounceMs=500] - Debounce delay for file writes
26
+ * @property {Function} [log] - Logger function
27
+ */
28
+
29
+ /**
30
+ * Jupyter Bridge
31
+ *
32
+ * Syncs a .ipynb file bidirectionally with MRMD via Yjs.
33
+ */
34
+ export class JupyterBridge {
35
+ /**
36
+ * @param {string} syncUrl - WebSocket URL for mrmd-sync (e.g., 'ws://localhost:4444')
37
+ * @param {string} ipynbPath - Path to the .ipynb file
38
+ * @param {BridgeOptions} [options]
39
+ */
40
+ constructor(syncUrl, ipynbPath, options = {}) {
41
+ this.syncUrl = syncUrl;
42
+ this.ipynbPath = ipynbPath;
43
+ this.options = {
44
+ name: 'jupyter-bridge',
45
+ color: '#f59e0b', // Amber - distinguishes from monitors (green) and editors
46
+ debounceMs: 500,
47
+ log: console.log,
48
+ ...options,
49
+ };
50
+
51
+ // Derive markdown path (same name, .md extension)
52
+ const dir = dirname(ipynbPath);
53
+ const base = basename(ipynbPath, '.ipynb');
54
+ this.mdPath = join(dir, `${base}.md`);
55
+
56
+ // The room name for mrmd-sync is the markdown path
57
+ // Use absolute path so mrmd-sync opens the right file
58
+ this.docPath = this.mdPath;
59
+
60
+ /** @type {Y.Doc} */
61
+ this.ydoc = new Y.Doc();
62
+
63
+ /** @type {WebsocketProvider|null} */
64
+ this.provider = null;
65
+
66
+ /** @type {import('chokidar').FSWatcher|null} */
67
+ this.watcher = null;
68
+
69
+ // State tracking
70
+ this._connected = false;
71
+ this._synced = false;
72
+ this._lastIpynbContent = null;
73
+ this._lastMdContent = null;
74
+ this._isWritingIpynb = false;
75
+ this._isWritingYjs = false;
76
+ this._writeTimeout = null;
77
+
78
+ // Store original notebook for metadata preservation
79
+ this._originalNotebook = null;
80
+ }
81
+
82
+ /**
83
+ * Log helper
84
+ */
85
+ _log(level, message, data = {}) {
86
+ const entry = {
87
+ timestamp: new Date().toISOString(),
88
+ level,
89
+ component: 'jupyter-bridge',
90
+ message,
91
+ ipynb: basename(this.ipynbPath),
92
+ ...data,
93
+ };
94
+ this.options.log(JSON.stringify(entry));
95
+ }
96
+
97
+ /**
98
+ * Start the bridge
99
+ *
100
+ * @returns {Promise<void>}
101
+ */
102
+ async start() {
103
+ this._log('info', 'Starting Jupyter bridge', {
104
+ ipynb: this.ipynbPath,
105
+ md: this.mdPath,
106
+ sync: this.syncUrl,
107
+ });
108
+
109
+ // Load initial notebook
110
+ await this._loadNotebook();
111
+
112
+ // Connect to mrmd-sync
113
+ await this._connect();
114
+
115
+ // Start watching the .ipynb file
116
+ this._startWatcher();
117
+
118
+ this._log('info', 'Bridge started');
119
+ }
120
+
121
+ /**
122
+ * Load the notebook and convert to markdown
123
+ */
124
+ async _loadNotebook() {
125
+ if (!existsSync(this.ipynbPath)) {
126
+ this._log('warn', 'Notebook file does not exist yet', { path: this.ipynbPath });
127
+ return;
128
+ }
129
+
130
+ try {
131
+ const content = readFileSync(this.ipynbPath, 'utf8');
132
+ const { notebook, error } = parseIpynb(content);
133
+
134
+ if (error) {
135
+ this._log('error', 'Failed to parse notebook', { error });
136
+ return;
137
+ }
138
+
139
+ this._originalNotebook = notebook;
140
+ this._lastIpynbContent = content;
141
+ this._lastMdContent = ipynbToMarkdown(notebook);
142
+
143
+ this._log('info', 'Loaded notebook', {
144
+ cells: notebook.cells.length,
145
+ mdChars: this._lastMdContent.length,
146
+ });
147
+ } catch (err) {
148
+ this._log('error', 'Failed to load notebook', { error: err.message });
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Connect to mrmd-sync as a Yjs peer
154
+ */
155
+ _connect() {
156
+ return new Promise((resolve, reject) => {
157
+ this._log('info', 'Connecting to sync server', {
158
+ url: this.syncUrl,
159
+ doc: this.docPath,
160
+ });
161
+
162
+ this.provider = new WebsocketProvider(this.syncUrl, this.docPath, this.ydoc, {
163
+ connect: true,
164
+ });
165
+
166
+ // Set awareness - this shows up in MRMD as a collaborator
167
+ this.provider.awareness.setLocalStateField('user', {
168
+ name: this.options.name,
169
+ color: this.options.color,
170
+ type: 'jupyter-bridge',
171
+ });
172
+
173
+ // Track connection
174
+ this.provider.on('status', ({ status }) => {
175
+ const wasConnected = this._connected;
176
+ this._connected = status === 'connected';
177
+
178
+ if (this._connected && !wasConnected) {
179
+ this._log('info', 'Connected to sync server');
180
+ } else if (!this._connected && wasConnected) {
181
+ this._log('warn', 'Disconnected from sync server');
182
+ }
183
+ });
184
+
185
+ // Wait for sync
186
+ this.provider.on('sync', (isSynced) => {
187
+ if (isSynced && !this._synced) {
188
+ this._synced = true;
189
+ this._log('info', 'Document synced');
190
+
191
+ // Initialize: push notebook content to Yjs if we have it and Yjs is empty
192
+ this._initializeContent();
193
+
194
+ // Start listening for Yjs changes
195
+ this._watchYjs();
196
+
197
+ resolve();
198
+ }
199
+ });
200
+
201
+ this.provider.on('connection-error', (err) => {
202
+ this._log('error', 'Connection error', { error: err.message });
203
+ reject(err);
204
+ });
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Initialize content - either from notebook or from existing Yjs state
210
+ */
211
+ _initializeContent() {
212
+ const ytext = this.ydoc.getText('content');
213
+ const yjsContent = ytext.toString();
214
+
215
+ if (yjsContent.length === 0 && this._lastMdContent) {
216
+ // Yjs is empty, push our notebook content
217
+ this._log('info', 'Initializing Yjs from notebook');
218
+ this._isWritingYjs = true;
219
+ this.ydoc.transact(() => {
220
+ ytext.insert(0, this._lastMdContent);
221
+ });
222
+ this._isWritingYjs = false;
223
+ } else if (yjsContent.length > 0) {
224
+ // Yjs has content, sync back to notebook
225
+ this._log('info', 'Syncing existing Yjs content to notebook');
226
+ this._lastMdContent = yjsContent;
227
+ this._scheduleIpynbWrite();
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Watch Yjs for changes (from other collaborators or MRMD editor)
233
+ */
234
+ _watchYjs() {
235
+ const ytext = this.ydoc.getText('content');
236
+
237
+ ytext.observe((event) => {
238
+ if (this._isWritingYjs) return;
239
+
240
+ const newContent = ytext.toString();
241
+ if (newContent === this._lastMdContent) return;
242
+
243
+ this._log('debug', 'Yjs content changed', {
244
+ oldLen: this._lastMdContent?.length || 0,
245
+ newLen: newContent.length,
246
+ });
247
+
248
+ this._lastMdContent = newContent;
249
+ this._scheduleIpynbWrite();
250
+ });
251
+ }
252
+
253
+ /**
254
+ * Schedule debounced write to .ipynb
255
+ */
256
+ _scheduleIpynbWrite() {
257
+ clearTimeout(this._writeTimeout);
258
+
259
+ this._writeTimeout = setTimeout(() => {
260
+ this._writeIpynb();
261
+ }, this.options.debounceMs);
262
+ }
263
+
264
+ /**
265
+ * Write current markdown state back to .ipynb
266
+ */
267
+ _writeIpynb() {
268
+ if (this._isWritingIpynb || !this._lastMdContent) return;
269
+
270
+ this._isWritingIpynb = true;
271
+
272
+ try {
273
+ // Convert markdown to notebook, preserving original metadata
274
+ const notebook = markdownToIpynb(this._lastMdContent, this._originalNotebook);
275
+ const content = serializeIpynb(notebook);
276
+
277
+ // Skip if unchanged
278
+ if (content === this._lastIpynbContent) {
279
+ this._isWritingIpynb = false;
280
+ return;
281
+ }
282
+
283
+ writeFileSync(this.ipynbPath, content, 'utf8');
284
+ this._lastIpynbContent = content;
285
+ this._originalNotebook = notebook;
286
+
287
+ this._log('info', 'Wrote notebook', {
288
+ cells: notebook.cells.length,
289
+ bytes: content.length,
290
+ });
291
+ } catch (err) {
292
+ this._log('error', 'Failed to write notebook', { error: err.message });
293
+ } finally {
294
+ this._isWritingIpynb = false;
295
+ }
296
+ }
297
+
298
+ /**
299
+ * Start watching the .ipynb file for external changes
300
+ */
301
+ _startWatcher() {
302
+ this.watcher = watch(this.ipynbPath, {
303
+ ignoreInitial: true,
304
+ awaitWriteFinish: { stabilityThreshold: 300 },
305
+ });
306
+
307
+ this.watcher.on('change', () => {
308
+ this._handleIpynbChange();
309
+ });
310
+
311
+ this.watcher.on('error', (err) => {
312
+ this._log('error', 'File watcher error', { error: err.message });
313
+ });
314
+
315
+ this._log('info', 'Watching notebook file');
316
+ }
317
+
318
+ /**
319
+ * Handle external change to .ipynb file
320
+ */
321
+ async _handleIpynbChange() {
322
+ // Skip if we just wrote the file
323
+ if (this._isWritingIpynb) return;
324
+
325
+ this._log('debug', 'Notebook file changed externally');
326
+
327
+ try {
328
+ const content = readFileSync(this.ipynbPath, 'utf8');
329
+
330
+ // Skip if unchanged
331
+ if (content === this._lastIpynbContent) return;
332
+
333
+ const { notebook, error } = parseIpynb(content);
334
+ if (error) {
335
+ this._log('error', 'Failed to parse changed notebook', { error });
336
+ return;
337
+ }
338
+
339
+ this._lastIpynbContent = content;
340
+ this._originalNotebook = notebook;
341
+
342
+ const newMdContent = ipynbToMarkdown(notebook);
343
+
344
+ // Skip if markdown unchanged
345
+ if (newMdContent === this._lastMdContent) {
346
+ return;
347
+ }
348
+
349
+ // Apply changes to Yjs using diff
350
+ this._applyToYjs(newMdContent);
351
+
352
+ this._log('info', 'Applied notebook changes to Yjs', {
353
+ cells: notebook.cells.length,
354
+ });
355
+ } catch (err) {
356
+ this._log('error', 'Failed to handle notebook change', { error: err.message });
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Apply changes to Yjs document using character-level diff
362
+ */
363
+ _applyToYjs(newContent) {
364
+ const ytext = this.ydoc.getText('content');
365
+ const oldContent = this._lastMdContent || '';
366
+
367
+ if (oldContent === newContent) return;
368
+
369
+ this._isWritingYjs = true;
370
+
371
+ const changes = diffChars(oldContent, newContent);
372
+
373
+ this.ydoc.transact(() => {
374
+ let pos = 0;
375
+ for (const change of changes) {
376
+ if (change.added) {
377
+ ytext.insert(pos, change.value);
378
+ pos += change.value.length;
379
+ } else if (change.removed) {
380
+ ytext.delete(pos, change.value.length);
381
+ } else {
382
+ pos += change.value.length;
383
+ }
384
+ }
385
+ });
386
+
387
+ this._lastMdContent = newContent;
388
+ this._isWritingYjs = false;
389
+ }
390
+
391
+ /**
392
+ * Stop the bridge
393
+ */
394
+ stop() {
395
+ this._log('info', 'Stopping bridge');
396
+
397
+ // Flush any pending write
398
+ clearTimeout(this._writeTimeout);
399
+ if (this._lastMdContent) {
400
+ this._writeIpynb();
401
+ }
402
+
403
+ // Stop file watcher
404
+ if (this.watcher) {
405
+ this.watcher.close();
406
+ this.watcher = null;
407
+ }
408
+
409
+ // Disconnect from sync
410
+ if (this.provider) {
411
+ this.provider.disconnect();
412
+ this.provider = null;
413
+ }
414
+
415
+ this._connected = false;
416
+ this._synced = false;
417
+
418
+ this._log('info', 'Bridge stopped');
419
+ }
420
+
421
+ /**
422
+ * Check if connected and synced
423
+ */
424
+ get isConnected() {
425
+ return this._connected && this._synced;
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Create and start a bridge
431
+ *
432
+ * @param {string} syncUrl
433
+ * @param {string} ipynbPath
434
+ * @param {BridgeOptions} [options]
435
+ * @returns {Promise<JupyterBridge>}
436
+ */
437
+ export async function createBridge(syncUrl, ipynbPath, options = {}) {
438
+ const bridge = new JupyterBridge(syncUrl, ipynbPath, options);
439
+ await bridge.start();
440
+ return bridge;
441
+ }
@@ -0,0 +1,368 @@
1
+ /**
2
+ * Jupyter Notebook <-> MRMD Markdown Converter
3
+ *
4
+ * Handles bidirectional conversion between .ipynb JSON and MRMD markdown format.
5
+ * Preserves outputs using execId-based output blocks.
6
+ */
7
+
8
+ /**
9
+ * Map Jupyter language names to MRMD fence languages
10
+ */
11
+ const LANGUAGE_MAP = {
12
+ python: 'python',
13
+ python3: 'python',
14
+ javascript: 'javascript',
15
+ typescript: 'typescript',
16
+ julia: 'julia',
17
+ r: 'r',
18
+ bash: 'bash',
19
+ sh: 'bash',
20
+ };
21
+
22
+ /**
23
+ * Generate an execution ID from Jupyter's execution_count
24
+ * Format: jupyter-{execution_count} to distinguish from MRMD's exec-{timestamp}-{random}
25
+ *
26
+ * @param {number|null} executionCount
27
+ * @returns {string|null}
28
+ */
29
+ export function executionCountToExecId(executionCount) {
30
+ if (executionCount === null || executionCount === undefined) {
31
+ return null;
32
+ }
33
+ return `jupyter-${executionCount}`;
34
+ }
35
+
36
+ /**
37
+ * Extract execution_count from an execId
38
+ *
39
+ * @param {string|null} execId
40
+ * @returns {number|null}
41
+ */
42
+ export function execIdToExecutionCount(execId) {
43
+ if (!execId) return null;
44
+
45
+ // Handle jupyter-{n} format
46
+ if (execId.startsWith('jupyter-')) {
47
+ const num = parseInt(execId.slice(8), 10);
48
+ return isNaN(num) ? null : num;
49
+ }
50
+
51
+ // Handle MRMD exec-{timestamp}-{random} format - no execution count
52
+ return null;
53
+ }
54
+
55
+ /**
56
+ * Convert Jupyter cell outputs to text for the output block
57
+ *
58
+ * @param {Array} outputs - Jupyter cell outputs
59
+ * @returns {string}
60
+ */
61
+ export function outputsToText(outputs) {
62
+ if (!outputs || outputs.length === 0) return '';
63
+
64
+ const parts = [];
65
+
66
+ for (const output of outputs) {
67
+ switch (output.output_type) {
68
+ case 'stream':
69
+ // stdout/stderr
70
+ parts.push(arrayToString(output.text));
71
+ break;
72
+
73
+ case 'execute_result':
74
+ case 'display_data':
75
+ // Rich output - prefer text/plain, note others
76
+ if (output.data) {
77
+ if (output.data['text/plain']) {
78
+ parts.push(arrayToString(output.data['text/plain']));
79
+ }
80
+ // TODO: Handle images, HTML, etc. with special markers
81
+ if (output.data['image/png']) {
82
+ parts.push('[Image: base64 PNG data]');
83
+ }
84
+ if (output.data['text/html'] && !output.data['text/plain']) {
85
+ parts.push('[HTML output]');
86
+ }
87
+ }
88
+ break;
89
+
90
+ case 'error':
91
+ // Error traceback
92
+ if (output.traceback) {
93
+ parts.push(output.traceback.join('\n'));
94
+ } else {
95
+ parts.push(`${output.ename}: ${output.evalue}`);
96
+ }
97
+ break;
98
+ }
99
+ }
100
+
101
+ return parts.join('');
102
+ }
103
+
104
+ /**
105
+ * Convert text from output block back to Jupyter outputs
106
+ * Note: This is lossy - we can't reconstruct rich outputs from plain text
107
+ *
108
+ * @param {string} text - Output block text
109
+ * @returns {Array}
110
+ */
111
+ export function textToOutputs(text) {
112
+ if (!text || text.trim() === '') return [];
113
+
114
+ // Simple case: treat as stdout stream
115
+ return [{
116
+ output_type: 'stream',
117
+ name: 'stdout',
118
+ text: text.split('\n').map((line, i, arr) =>
119
+ i < arr.length - 1 ? line + '\n' : line
120
+ ).filter(line => line !== ''),
121
+ }];
122
+ }
123
+
124
+ /**
125
+ * Convert Jupyter notebook JSON to MRMD markdown
126
+ *
127
+ * @param {Object} notebook - Parsed .ipynb JSON
128
+ * @returns {string} MRMD markdown content
129
+ */
130
+ export function ipynbToMarkdown(notebook) {
131
+ if (!notebook || !notebook.cells) {
132
+ return '';
133
+ }
134
+
135
+ const parts = [];
136
+
137
+ // Extract language from kernel info
138
+ const language = notebook.metadata?.kernelspec?.language ||
139
+ notebook.metadata?.language_info?.name ||
140
+ 'python';
141
+ const fenceLanguage = LANGUAGE_MAP[language] || language;
142
+
143
+ for (const cell of notebook.cells) {
144
+ if (cell.cell_type === 'markdown') {
145
+ // Markdown cell - output as-is
146
+ parts.push(arrayToString(cell.source));
147
+ } else if (cell.cell_type === 'code') {
148
+ // Code cell with potential output
149
+ const code = arrayToString(cell.source);
150
+ const execId = executionCountToExecId(cell.execution_count);
151
+ const outputText = outputsToText(cell.outputs);
152
+
153
+ // Code block
154
+ parts.push(`\`\`\`${fenceLanguage}\n${code}\n\`\`\``);
155
+
156
+ // Output block (only if there's output or an execution count)
157
+ if (outputText || execId) {
158
+ const outputFence = execId ? `\`\`\`output:${execId}` : '```output';
159
+ parts.push(`${outputFence}\n${outputText}\`\`\``);
160
+ }
161
+ } else if (cell.cell_type === 'raw') {
162
+ // Raw cell - wrap in raw block
163
+ parts.push(`\`\`\`\n${arrayToString(cell.source)}\n\`\`\``);
164
+ }
165
+ }
166
+
167
+ return parts.join('\n\n');
168
+ }
169
+
170
+ /**
171
+ * Convert MRMD markdown to Jupyter notebook JSON
172
+ *
173
+ * @param {string} markdown - MRMD markdown content
174
+ * @param {Object} [existingNotebook] - Existing notebook to preserve metadata from
175
+ * @returns {Object} Jupyter notebook JSON
176
+ */
177
+ export function markdownToIpynb(markdown, existingNotebook = null) {
178
+ const cells = [];
179
+ const lines = markdown.split('\n');
180
+
181
+ let i = 0;
182
+ let currentMarkdown = [];
183
+
184
+ // Preserve metadata from existing notebook or use defaults
185
+ const metadata = existingNotebook?.metadata || {
186
+ kernelspec: {
187
+ display_name: 'Python 3',
188
+ language: 'python',
189
+ name: 'python3',
190
+ },
191
+ language_info: {
192
+ name: 'python',
193
+ version: '3.10.0',
194
+ },
195
+ };
196
+
197
+ const nbformat = existingNotebook?.nbformat || 4;
198
+ const nbformat_minor = existingNotebook?.nbformat_minor || 5;
199
+
200
+ // Flush accumulated markdown as a cell
201
+ const flushMarkdown = () => {
202
+ if (currentMarkdown.length > 0) {
203
+ const text = currentMarkdown.join('\n');
204
+ if (text.trim()) {
205
+ cells.push({
206
+ cell_type: 'markdown',
207
+ metadata: {},
208
+ source: stringToArray(text),
209
+ });
210
+ }
211
+ currentMarkdown = [];
212
+ }
213
+ };
214
+
215
+ while (i < lines.length) {
216
+ const line = lines[i];
217
+
218
+ // Check for code fence
219
+ const fenceMatch = line.match(/^```(\w*)/);
220
+ if (fenceMatch) {
221
+ const language = fenceMatch[1].toLowerCase();
222
+
223
+ // Skip output blocks - they'll be handled with their code cell
224
+ if (language === 'output' || language.startsWith('output:')) {
225
+ // Find the closing fence
226
+ i++;
227
+ while (i < lines.length && !lines[i].match(/^```\s*$/)) {
228
+ i++;
229
+ }
230
+ i++; // Skip closing fence
231
+ continue;
232
+ }
233
+
234
+ // Skip stdin blocks
235
+ if (language.startsWith('stdin:')) {
236
+ i++;
237
+ while (i < lines.length && !lines[i].match(/^```\s*$/)) {
238
+ i++;
239
+ }
240
+ i++;
241
+ continue;
242
+ }
243
+
244
+ // Flush any accumulated markdown
245
+ flushMarkdown();
246
+
247
+ // Parse code block
248
+ const codeLines = [];
249
+ i++;
250
+ while (i < lines.length && !lines[i].match(/^```\s*$/)) {
251
+ codeLines.push(lines[i]);
252
+ i++;
253
+ }
254
+ i++; // Skip closing fence
255
+
256
+ const code = codeLines.join('\n');
257
+
258
+ // Look for following output block
259
+ let outputs = [];
260
+ let executionCount = null;
261
+
262
+ // Skip whitespace
263
+ let lookAhead = i;
264
+ while (lookAhead < lines.length && lines[lookAhead].trim() === '') {
265
+ lookAhead++;
266
+ }
267
+
268
+ // Check for output block
269
+ if (lookAhead < lines.length) {
270
+ const outputMatch = lines[lookAhead].match(/^```output(?::([^\s]*))?/);
271
+ if (outputMatch) {
272
+ const execId = outputMatch[1] || null;
273
+ executionCount = execIdToExecutionCount(execId);
274
+
275
+ // Parse output content
276
+ const outputLines = [];
277
+ lookAhead++;
278
+ while (lookAhead < lines.length && !lines[lookAhead].match(/^```\s*$/)) {
279
+ outputLines.push(lines[lookAhead]);
280
+ lookAhead++;
281
+ }
282
+ lookAhead++; // Skip closing fence
283
+
284
+ const outputText = outputLines.join('\n');
285
+ outputs = textToOutputs(outputText);
286
+
287
+ // Advance main index past the output block
288
+ i = lookAhead;
289
+ }
290
+ }
291
+
292
+ // Create code cell
293
+ cells.push({
294
+ cell_type: 'code',
295
+ execution_count: executionCount,
296
+ metadata: {},
297
+ source: stringToArray(code),
298
+ outputs,
299
+ });
300
+ } else {
301
+ // Regular markdown line
302
+ currentMarkdown.push(line);
303
+ i++;
304
+ }
305
+ }
306
+
307
+ // Flush remaining markdown
308
+ flushMarkdown();
309
+
310
+ return {
311
+ cells,
312
+ metadata,
313
+ nbformat,
314
+ nbformat_minor,
315
+ };
316
+ }
317
+
318
+ /**
319
+ * Convert Jupyter array-or-string to string
320
+ */
321
+ function arrayToString(source) {
322
+ if (Array.isArray(source)) {
323
+ return source.join('');
324
+ }
325
+ return source || '';
326
+ }
327
+
328
+ /**
329
+ * Convert string to Jupyter array format (lines with \n)
330
+ */
331
+ function stringToArray(str) {
332
+ if (!str) return [];
333
+ const lines = str.split('\n');
334
+ return lines.map((line, i) =>
335
+ i < lines.length - 1 ? line + '\n' : line
336
+ ).filter((line, i, arr) =>
337
+ // Keep all lines except trailing empty string
338
+ i < arr.length - 1 || line !== ''
339
+ );
340
+ }
341
+
342
+ /**
343
+ * Parse a .ipynb file content
344
+ *
345
+ * @param {string} content - Raw file content
346
+ * @returns {{notebook: Object|null, error: string|null}}
347
+ */
348
+ export function parseIpynb(content) {
349
+ try {
350
+ const notebook = JSON.parse(content);
351
+ if (!notebook.cells || !Array.isArray(notebook.cells)) {
352
+ return { notebook: null, error: 'Invalid notebook: missing cells array' };
353
+ }
354
+ return { notebook, error: null };
355
+ } catch (err) {
356
+ return { notebook: null, error: `JSON parse error: ${err.message}` };
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Serialize notebook to .ipynb format
362
+ *
363
+ * @param {Object} notebook
364
+ * @returns {string}
365
+ */
366
+ export function serializeIpynb(notebook) {
367
+ return JSON.stringify(notebook, null, 1);
368
+ }
package/src/index.js ADDED
@@ -0,0 +1,17 @@
1
+ /**
2
+ * mrmd-jupyter-bridge
3
+ *
4
+ * Bidirectional sync between Jupyter notebooks and MRMD markdown via Yjs.
5
+ */
6
+
7
+ export { JupyterBridge, createBridge } from './bridge.js';
8
+ export {
9
+ ipynbToMarkdown,
10
+ markdownToIpynb,
11
+ parseIpynb,
12
+ serializeIpynb,
13
+ executionCountToExecId,
14
+ execIdToExecutionCount,
15
+ outputsToText,
16
+ textToOutputs,
17
+ } from './converter.js';
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Quick test for the converter
5
+ */
6
+
7
+ import { readFileSync } from 'fs';
8
+ import {
9
+ ipynbToMarkdown,
10
+ markdownToIpynb,
11
+ parseIpynb,
12
+ serializeIpynb,
13
+ } from './src/converter.js';
14
+
15
+ // Test 1: Load notebook and convert to markdown
16
+ console.log('=== Test 1: ipynb -> markdown ===\n');
17
+
18
+ const ipynbContent = readFileSync('./test-notebook.ipynb', 'utf8');
19
+ const { notebook, error } = parseIpynb(ipynbContent);
20
+
21
+ if (error) {
22
+ console.error('Parse error:', error);
23
+ process.exit(1);
24
+ }
25
+
26
+ console.log(`Loaded notebook with ${notebook.cells.length} cells\n`);
27
+
28
+ const markdown = ipynbToMarkdown(notebook);
29
+ console.log('Generated markdown:\n');
30
+ console.log('---');
31
+ console.log(markdown);
32
+ console.log('---\n');
33
+
34
+ // Test 2: Convert markdown back to notebook
35
+ console.log('=== Test 2: markdown -> ipynb ===\n');
36
+
37
+ const roundTrip = markdownToIpynb(markdown, notebook);
38
+ console.log(`Round-trip notebook has ${roundTrip.cells.length} cells`);
39
+ console.log('\nCell types:');
40
+ roundTrip.cells.forEach((cell, i) => {
41
+ const preview = (Array.isArray(cell.source) ? cell.source.join('') : cell.source).slice(0, 50);
42
+ console.log(` ${i}: ${cell.cell_type} - "${preview}..."`);
43
+ });
44
+
45
+ // Test 3: Verify JSON output
46
+ console.log('\n=== Test 3: Serialize ===\n');
47
+ const json = serializeIpynb(roundTrip);
48
+ console.log(`Output JSON is ${json.length} bytes`);
49
+
50
+ console.log('\n✅ All tests passed!');
@@ -0,0 +1,116 @@
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# Test Notebook\n",
8
+ "\n",
9
+ "This is a test notebook for the Jupyter bridge.\n"
10
+ ]
11
+ },
12
+ {
13
+ "cell_type": "code",
14
+ "execution_count": 1,
15
+ "metadata": {},
16
+ "source": [
17
+ "print(\"Hello from Jupyter!\")"
18
+ ],
19
+ "outputs": [
20
+ {
21
+ "output_type": "stream",
22
+ "name": "stdout",
23
+ "text": [
24
+ "Hello from Jupyter!"
25
+ ]
26
+ }
27
+ ]
28
+ },
29
+ {
30
+ "cell_type": "code",
31
+ "execution_count": 2,
32
+ "metadata": {},
33
+ "source": [
34
+ "x = 42\n",
35
+ "y = 100\n",
36
+ "print(f\"x = {x}\")\n",
37
+ "print(f\"y = {y}\")"
38
+ ],
39
+ "outputs": [
40
+ {
41
+ "output_type": "stream",
42
+ "name": "stdout",
43
+ "text": [
44
+ "x = 42\n",
45
+ "y = 100"
46
+ ]
47
+ }
48
+ ]
49
+ },
50
+ {
51
+ "cell_type": "markdown",
52
+ "metadata": {},
53
+ "source": [
54
+ "\n",
55
+ "## Results\n",
56
+ "\n",
57
+ "The calculation results are shown above.\n"
58
+ ]
59
+ },
60
+ {
61
+ "cell_type": "code",
62
+ "execution_count": 3,
63
+ "metadata": {},
64
+ "source": [
65
+ "# NEW CELL - added externally!\n",
66
+ "result = x + y\n",
67
+ "print(f\"Result: {result}\")"
68
+ ],
69
+ "outputs": [
70
+ {
71
+ "output_type": "stream",
72
+ "name": "stdout",
73
+ "text": [
74
+ "Result: 142"
75
+ ]
76
+ }
77
+ ]
78
+ },
79
+ {
80
+ "cell_type": "markdown",
81
+ "metadata": {},
82
+ "source": [
83
+ "\n",
84
+ "### This section was added from Jupyter!\n",
85
+ "\n",
86
+ "## Added from MRMD!\n",
87
+ "\n",
88
+ "This paragraph was added by editing the markdown directly.\n"
89
+ ]
90
+ },
91
+ {
92
+ "cell_type": "code",
93
+ "execution_count": null,
94
+ "metadata": {},
95
+ "source": [
96
+ "# This cell was added from MRMD markdown!\n",
97
+ "z = result * 2\n",
98
+ "print(f\"Double the result: {z}\")"
99
+ ],
100
+ "outputs": []
101
+ }
102
+ ],
103
+ "metadata": {
104
+ "kernelspec": {
105
+ "display_name": "Python 3",
106
+ "language": "python",
107
+ "name": "python3"
108
+ },
109
+ "language_info": {
110
+ "name": "python",
111
+ "version": "3.10.0"
112
+ }
113
+ },
114
+ "nbformat": 4,
115
+ "nbformat_minor": 5
116
+ }
@@ -0,0 +1,49 @@
1
+ # Test Notebook
2
+
3
+ This is a test notebook for the Jupyter bridge.
4
+
5
+ ```python
6
+ print("Hello from Jupyter!")
7
+ ```
8
+
9
+ ```output:jupyter-1
10
+ Hello from Jupyter!
11
+ ```
12
+
13
+ ```python
14
+ x = 42
15
+ y = 100
16
+ print(f"x = {x}")
17
+ print(f"y = {y}")
18
+ ```
19
+
20
+ ```output:jupyter-2
21
+ x = 42
22
+ y = 100
23
+ ```
24
+
25
+ ## Results
26
+
27
+ The calculation results are shown above.
28
+
29
+ ```python
30
+ # NEW CELL - added externally!
31
+ result = x + y
32
+ print(f"Result: {result}")
33
+ ```
34
+
35
+ ```output:jupyter-3
36
+ Result: 142
37
+ ```
38
+
39
+ ### This section was added from Jupyter!
40
+
41
+ ## Added from MRMD!
42
+
43
+ This paragraph was added by editing the markdown directly.
44
+
45
+ ```python
46
+ # This cell was added from MRMD markdown!
47
+ z = result * 2
48
+ print(f"Double the result: {z}")
49
+ ```