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 +154 -0
- package/demo.ipynb +75 -0
- package/demo.md +28 -0
- package/package.json +37 -0
- package/src/bridge.js +441 -0
- package/src/converter.js +368 -0
- package/src/index.js +17 -0
- package/test-converter.js +50 -0
- package/test-notebook.ipynb +116 -0
- package/test-notebook.md +49 -0
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
|
+
}
|
package/src/converter.js
ADDED
|
@@ -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
|
+
}
|
package/test-notebook.md
ADDED
|
@@ -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
|
+
```
|