mrmd-server 0.2.4 → 0.2.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mrmd-server",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "HTTP server for mrmd - run mrmd in any browser, access from anywhere",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/api/file.js CHANGED
@@ -325,6 +325,143 @@ export function createFileRoutes(ctx) {
325
325
  }
326
326
  });
327
327
 
328
+ /**
329
+ * GET /api/browse?path=...&type=all|dir|file&show_hidden=true
330
+ * Browse the filesystem for the file picker.
331
+ * Returns { path, parent, entries: [{name, path, type, size?, modified?}] }
332
+ */
333
+ router.get('/browse', async (req, res) => {
334
+ try {
335
+ const os = await import('os');
336
+ const fs = await import('fs/promises');
337
+
338
+ let browsePath = req.query.path || '~';
339
+ if (browsePath === '~') {
340
+ browsePath = ctx.projectDir || os.default.homedir();
341
+ }
342
+
343
+ const resolvedPath = path.resolve(browsePath);
344
+ const typeFilter = req.query.type || 'all'; // 'all', 'dir', 'file'
345
+ const showHidden = req.query.show_hidden === 'true';
346
+
347
+ let dirEntries;
348
+ try {
349
+ dirEntries = await fs.readdir(resolvedPath, { withFileTypes: true });
350
+ } catch (err) {
351
+ if (err.code === 'ENOENT') {
352
+ return res.status(404).json({ error: 'Directory not found', path: resolvedPath });
353
+ }
354
+ if (err.code === 'EACCES') {
355
+ return res.status(403).json({ error: 'Permission denied', path: resolvedPath });
356
+ }
357
+ throw err;
358
+ }
359
+
360
+ const entries = [];
361
+ for (const entry of dirEntries) {
362
+ // Skip hidden files unless requested
363
+ if (!showHidden && entry.name.startsWith('.')) continue;
364
+ // Skip common uninteresting directories
365
+ if (entry.name === 'node_modules' || entry.name === '__pycache__' || entry.name === '.git') continue;
366
+
367
+ const isDir = entry.isDirectory();
368
+ const isFile = entry.isFile();
369
+ if (!isDir && !isFile) continue;
370
+ if (typeFilter === 'dir' && !isDir) continue;
371
+ if (typeFilter === 'file' && !isFile) continue;
372
+
373
+ const entryPath = path.join(resolvedPath, entry.name);
374
+ const item = {
375
+ name: entry.name,
376
+ path: entryPath,
377
+ type: isDir ? 'directory' : 'file',
378
+ };
379
+
380
+ // Add file metadata (best-effort, don't fail if stat errors)
381
+ if (isFile) {
382
+ try {
383
+ const stat = await fs.stat(entryPath);
384
+ item.size = stat.size;
385
+ item.modified = stat.mtime.toISOString();
386
+ } catch { /* ignore stat errors */ }
387
+ }
388
+
389
+ entries.push(item);
390
+ }
391
+
392
+ // Sort: directories first, then alphabetical
393
+ entries.sort((a, b) => {
394
+ if (a.type === 'directory' && b.type !== 'directory') return -1;
395
+ if (a.type !== 'directory' && b.type === 'directory') return 1;
396
+ return a.name.localeCompare(b.name);
397
+ });
398
+
399
+ const parent = path.dirname(resolvedPath);
400
+ res.json({
401
+ path: resolvedPath,
402
+ parent: parent !== resolvedPath ? parent : null,
403
+ entries,
404
+ });
405
+ } catch (err) {
406
+ console.error('[file:browse]', err);
407
+ res.status(500).json({ error: err.message });
408
+ }
409
+ });
410
+
411
+ /**
412
+ * POST /api/file/write-bytes
413
+ * Write raw bytes to a file (for drag-drop uploads from browser).
414
+ * Body: { filePath, bytes: number[] }
415
+ */
416
+ router.post('/write-bytes', async (req, res) => {
417
+ try {
418
+ const { filePath, bytes } = req.body;
419
+ if (!filePath || !bytes) {
420
+ return res.status(400).json({ error: 'filePath and bytes required' });
421
+ }
422
+ const fullPath = resolvePath(ctx.projectDir, filePath);
423
+ await fsPromises.mkdir(path.dirname(fullPath), { recursive: true });
424
+ await fsPromises.writeFile(fullPath, Buffer.from(bytes));
425
+
426
+ ctx.eventBus.projectChanged(ctx.projectDir);
427
+ res.json({ success: true, path: fullPath });
428
+ } catch (err) {
429
+ console.error('[file:write-bytes]', err);
430
+ res.status(500).json({ error: err.message });
431
+ }
432
+ });
433
+
434
+ /**
435
+ * POST /api/file/copy
436
+ * Copy a file or directory.
437
+ * Body: { fromPath, toPath }
438
+ */
439
+ router.post('/copy', async (req, res) => {
440
+ try {
441
+ const { fromPath, toPath } = req.body;
442
+ if (!fromPath || !toPath) {
443
+ return res.status(400).json({ error: 'fromPath and toPath required' });
444
+ }
445
+
446
+ const fullFrom = resolvePath(ctx.projectDir, fromPath);
447
+ const fullTo = resolvePath(ctx.projectDir, toPath);
448
+ const stat = await fsPromises.stat(fullFrom);
449
+
450
+ if (stat.isDirectory()) {
451
+ await fsPromises.cp(fullFrom, fullTo, { recursive: true });
452
+ } else {
453
+ await fsPromises.mkdir(path.dirname(fullTo), { recursive: true });
454
+ await fsPromises.copyFile(fullFrom, fullTo);
455
+ }
456
+
457
+ ctx.eventBus.projectChanged(ctx.projectDir);
458
+ res.json({ success: true, path: fullTo });
459
+ } catch (err) {
460
+ console.error('[file:copy]', err);
461
+ res.status(500).json({ error: err.message });
462
+ }
463
+ });
464
+
328
465
  return router;
329
466
  }
330
467
 
package/src/api/index.js CHANGED
@@ -9,3 +9,4 @@ export { createSystemRoutes } from './system.js';
9
9
  export { createRuntimeRoutes } from './runtime.js';
10
10
  export { createNotebookRoutes } from './notebook.js';
11
11
  export { createSettingsRoutes } from './settings.js';
12
+ export { createVoiceRoutes } from './voice.js';
@@ -0,0 +1,119 @@
1
+ /**
2
+ * LanguageTool API routes
3
+ *
4
+ * Mirrors electronAPI.languagetool.*
5
+ */
6
+
7
+ import { Router } from 'express';
8
+
9
+ export function createLanguageToolRoutes(ctx) {
10
+ const router = Router();
11
+ const { languageToolService, languageToolPreferencesService } = ctx;
12
+
13
+ router.get('/status', async (req, res) => {
14
+ try {
15
+ res.json(await languageToolService.status());
16
+ } catch (err) {
17
+ console.error('[languagetool:status]', err);
18
+ res.status(500).json({ error: err.message });
19
+ }
20
+ });
21
+
22
+ router.get('/languages', async (req, res) => {
23
+ try {
24
+ res.json(await languageToolService.languages());
25
+ } catch (err) {
26
+ console.error('[languagetool:languages]', err);
27
+ res.status(500).json({ error: err.message });
28
+ }
29
+ });
30
+
31
+ router.post('/check', async (req, res) => {
32
+ try {
33
+ res.json(await languageToolService.check(req.body || {}));
34
+ } catch (err) {
35
+ console.error('[languagetool:check]', err);
36
+ res.status(500).json({ error: err.message });
37
+ }
38
+ });
39
+
40
+ router.get('/prefs', async (req, res) => {
41
+ try {
42
+ const { documentPath, projectRoot } = req.query;
43
+ res.json(await languageToolPreferencesService.getForDocument({ documentPath, projectRoot }));
44
+ } catch (err) {
45
+ console.error('[languagetool:getPrefs]', err);
46
+ res.status(500).json({ error: err.message });
47
+ }
48
+ });
49
+
50
+ router.post('/prefs', async (req, res) => {
51
+ try {
52
+ const { documentPath, patch = {}, projectRoot = null } = req.body || {};
53
+ res.json(await languageToolPreferencesService.setForDocument({ documentPath, patch, projectRoot }));
54
+ } catch (err) {
55
+ console.error('[languagetool:setPrefs]', err);
56
+ res.status(500).json({ error: err.message });
57
+ }
58
+ });
59
+
60
+ router.post('/prefs/clear', async (req, res) => {
61
+ try {
62
+ const { documentPath, projectRoot = null } = req.body || {};
63
+ res.json(await languageToolPreferencesService.clearDocumentOverrides({ documentPath, projectRoot }));
64
+ } catch (err) {
65
+ console.error('[languagetool:clearPrefs]', err);
66
+ res.status(500).json({ error: err.message });
67
+ }
68
+ });
69
+
70
+ router.get('/dictionary', (req, res) => {
71
+ try {
72
+ res.json(languageToolPreferencesService.getDictionary());
73
+ } catch (err) {
74
+ console.error('[languagetool:getDictionary]', err);
75
+ res.status(500).json({ error: err.message });
76
+ }
77
+ });
78
+
79
+ router.post('/dictionary/add', (req, res) => {
80
+ try {
81
+ const { word } = req.body || {};
82
+ res.json(languageToolPreferencesService.addToDictionary(word));
83
+ } catch (err) {
84
+ console.error('[languagetool:addToDictionary]', err);
85
+ res.status(500).json({ error: err.message });
86
+ }
87
+ });
88
+
89
+ router.post('/dictionary/remove', (req, res) => {
90
+ try {
91
+ const { word } = req.body || {};
92
+ res.json(languageToolPreferencesService.removeFromDictionary(word));
93
+ } catch (err) {
94
+ console.error('[languagetool:removeFromDictionary]', err);
95
+ res.status(500).json({ error: err.message });
96
+ }
97
+ });
98
+
99
+ router.get('/defaults', (req, res) => {
100
+ try {
101
+ res.json(languageToolPreferencesService.getDefaults());
102
+ } catch (err) {
103
+ console.error('[languagetool:getDefaults]', err);
104
+ res.status(500).json({ error: err.message });
105
+ }
106
+ });
107
+
108
+ router.post('/defaults', (req, res) => {
109
+ try {
110
+ const { patch = {} } = req.body || {};
111
+ res.json(languageToolPreferencesService.setDefaults(patch));
112
+ } catch (err) {
113
+ console.error('[languagetool:setDefaults]', err);
114
+ res.status(500).json({ error: err.message });
115
+ }
116
+ });
117
+
118
+ return router;
119
+ }
@@ -8,9 +8,8 @@
8
8
  import { Router } from 'express';
9
9
  import path from 'path';
10
10
  import fs from 'fs/promises';
11
- import { existsSync, readFileSync } from 'fs';
11
+ import { existsSync } from 'fs';
12
12
  import { watch } from 'chokidar';
13
- import { Project } from 'mrmd-project';
14
13
 
15
14
  const DOC_EXTENSIONS = ['.md', '.qmd'];
16
15
 
@@ -30,25 +29,61 @@ function stripDocExtension(fileName) {
30
29
  }
31
30
 
32
31
  /**
33
- * Detect project from a file path
34
- * Returns { root, config } or null if not in a project
32
+ * Resolve project root from a file path without requiring mrmd.md.
35
33
  */
36
- function detectProject(filePath) {
37
- // Use mrmd-project's findRoot to locate project root
38
- const root = Project.findRoot(filePath, (dir) => existsSync(path.join(dir, 'mrmd.md')));
34
+ function findGitRoot(startDir) {
35
+ let current = path.resolve(startDir || '/');
36
+ while (true) {
37
+ if (existsSync(path.join(current, '.git'))) return current;
38
+ const parent = path.dirname(current);
39
+ if (!parent || parent === current) break;
40
+ current = parent;
41
+ }
42
+ return null;
43
+ }
39
44
 
40
- if (!root) return null;
45
+ async function hasDocsInDir(dir) {
46
+ try {
47
+ const entries = await fs.readdir(dir, { withFileTypes: true });
48
+ return entries.some((entry) => entry.isFile() && isDocFile(entry.name));
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
41
53
 
42
- // Read and parse mrmd.md config
54
+ async function detectProject(filePath) {
55
+ const abs = path.resolve(filePath);
56
+ let startDir = abs;
43
57
  try {
44
- const mrmdPath = path.join(root, 'mrmd.md');
45
- const content = readFileSync(mrmdPath, 'utf8');
46
- const config = Project.parseConfig(content);
47
- return { root, config };
48
- } catch (e) {
49
- // mrmd.md exists but couldn't be read/parsed
50
- return { root, config: {} };
58
+ const stat = await fs.stat(abs);
59
+ if (stat.isFile()) startDir = path.dirname(abs);
60
+ } catch {
61
+ if (path.extname(abs)) startDir = path.dirname(abs);
62
+ }
63
+
64
+ const gitRoot = findGitRoot(startDir);
65
+ if (gitRoot) {
66
+ return { root: gitRoot, config: { name: path.basename(gitRoot) || 'Project' } };
51
67
  }
68
+
69
+ let candidate = startDir;
70
+ let current = startDir;
71
+ const homeDir = path.resolve(process.env.HOME || '/');
72
+ for (let i = 0; i < 4; i++) {
73
+ const parent = path.dirname(current);
74
+ if (!parent || parent === current || parent === '/' || parent === homeDir) break;
75
+ if (await hasDocsInDir(parent)) {
76
+ candidate = parent;
77
+ current = parent;
78
+ continue;
79
+ }
80
+ break;
81
+ }
82
+
83
+ return {
84
+ root: candidate,
85
+ config: { name: path.basename(candidate) || 'Project' },
86
+ };
52
87
  }
53
88
 
54
89
  /**
@@ -75,7 +110,7 @@ export function createProjectRoutes(ctx) {
75
110
 
76
111
  // Detect project from file path
77
112
  const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(ctx.projectDir || process.cwd(), filePath);
78
- const detected = detectProject(resolvedPath);
113
+ const detected = await detectProject(resolvedPath);
79
114
 
80
115
  if (detected) {
81
116
  // Get or start sync server for this project
@@ -164,7 +199,7 @@ export function createProjectRoutes(ctx) {
164
199
  * Mirrors: electronAPI.project.create(targetPath)
165
200
  *
166
201
  * Uses ProjectService.createProject() which:
167
- * 1. Creates scaffold files (mrmd.md, index files)
202
+ * 1. Creates scaffold files (index + assets)
168
203
  * 2. Creates venv
169
204
  * 3. Installs mrmd-python
170
205
  */
@@ -202,6 +237,34 @@ export function createProjectRoutes(ctx) {
202
237
  }
203
238
  });
204
239
 
240
+ /**
241
+ * GET /api/project/raw-tree?root=...&showSystem=true&maxDepth=...
242
+ * Get raw file tree (all files, actual filenames) for file browser view.
243
+ * Mirrors: electronAPI.project.rawTree(root, showSystem, maxDepth)
244
+ */
245
+ router.get('/raw-tree', async (req, res) => {
246
+ try {
247
+ const root = req.query.root || ctx.projectDir;
248
+ const showSystem = req.query.showSystem === 'true';
249
+ const maxDepth = Number.isFinite(Number(req.query.maxDepth)) ? Number(req.query.maxDepth) : undefined;
250
+
251
+ if (!root) {
252
+ return res.status(400).json({ error: 'root query parameter required' });
253
+ }
254
+
255
+ // Use shared ProjectService implementation so Electron + server stay aligned.
256
+ const tree = await projectService.getRawTree(root, {
257
+ showSystem,
258
+ ...(Number.isInteger(maxDepth) && maxDepth >= 0 ? { maxDepth } : {}),
259
+ });
260
+
261
+ res.json(tree);
262
+ } catch (err) {
263
+ console.error('[project:raw-tree]', err);
264
+ res.status(500).json({ error: err.message });
265
+ }
266
+ });
267
+
205
268
  /**
206
269
  * POST /api/project/invalidate
207
270
  * Invalidate cached project info
@@ -210,9 +273,11 @@ export function createProjectRoutes(ctx) {
210
273
  router.post('/invalidate', async (req, res) => {
211
274
  try {
212
275
  const { projectRoot } = req.body;
213
- // In this implementation we don't cache, so this is a no-op
214
- // but we emit an event so the UI can refresh
215
- ctx.eventBus.projectChanged(projectRoot || ctx.projectDir);
276
+ const root = projectRoot || ctx.projectDir;
277
+ if (root) {
278
+ projectService.invalidate(root);
279
+ }
280
+ ctx.eventBus.projectChanged(root);
216
281
  res.json({ success: true });
217
282
  } catch (err) {
218
283
  console.error('[project:invalidate]', err);
@@ -243,7 +308,9 @@ export function createProjectRoutes(ctx) {
243
308
  });
244
309
 
245
310
  watcher.on('all', (event, filePath) => {
246
- if (isDocFile(filePath)) {
311
+ const isDirectoryEvent = !path.extname(filePath || '');
312
+ if (isDocFile(filePath) || isDirectoryEvent) {
313
+ projectService.invalidate(watchPath);
247
314
  ctx.eventBus.projectChanged(watchPath);
248
315
  }
249
316
  });
@@ -283,29 +350,9 @@ export function createProjectRoutes(ctx) {
283
350
  */
284
351
  async function getProjectInfo(filePath, defaultRoot) {
285
352
  const resolvedPath = path.resolve(defaultRoot, filePath);
286
-
287
- // Find project root by walking up to find mrmd.md
288
- let projectRoot = path.dirname(resolvedPath);
289
- let mrmdConfig = null;
290
-
291
- for (let i = 0; i < 10; i++) {
292
- const mrmdPath = path.join(projectRoot, 'mrmd.md');
293
- try {
294
- const content = await fs.readFile(mrmdPath, 'utf-8');
295
- mrmdConfig = parseMrmdConfig(content);
296
- break;
297
- } catch {
298
- const parent = path.dirname(projectRoot);
299
- if (parent === projectRoot) break;
300
- projectRoot = parent;
301
- }
302
- }
303
-
304
- // If no mrmd.md found, use the provided directory
305
- if (!mrmdConfig) {
306
- projectRoot = defaultRoot;
307
- mrmdConfig = { venv: '.venv' };
308
- }
353
+ const detected = await detectProject(resolvedPath);
354
+ const projectRoot = detected?.root || path.dirname(resolvedPath);
355
+ const config = detected?.config || { name: path.basename(projectRoot) || 'Project' };
309
356
 
310
357
  // Build nav tree
311
358
  const navTree = await buildNavTree(projectRoot);
@@ -315,7 +362,7 @@ async function getProjectInfo(filePath, defaultRoot) {
315
362
 
316
363
  return {
317
364
  root: projectRoot,
318
- config: mrmdConfig,
365
+ config,
319
366
  navTree,
320
367
  files,
321
368
  currentFile: resolvedPath,
@@ -337,67 +384,6 @@ function flattenNavTree(nodes, projectRoot) {
337
384
  return files;
338
385
  }
339
386
 
340
- /**
341
- * Parse mrmd.md config (from yaml config code blocks)
342
- */
343
- function parseMrmdConfig(content) {
344
- const config = { venv: '.venv' };
345
-
346
- // Find all ```yaml config blocks and merge them
347
- const configBlockRegex = /```yaml\s+config\n([\s\S]*?)```/g;
348
- let match;
349
-
350
- while ((match = configBlockRegex.exec(content)) !== null) {
351
- const yaml = match[1];
352
-
353
- // Parse name
354
- const nameMatch = yaml.match(/^name:\s*["']?([^"'\n]+)["']?/m);
355
- if (nameMatch) {
356
- config.name = nameMatch[1].trim();
357
- }
358
-
359
- // Parse venv (direct or under session.python)
360
- const venvMatch = yaml.match(/^\s*venv:\s*["']?([^"'\n]+)["']?/m);
361
- if (venvMatch) {
362
- config.venv = venvMatch[1].trim();
363
- }
364
-
365
- // Parse session config
366
- const sessionMatch = yaml.match(/^session:\n([\s\S]*?)(?=^[^\s]|\Z)/m);
367
- if (sessionMatch) {
368
- config.session = {};
369
- const sessionYaml = sessionMatch[1];
370
-
371
- // Parse python session
372
- const pythonMatch = sessionYaml.match(/^\s+python:\n([\s\S]*?)(?=^\s+\w+:|\Z)/m);
373
- if (pythonMatch) {
374
- config.session.python = {};
375
- const pyYaml = pythonMatch[1];
376
- const pyVenv = pyYaml.match(/venv:\s*["']?([^"'\n]+)["']?/);
377
- if (pyVenv) config.session.python.venv = pyVenv[1].trim();
378
- const pyCwd = pyYaml.match(/cwd:\s*["']?([^"'\n]+)["']?/);
379
- if (pyCwd) config.session.python.cwd = pyCwd[1].trim();
380
- const pyName = pyYaml.match(/name:\s*["']?([^"'\n]+)["']?/);
381
- if (pyName) config.session.python.name = pyName[1].trim();
382
- const pyAutoStart = pyYaml.match(/auto_start:\s*(true|false)/);
383
- if (pyAutoStart) config.session.python.auto_start = pyAutoStart[1] === 'true';
384
- }
385
- }
386
- }
387
-
388
- // Also check YAML frontmatter for backwards compatibility
389
- const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
390
- if (fmMatch) {
391
- const yaml = fmMatch[1];
392
- const venvMatch = yaml.match(/venv:\s*(.+)/);
393
- if (venvMatch && !config.venv) {
394
- config.venv = venvMatch[1].trim();
395
- }
396
- }
397
-
398
- return config;
399
- }
400
-
401
387
  /**
402
388
  * Build navigation tree for a project
403
389
  */
@@ -437,7 +423,7 @@ async function buildNavTree(projectRoot, relativePath = '') {
437
423
  children,
438
424
  });
439
425
  }
440
- } else if (isDocFile(entry.name) && entry.name !== 'mrmd.md') {
426
+ } else if (isDocFile(entry.name)) {
441
427
  nodes.push({
442
428
  isFolder: false,
443
429
  title: cleanName(stripDocExtension(entry.name)),
@@ -467,3 +453,4 @@ async function hasIndexFile(dirPath) {
467
453
  function cleanName(name) {
468
454
  return name.replace(/^\d+[-_.\s]*/, '');
469
455
  }
456
+