mrmd-server 0.1.0 → 0.1.2

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.
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Notebook (Jupyter) API routes
3
+ *
4
+ * Mirrors electronAPI.notebook.*
5
+ */
6
+
7
+ import { Router } from 'express';
8
+ import { spawn } from 'child_process';
9
+ import path from 'path';
10
+ import fs from 'fs/promises';
11
+
12
+ // Active sync processes: ipynbPath -> { process, shadowPath, syncPort }
13
+ const syncProcesses = new Map();
14
+
15
+ /**
16
+ * Create notebook routes
17
+ * @param {import('../server.js').ServerContext} ctx
18
+ */
19
+ export function createNotebookRoutes(ctx) {
20
+ const router = Router();
21
+
22
+ /**
23
+ * POST /api/notebook/convert
24
+ * Convert a Jupyter notebook to markdown (deletes the .ipynb file)
25
+ * Mirrors: electronAPI.notebook.convert(ipynbPath)
26
+ */
27
+ router.post('/convert', async (req, res) => {
28
+ try {
29
+ const { ipynbPath } = req.body;
30
+ if (!ipynbPath) {
31
+ return res.status(400).json({ error: 'ipynbPath required' });
32
+ }
33
+
34
+ const fullPath = path.resolve(ctx.projectDir, ipynbPath);
35
+
36
+ // Verify file exists and is .ipynb
37
+ try {
38
+ await fs.access(fullPath);
39
+ } catch {
40
+ return res.status(404).json({ success: false, error: 'File not found' });
41
+ }
42
+
43
+ if (!fullPath.endsWith('.ipynb')) {
44
+ return res.status(400).json({ success: false, error: 'File must be a .ipynb file' });
45
+ }
46
+
47
+ // Read notebook
48
+ const content = await fs.readFile(fullPath, 'utf-8');
49
+ const notebook = JSON.parse(content);
50
+
51
+ // Convert to markdown
52
+ const markdown = convertNotebookToMarkdown(notebook);
53
+
54
+ // Write markdown file
55
+ const mdPath = fullPath.replace(/\.ipynb$/, '.md');
56
+ await fs.writeFile(mdPath, markdown, 'utf-8');
57
+
58
+ // Delete original .ipynb
59
+ await fs.unlink(fullPath);
60
+
61
+ res.json({
62
+ success: true,
63
+ mdPath: path.relative(ctx.projectDir, mdPath),
64
+ });
65
+ } catch (err) {
66
+ console.error('[notebook:convert]', err);
67
+ res.status(500).json({ success: false, error: err.message });
68
+ }
69
+ });
70
+
71
+ /**
72
+ * POST /api/notebook/start-sync
73
+ * Start syncing a notebook (creates shadow .md in .mrmd folder)
74
+ * Mirrors: electronAPI.notebook.startSync(ipynbPath)
75
+ */
76
+ router.post('/start-sync', async (req, res) => {
77
+ try {
78
+ const { ipynbPath } = req.body;
79
+ if (!ipynbPath) {
80
+ return res.status(400).json({ error: 'ipynbPath required' });
81
+ }
82
+
83
+ const fullPath = path.resolve(ctx.projectDir, ipynbPath);
84
+
85
+ // Check if already syncing
86
+ if (syncProcesses.has(fullPath)) {
87
+ const existing = syncProcesses.get(fullPath);
88
+ return res.json({
89
+ success: true,
90
+ shadowPath: existing.shadowPath,
91
+ syncPort: existing.syncPort,
92
+ reused: true,
93
+ });
94
+ }
95
+
96
+ // Verify file exists
97
+ try {
98
+ await fs.access(fullPath);
99
+ } catch {
100
+ return res.status(404).json({ success: false, error: 'File not found' });
101
+ }
102
+
103
+ // Create .mrmd directory if needed
104
+ const mrmdDir = path.join(path.dirname(fullPath), '.mrmd');
105
+ await fs.mkdir(mrmdDir, { recursive: true });
106
+
107
+ // Create shadow markdown file
108
+ const baseName = path.basename(fullPath, '.ipynb');
109
+ const shadowPath = path.join(mrmdDir, `${baseName}.shadow.md`);
110
+
111
+ // Read and convert notebook
112
+ const content = await fs.readFile(fullPath, 'utf-8');
113
+ const notebook = JSON.parse(content);
114
+ const markdown = convertNotebookToMarkdown(notebook);
115
+ await fs.writeFile(shadowPath, markdown, 'utf-8');
116
+
117
+ // Find mrmd-jupyter-bridge
118
+ const bridgePaths = [
119
+ path.join(ctx.projectDir, '../mrmd-jupyter-bridge'),
120
+ path.join(process.cwd(), '../mrmd-jupyter-bridge'),
121
+ path.join(process.cwd(), 'mrmd-jupyter-bridge'),
122
+ ];
123
+
124
+ let bridgePath = null;
125
+ for (const p of bridgePaths) {
126
+ try {
127
+ await fs.access(path.join(p, 'package.json'));
128
+ bridgePath = p;
129
+ break;
130
+ } catch {}
131
+ }
132
+
133
+ let syncPort = null;
134
+ let proc = null;
135
+
136
+ if (bridgePath) {
137
+ // Start sync process
138
+ syncPort = 4450 + syncProcesses.size;
139
+
140
+ proc = spawn('node', [
141
+ path.join(bridgePath, 'src', 'sync.js'),
142
+ '--notebook', fullPath,
143
+ '--shadow', shadowPath,
144
+ '--port', syncPort.toString(),
145
+ ], {
146
+ cwd: path.dirname(fullPath),
147
+ stdio: ['pipe', 'pipe', 'pipe'],
148
+ });
149
+
150
+ proc.on('exit', () => {
151
+ syncProcesses.delete(fullPath);
152
+ });
153
+ }
154
+
155
+ syncProcesses.set(fullPath, {
156
+ process: proc,
157
+ shadowPath: path.relative(ctx.projectDir, shadowPath),
158
+ syncPort,
159
+ });
160
+
161
+ res.json({
162
+ success: true,
163
+ shadowPath: path.relative(ctx.projectDir, shadowPath),
164
+ syncPort,
165
+ });
166
+ } catch (err) {
167
+ console.error('[notebook:startSync]', err);
168
+ res.status(500).json({ success: false, error: err.message });
169
+ }
170
+ });
171
+
172
+ /**
173
+ * POST /api/notebook/stop-sync
174
+ * Stop syncing a notebook
175
+ * Mirrors: electronAPI.notebook.stopSync(ipynbPath)
176
+ */
177
+ router.post('/stop-sync', async (req, res) => {
178
+ try {
179
+ const { ipynbPath } = req.body;
180
+ if (!ipynbPath) {
181
+ return res.status(400).json({ error: 'ipynbPath required' });
182
+ }
183
+
184
+ const fullPath = path.resolve(ctx.projectDir, ipynbPath);
185
+ const sync = syncProcesses.get(fullPath);
186
+
187
+ if (sync) {
188
+ if (sync.process && !sync.process.killed) {
189
+ sync.process.kill();
190
+ }
191
+ syncProcesses.delete(fullPath);
192
+ }
193
+
194
+ res.json({ success: true });
195
+ } catch (err) {
196
+ console.error('[notebook:stopSync]', err);
197
+ res.status(500).json({ success: false, error: err.message });
198
+ }
199
+ });
200
+
201
+ return router;
202
+ }
203
+
204
+ /**
205
+ * Convert Jupyter notebook to markdown
206
+ */
207
+ function convertNotebookToMarkdown(notebook) {
208
+ const cells = notebook.cells || [];
209
+ const lines = [];
210
+
211
+ // Add frontmatter if notebook has metadata
212
+ if (notebook.metadata?.kernelspec?.language) {
213
+ lines.push('---');
214
+ lines.push(`language: ${notebook.metadata.kernelspec.language}`);
215
+ if (notebook.metadata.kernelspec.display_name) {
216
+ lines.push(`kernel: ${notebook.metadata.kernelspec.display_name}`);
217
+ }
218
+ lines.push('---');
219
+ lines.push('');
220
+ }
221
+
222
+ for (const cell of cells) {
223
+ const source = Array.isArray(cell.source)
224
+ ? cell.source.join('')
225
+ : cell.source || '';
226
+
227
+ if (cell.cell_type === 'markdown') {
228
+ lines.push(source.trim());
229
+ lines.push('');
230
+ } else if (cell.cell_type === 'code') {
231
+ // Determine language
232
+ const lang = notebook.metadata?.kernelspec?.language || 'python';
233
+
234
+ lines.push('```' + lang);
235
+ lines.push(source.trim());
236
+ lines.push('```');
237
+ lines.push('');
238
+
239
+ // Add outputs if present
240
+ if (cell.outputs && cell.outputs.length > 0) {
241
+ for (const output of cell.outputs) {
242
+ if (output.output_type === 'stream') {
243
+ const text = Array.isArray(output.text)
244
+ ? output.text.join('')
245
+ : output.text || '';
246
+ if (text.trim()) {
247
+ lines.push('```output');
248
+ lines.push(text.trim());
249
+ lines.push('```');
250
+ lines.push('');
251
+ }
252
+ } else if (output.output_type === 'execute_result' || output.output_type === 'display_data') {
253
+ const data = output.data || {};
254
+ if (data['text/plain']) {
255
+ const text = Array.isArray(data['text/plain'])
256
+ ? data['text/plain'].join('')
257
+ : data['text/plain'];
258
+ lines.push('```output');
259
+ lines.push(text.trim());
260
+ lines.push('```');
261
+ lines.push('');
262
+ }
263
+ // Handle images
264
+ if (data['image/png']) {
265
+ lines.push(`![output](data:image/png;base64,${data['image/png']})`);
266
+ lines.push('');
267
+ }
268
+ } else if (output.output_type === 'error') {
269
+ const traceback = output.traceback || [];
270
+ // Strip ANSI codes
271
+ const cleanTraceback = traceback
272
+ .map(line => line.replace(/\x1b\[[0-9;]*m/g, ''))
273
+ .join('\n');
274
+ lines.push('```error');
275
+ lines.push(cleanTraceback.trim());
276
+ lines.push('```');
277
+ lines.push('');
278
+ }
279
+ }
280
+ }
281
+ } else if (cell.cell_type === 'raw') {
282
+ lines.push('```');
283
+ lines.push(source.trim());
284
+ lines.push('```');
285
+ lines.push('');
286
+ }
287
+ }
288
+
289
+ return lines.join('\n');
290
+ }
@@ -1,13 +1,38 @@
1
1
  /**
2
2
  * Project API routes
3
3
  *
4
- * Mirrors electronAPI.project.*
4
+ * Mirrors electronAPI.project.* using ProjectService from mrmd-electron
5
+ * and dynamic sync server management.
5
6
  */
6
7
 
7
8
  import { Router } from 'express';
8
9
  import path from 'path';
9
10
  import fs from 'fs/promises';
11
+ import { existsSync, readFileSync } from 'fs';
10
12
  import { watch } from 'chokidar';
13
+ import { Project } from 'mrmd-project';
14
+
15
+ /**
16
+ * Detect project from a file path
17
+ * Returns { root, config } or null if not in a project
18
+ */
19
+ function detectProject(filePath) {
20
+ // Use mrmd-project's findRoot to locate project root
21
+ const root = Project.findRoot(filePath, (dir) => existsSync(path.join(dir, 'mrmd.md')));
22
+
23
+ if (!root) return null;
24
+
25
+ // Read and parse mrmd.md config
26
+ try {
27
+ const mrmdPath = path.join(root, 'mrmd.md');
28
+ const content = readFileSync(mrmdPath, 'utf8');
29
+ const config = Project.parseConfig(content);
30
+ return { root, config };
31
+ } catch (e) {
32
+ // mrmd.md exists but couldn't be read/parsed
33
+ return { root, config: {} };
34
+ }
35
+ }
11
36
 
12
37
  /**
13
38
  * Create project routes
@@ -15,11 +40,14 @@ import { watch } from 'chokidar';
15
40
  */
16
41
  export function createProjectRoutes(ctx) {
17
42
  const router = Router();
43
+ const { projectService, acquireSyncServer, releaseSyncServer, getSyncServer, listSyncServers } = ctx;
18
44
 
19
45
  /**
20
46
  * GET /api/project?path=...
21
47
  * Get project info for a file path
22
48
  * Mirrors: electronAPI.project.get(filePath)
49
+ *
50
+ * Enhanced: Now uses mrmd-project for detection and starts sync server dynamically
23
51
  */
24
52
  router.get('/', async (req, res) => {
25
53
  try {
@@ -28,14 +56,91 @@ export function createProjectRoutes(ctx) {
28
56
  return res.status(400).json({ error: 'path query parameter required' });
29
57
  }
30
58
 
31
- const projectInfo = await getProjectInfo(filePath, ctx.projectDir);
32
- res.json(projectInfo);
59
+ // Detect project from file path
60
+ const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(ctx.projectDir || process.cwd(), filePath);
61
+ const detected = detectProject(resolvedPath);
62
+
63
+ if (detected) {
64
+ // Get or start sync server for this project
65
+ let syncInfo = null;
66
+ try {
67
+ syncInfo = await acquireSyncServer(detected.root);
68
+ } catch (e) {
69
+ console.warn('[project] Could not start sync server:', e.message);
70
+ }
71
+
72
+ // Build nav tree
73
+ const navTree = await buildNavTree(detected.root);
74
+
75
+ res.json({
76
+ root: detected.root,
77
+ config: detected.config,
78
+ navTree,
79
+ files: flattenNavTree(navTree, detected.root),
80
+ currentFile: resolvedPath,
81
+ syncPort: syncInfo?.port || null,
82
+ });
83
+ } else {
84
+ // No project detected, use fallback
85
+ const projectInfo = await getProjectInfo(filePath, ctx.projectDir || process.cwd());
86
+ res.json(projectInfo);
87
+ }
33
88
  } catch (err) {
34
89
  console.error('[project:get]', err);
35
90
  res.status(500).json({ error: err.message });
36
91
  }
37
92
  });
38
93
 
94
+ /**
95
+ * GET /api/project/sync
96
+ * List all active sync servers
97
+ */
98
+ router.get('/sync', async (req, res) => {
99
+ try {
100
+ const servers = listSyncServers();
101
+ res.json(servers);
102
+ } catch (err) {
103
+ console.error('[project:sync]', err);
104
+ res.status(500).json({ error: err.message });
105
+ }
106
+ });
107
+
108
+ /**
109
+ * POST /api/project/sync/acquire
110
+ * Acquire sync server for a project directory
111
+ */
112
+ router.post('/sync/acquire', async (req, res) => {
113
+ try {
114
+ const { projectDir } = req.body;
115
+ if (!projectDir) {
116
+ return res.status(400).json({ error: 'projectDir required' });
117
+ }
118
+ const server = await acquireSyncServer(projectDir);
119
+ res.json({ port: server.port, dir: server.dir, refCount: server.refCount });
120
+ } catch (err) {
121
+ console.error('[project:sync/acquire]', err);
122
+ res.status(500).json({ error: err.message });
123
+ }
124
+ });
125
+
126
+ /**
127
+ * POST /api/project/sync/release
128
+ * Release sync server for a project directory
129
+ */
130
+ router.post('/sync/release', async (req, res) => {
131
+ try {
132
+ const { projectDir } = req.body;
133
+ if (!projectDir) {
134
+ return res.status(400).json({ error: 'projectDir required' });
135
+ }
136
+ releaseSyncServer(projectDir);
137
+ res.json({ success: true });
138
+ } catch (err) {
139
+ console.error('[project:sync/release]', err);
140
+ res.status(500).json({ error: err.message });
141
+ }
142
+ });
143
+
39
144
  /**
40
145
  * POST /api/project
41
146
  * Create a new mrmd project
@@ -198,28 +303,89 @@ async function getProjectInfo(filePath, defaultRoot) {
198
303
  // Build nav tree
199
304
  const navTree = await buildNavTree(projectRoot);
200
305
 
306
+ // Collect all files from nav tree (flattened)
307
+ const files = flattenNavTree(navTree, projectRoot);
308
+
201
309
  return {
202
310
  root: projectRoot,
203
311
  config: mrmdConfig,
204
312
  navTree,
313
+ files,
205
314
  currentFile: resolvedPath,
206
315
  };
207
316
  }
208
317
 
209
318
  /**
210
- * Parse mrmd.md config (frontmatter)
319
+ * Flatten nav tree to list of file paths
320
+ */
321
+ function flattenNavTree(nodes, projectRoot) {
322
+ const files = [];
323
+ for (const node of nodes) {
324
+ if (!node.isFolder) {
325
+ files.push(path.join(projectRoot, node.path));
326
+ } else if (node.children) {
327
+ files.push(...flattenNavTree(node.children, projectRoot));
328
+ }
329
+ }
330
+ return files;
331
+ }
332
+
333
+ /**
334
+ * Parse mrmd.md config (from yaml config code blocks)
211
335
  */
212
336
  function parseMrmdConfig(content) {
213
337
  const config = { venv: '.venv' };
214
338
 
215
- // Simple YAML frontmatter parsing
216
- const match = content.match(/^---\n([\s\S]*?)\n---/);
217
- if (match) {
339
+ // Find all ```yaml config blocks and merge them
340
+ const configBlockRegex = /```yaml\s+config\n([\s\S]*?)```/g;
341
+ let match;
342
+
343
+ while ((match = configBlockRegex.exec(content)) !== null) {
218
344
  const yaml = match[1];
219
- const venvMatch = yaml.match(/venv:\s*(.+)/);
345
+
346
+ // Parse name
347
+ const nameMatch = yaml.match(/^name:\s*["']?([^"'\n]+)["']?/m);
348
+ if (nameMatch) {
349
+ config.name = nameMatch[1].trim();
350
+ }
351
+
352
+ // Parse venv (direct or under session.python)
353
+ const venvMatch = yaml.match(/^\s*venv:\s*["']?([^"'\n]+)["']?/m);
220
354
  if (venvMatch) {
221
355
  config.venv = venvMatch[1].trim();
222
356
  }
357
+
358
+ // Parse session config
359
+ const sessionMatch = yaml.match(/^session:\n([\s\S]*?)(?=^[^\s]|\Z)/m);
360
+ if (sessionMatch) {
361
+ config.session = {};
362
+ const sessionYaml = sessionMatch[1];
363
+
364
+ // Parse python session
365
+ const pythonMatch = sessionYaml.match(/^\s+python:\n([\s\S]*?)(?=^\s+\w+:|\Z)/m);
366
+ if (pythonMatch) {
367
+ config.session.python = {};
368
+ const pyYaml = pythonMatch[1];
369
+ const pyVenv = pyYaml.match(/venv:\s*["']?([^"'\n]+)["']?/);
370
+ if (pyVenv) config.session.python.venv = pyVenv[1].trim();
371
+ const pyCwd = pyYaml.match(/cwd:\s*["']?([^"'\n]+)["']?/);
372
+ if (pyCwd) config.session.python.cwd = pyCwd[1].trim();
373
+ const pyName = pyYaml.match(/name:\s*["']?([^"'\n]+)["']?/);
374
+ if (pyName) config.session.python.name = pyName[1].trim();
375
+ const pyAutoStart = pyYaml.match(/auto_start:\s*(true|false)/);
376
+ if (pyAutoStart) config.session.python.auto_start = pyAutoStart[1] === 'true';
377
+ }
378
+ }
379
+ }
380
+
381
+ // Also check YAML frontmatter for backwards compatibility
382
+ const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
383
+ if (fmMatch) {
384
+ const yaml = fmMatch[1];
385
+ const venvMatch = yaml.match(/venv:\s*(.+)/);
386
+ if (venvMatch && !config.venv) {
387
+ config.venv = venvMatch[1].trim();
388
+ }
223
389
  }
224
390
 
225
391
  return config;
@@ -258,16 +424,16 @@ async function buildNavTree(projectRoot, relativePath = '') {
258
424
  // Only include directories that have .md files (directly or nested)
259
425
  if (children.length > 0 || await hasIndexFile(path.join(projectRoot, entryRelPath))) {
260
426
  nodes.push({
261
- type: 'folder',
262
- name: cleanName(entry.name),
427
+ isFolder: true,
428
+ title: cleanName(entry.name),
263
429
  path: entryRelPath,
264
430
  children,
265
431
  });
266
432
  }
267
433
  } else if (entry.name.endsWith('.md') && entry.name !== 'mrmd.md') {
268
434
  nodes.push({
269
- type: 'file',
270
- name: cleanName(entry.name.replace(/\.md$/, '')),
435
+ isFolder: false,
436
+ title: cleanName(entry.name.replace(/\.md$/, '')),
271
437
  path: entryRelPath,
272
438
  });
273
439
  }