mrmd-server 0.2.6 → 0.2.7

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,520 @@
1
+ /**
2
+ * FileService - File operations with automatic refactoring
3
+ *
4
+ * Manages file operations (create, move, delete) within mrmd projects.
5
+ * Automatically updates internal links and asset paths when files move.
6
+ *
7
+ * Uses mrmd-project for FSML parsing and link/asset refactoring.
8
+ */
9
+
10
+ import { FSML, Links, Assets } from 'mrmd-project';
11
+ import fs from 'fs';
12
+ import fsPromises from 'fs/promises';
13
+ import path from 'path';
14
+ import { UNORDERED_FILES, ASSETS_DIR_NAME } from '../config.js';
15
+
16
+ const DOC_EXTENSIONS = ['.md', '.qmd'];
17
+
18
+ function isDocFilename(filename) {
19
+ if (!filename) return false;
20
+ const lower = filename.toLowerCase();
21
+ return DOC_EXTENSIONS.some(ext => lower.endsWith(ext));
22
+ }
23
+
24
+ function semanticLinkName(filePath) {
25
+ if (!filePath) return '';
26
+ return filePath
27
+ .split('/')
28
+ .pop()
29
+ .replace(/\.[^.]+$/, '')
30
+ .replace(/^\d+-/, '')
31
+ .toLowerCase();
32
+ }
33
+
34
+ function needsGlobalLinkRefactor(fromPath, toPath) {
35
+ return semanticLinkName(fromPath) !== semanticLinkName(toPath);
36
+ }
37
+
38
+ class FileService {
39
+ /**
40
+ * @param {ProjectService} projectService - Reference to ProjectService for cache invalidation
41
+ */
42
+ constructor(projectService) {
43
+ this.projectService = projectService;
44
+ }
45
+
46
+ /**
47
+ * Scan files in a directory
48
+ *
49
+ * @param {string} root - Directory to scan
50
+ * @param {object} options - Scan options
51
+ * @param {boolean} options.includeHidden - Include hidden (_) directories
52
+ * @param {string[]} options.extensions - File extensions to include
53
+ * @param {number} options.maxDepth - Maximum recursion depth
54
+ * @returns {Promise<string[]>} Sorted relative paths
55
+ */
56
+ async scan(root, options = {}) {
57
+ const {
58
+ includeHidden = false,
59
+ extensions = DOC_EXTENSIONS,
60
+ maxDepth = 10,
61
+ } = options;
62
+
63
+ const files = [];
64
+
65
+ const walk = async (dir, depth) => {
66
+ if (depth > maxDepth) return;
67
+
68
+ let entries;
69
+ try {
70
+ entries = await fsPromises.readdir(dir, { withFileTypes: true });
71
+ } catch {
72
+ return;
73
+ }
74
+
75
+ for (const entry of entries) {
76
+ const fullPath = path.join(dir, entry.name);
77
+ const relativePath = path.relative(root, fullPath);
78
+
79
+ // Skip system files (.)
80
+ if (entry.name.startsWith('.')) continue;
81
+
82
+ // Skip hidden files (_) unless requested
83
+ if (!includeHidden && entry.name.startsWith('_')) continue;
84
+
85
+ // Skip node_modules
86
+ if (entry.name === 'node_modules') continue;
87
+
88
+ if (entry.isDirectory()) {
89
+ await walk(fullPath, depth + 1);
90
+ } else {
91
+ // Check extension
92
+ const hasMatchingExt = extensions.some(ext => entry.name.endsWith(ext));
93
+ if (hasMatchingExt) {
94
+ files.push(relativePath);
95
+ }
96
+ }
97
+ }
98
+ };
99
+
100
+ await walk(root, 0);
101
+ return FSML.sortPaths(files);
102
+ }
103
+
104
+ /**
105
+ * Create a file
106
+ *
107
+ * @param {string} filePath - Absolute path to create
108
+ * @param {string} content - File content
109
+ */
110
+ async createFile(filePath, content = '') {
111
+ await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
112
+ await fsPromises.writeFile(filePath, content);
113
+ }
114
+
115
+ /**
116
+ * Create a file within a project (handles FSML ordering)
117
+ *
118
+ * @param {string} projectRoot - Project root path
119
+ * @param {string} relativePath - Desired relative path
120
+ * @param {string} content - File content
121
+ * @returns {Promise<string>} Actual relative path (may have order prefix)
122
+ */
123
+ // Files that should never have order prefixes (using config from ../config.js)
124
+
125
+ /**
126
+ * Check if a filename should bypass FSML ordering
127
+ */
128
+ static shouldBypassOrdering(filename) {
129
+ const lower = filename.toLowerCase();
130
+ return UNORDERED_FILES.has(lower) || lower.startsWith('_');
131
+ }
132
+
133
+ async createInProject(projectRoot, relativePath, content = '') {
134
+ // Get the directory
135
+ const dir = path.dirname(relativePath);
136
+ const dirPath = dir ? path.join(projectRoot, dir) : projectRoot;
137
+
138
+ let finalPath = relativePath;
139
+ const filename = path.basename(relativePath);
140
+
141
+ // Skip ordering for special files (README.md/README.qmd, LICENSE, etc.)
142
+ if (FileService.shouldBypassOrdering(filename)) {
143
+ const fullPath = path.join(projectRoot, finalPath);
144
+ await this.createFile(fullPath, content);
145
+
146
+ if (this.projectService) {
147
+ this.projectService.invalidate(projectRoot);
148
+ }
149
+ return finalPath;
150
+ }
151
+
152
+ // Check if directory exists and has ordered files
153
+ if (await this.dirExists(dirPath)) {
154
+ try {
155
+ const siblings = await fsPromises.readdir(dirPath);
156
+ const docSiblings = siblings.filter(isDocFilename);
157
+
158
+ // Find max order among siblings
159
+ let maxOrder = 0;
160
+ let hasOrderedFiles = false;
161
+
162
+ for (const sibling of docSiblings) {
163
+ const parsed = FSML.parsePath(sibling);
164
+ if (parsed.order !== null) {
165
+ hasOrderedFiles = true;
166
+ maxOrder = Math.max(maxOrder, parsed.order);
167
+ }
168
+ }
169
+
170
+ // If folder has ordered files and new file doesn't have order, add one
171
+ if (hasOrderedFiles) {
172
+ const parsed = FSML.parsePath(filename);
173
+
174
+ if (parsed.order === null) {
175
+ // Add next order prefix
176
+ const newOrder = maxOrder + 1;
177
+ const paddedOrder = String(newOrder).padStart(2, '0');
178
+ const newFilename = `${paddedOrder}-${filename}`;
179
+ finalPath = dir ? path.join(dir, newFilename) : newFilename;
180
+ }
181
+ }
182
+ } catch {
183
+ // Directory doesn't exist yet, that's fine
184
+ }
185
+ }
186
+
187
+ const fullPath = path.join(projectRoot, finalPath);
188
+ await this.createFile(fullPath, content);
189
+
190
+ // Invalidate project cache
191
+ if (this.projectService) {
192
+ this.projectService.invalidate(projectRoot);
193
+ }
194
+
195
+ return finalPath;
196
+ }
197
+
198
+ /**
199
+ * Reorder a file/folder using FSML conventions (for drag-drop)
200
+ *
201
+ * Uses FSML.computeReorder() with sibling information to properly
202
+ * shift all numbered items and maintain FSML ordering.
203
+ *
204
+ * @param {string} projectRoot - Project root path
205
+ * @param {string} sourcePath - Source relative path
206
+ * @param {string} targetPath - Target relative path (drop target)
207
+ * @param {'before' | 'after' | 'inside'} position - Drop position
208
+ * @returns {Promise<RefactorResult>}
209
+ */
210
+ async reorder(projectRoot, sourcePath, targetPath, position) {
211
+ // 1. Scan all files ONCE for both reorder computation and link refactoring
212
+ const allFiles = await this.scan(projectRoot, { includeHidden: true });
213
+
214
+ // 2. Use FSML.computeReorder with siblings for proper shift computation
215
+ const { newPath, renames } = FSML.computeReorder(sourcePath, targetPath, position, allFiles);
216
+
217
+ console.log(`[FileService.reorder] ${sourcePath} -> ${newPath} (${position})`);
218
+ console.log(`[FileService.reorder] Renames needed:`, renames);
219
+
220
+ // 3. If no renames needed, nothing to do
221
+ if (renames.length === 0) {
222
+ console.log(`[FileService.reorder] No changes needed`);
223
+ return { movedFile: sourcePath, updatedFiles: [] };
224
+ }
225
+
226
+ const updatedFiles = [];
227
+
228
+ // 4. Execute renames in order, passing pre-scanned files to avoid redundant scans
229
+ for (const rename of renames) {
230
+ try {
231
+ const result = await this.move(projectRoot, rename.from, rename.to, { _cachedFiles: allFiles });
232
+ updatedFiles.push(...result.updatedFiles);
233
+ } catch (e) {
234
+ console.error(`[FileService.reorder] Failed to rename ${rename.from} -> ${rename.to}:`, e.message);
235
+ // Continue with other renames - partial success is better than nothing
236
+ }
237
+ }
238
+
239
+ // 5. Invalidate project cache once at the end (not per-move)
240
+ if (this.projectService) {
241
+ this.projectService.invalidate(projectRoot);
242
+ }
243
+
244
+ return { movedFile: newPath, updatedFiles: [...new Set(updatedFiles)] };
245
+ }
246
+
247
+ /**
248
+ * Move/rename a file or folder with automatic refactoring
249
+ *
250
+ * @param {string} projectRoot - Project root path
251
+ * @param {string} fromPath - Source relative path
252
+ * @param {string} toPath - Destination relative path
253
+ * @returns {Promise<RefactorResult>}
254
+ */
255
+ /**
256
+ * Move/rename a file or folder with automatic refactoring
257
+ *
258
+ * @param {string} projectRoot - Project root path
259
+ * @param {string} fromPath - Source relative path
260
+ * @param {string} toPath - Destination relative path
261
+ * @param {object} [options] - Internal options
262
+ * @param {string[]} [options._cachedFiles] - Pre-scanned file list (avoids redundant scans in batch ops)
263
+ * @returns {Promise<RefactorResult>}
264
+ */
265
+ async move(projectRoot, fromPath, toPath, options = {}) {
266
+ const fullFromPath = path.join(projectRoot, fromPath);
267
+ const fullToPath = path.join(projectRoot, toPath);
268
+
269
+ // Check if source exists
270
+ let stat;
271
+ try {
272
+ stat = await fsPromises.stat(fullFromPath);
273
+ } catch (e) {
274
+ // Source doesn't exist - might have been renamed already in a batch
275
+ console.log(`[FileService.move] Source doesn't exist (may have been renamed): ${fromPath}`);
276
+ return { movedFile: toPath, updatedFiles: [] };
277
+ }
278
+
279
+ // Check if source is a directory
280
+ const isDirectory = stat.isDirectory();
281
+
282
+ if (isDirectory) {
283
+ return this.moveDirectory(projectRoot, fromPath, toPath, options);
284
+ }
285
+
286
+ const updatedFiles = [];
287
+
288
+ const shouldRefactorLinks = needsGlobalLinkRefactor(fromPath, toPath);
289
+ if (shouldRefactorLinks) {
290
+ // Use pre-scanned files if available, otherwise scan
291
+ const files = options._cachedFiles || await this.scan(projectRoot, { includeHidden: true });
292
+
293
+ // For each file, check if it references the moved file
294
+ for (const file of files) {
295
+ if (file === fromPath) continue;
296
+
297
+ const fullPath = path.join(projectRoot, file);
298
+ let content;
299
+ try {
300
+ content = await fsPromises.readFile(fullPath, 'utf8');
301
+ } catch (e) {
302
+ console.warn(`[file] Could not read ${file} for link refactoring:`, e.message);
303
+ continue;
304
+ }
305
+
306
+ // Update links using mrmd-project
307
+ const updatedContent = Links.refactor(content, [
308
+ { from: fromPath, to: toPath },
309
+ ], file);
310
+
311
+ if (updatedContent !== content) {
312
+ await fsPromises.writeFile(fullPath, updatedContent);
313
+ updatedFiles.push(file);
314
+ }
315
+ }
316
+ }
317
+
318
+ // Read the file being moved
319
+ let movingContent;
320
+ try {
321
+ movingContent = await fsPromises.readFile(fullFromPath, 'utf8');
322
+ } catch (e) {
323
+ throw new Error(`Cannot read source file: ${e.message}`);
324
+ }
325
+
326
+ // Update asset paths IN the moved file using mrmd-project
327
+ const updatedMovingContent = Assets.refactorPaths(
328
+ movingContent,
329
+ fromPath,
330
+ toPath,
331
+ ASSETS_DIR_NAME
332
+ );
333
+
334
+ // Actually move the file
335
+ await fsPromises.mkdir(path.dirname(fullToPath), { recursive: true });
336
+ await fsPromises.writeFile(fullToPath, updatedMovingContent);
337
+ await fsPromises.unlink(fullFromPath);
338
+
339
+ // Clean up empty directories
340
+ await this.removeEmptyDirs(path.dirname(fullFromPath), projectRoot);
341
+
342
+ // Invalidate project cache (skip if called from batch operation like reorder)
343
+ if (!options._cachedFiles && this.projectService) {
344
+ this.projectService.invalidate(projectRoot);
345
+ }
346
+
347
+ return { movedFile: toPath, updatedFiles };
348
+ }
349
+
350
+ /**
351
+ * Move/rename a directory with automatic refactoring
352
+ *
353
+ * @param {string} projectRoot - Project root path
354
+ * @param {string} fromPath - Source relative path (folder)
355
+ * @param {string} toPath - Destination relative path (folder)
356
+ * @returns {Promise<RefactorResult>}
357
+ */
358
+ /**
359
+ * Move/rename a directory with automatic refactoring
360
+ *
361
+ * @param {string} projectRoot - Project root path
362
+ * @param {string} fromPath - Source relative path (folder)
363
+ * @param {string} toPath - Destination relative path (folder)
364
+ * @param {object} [options] - Internal options
365
+ * @param {string[]} [options._cachedFiles] - Pre-scanned file list
366
+ * @returns {Promise<RefactorResult>}
367
+ */
368
+ async moveDirectory(projectRoot, fromPath, toPath, options = {}) {
369
+ const fullFromPath = path.join(projectRoot, fromPath);
370
+ const fullToPath = path.join(projectRoot, toPath);
371
+ const updatedFiles = [];
372
+
373
+ // Use pre-scanned files if available, otherwise scan
374
+ const allFiles = options._cachedFiles || await this.scan(projectRoot, { includeHidden: true });
375
+
376
+ // 2. Build list of files being moved and their new paths
377
+ const movedFiles = [];
378
+ for (const file of allFiles) {
379
+ if (file.startsWith(fromPath + '/') || file === fromPath) {
380
+ const newPath = file.replace(fromPath, toPath);
381
+ movedFiles.push({ from: file, to: newPath });
382
+ }
383
+ }
384
+
385
+ const shouldRefactorLinks = movedFiles.some(moved => needsGlobalLinkRefactor(moved.from, moved.to));
386
+ if (shouldRefactorLinks) {
387
+ // 3. Update links in files NOT being moved that reference moved files
388
+ for (const file of allFiles) {
389
+ if (file.startsWith(fromPath + '/')) continue; // Skip files being moved
390
+
391
+ const fullPath = path.join(projectRoot, file);
392
+ let content;
393
+ try {
394
+ content = await fsPromises.readFile(fullPath, 'utf8');
395
+ } catch (e) {
396
+ console.warn(`[file] Could not read ${file} for directory refactoring:`, e.message);
397
+ continue;
398
+ }
399
+
400
+ // Update all links to moved files
401
+ let updatedContent = content;
402
+ for (const moved of movedFiles) {
403
+ updatedContent = Links.refactor(updatedContent, [
404
+ { from: moved.from, to: moved.to },
405
+ ], file);
406
+ }
407
+
408
+ if (updatedContent !== content) {
409
+ await fsPromises.writeFile(fullPath, updatedContent);
410
+ updatedFiles.push(file);
411
+ }
412
+ }
413
+ }
414
+
415
+ // 4. Update asset paths in files being moved
416
+ for (const moved of movedFiles) {
417
+ const fullPath = path.join(projectRoot, moved.from);
418
+ let content;
419
+ try {
420
+ content = await fsPromises.readFile(fullPath, 'utf8');
421
+ } catch (e) {
422
+ console.warn(`[file] Could not read ${moved.from} for asset path update:`, e.message);
423
+ continue;
424
+ }
425
+
426
+ const updatedContent = Assets.refactorPaths(
427
+ content,
428
+ moved.from,
429
+ moved.to,
430
+ ASSETS_DIR_NAME
431
+ );
432
+
433
+ if (updatedContent !== content) {
434
+ await fsPromises.writeFile(fullPath, updatedContent);
435
+ }
436
+ }
437
+
438
+ // 5. Actually move the directory
439
+ await fsPromises.mkdir(path.dirname(fullToPath), { recursive: true });
440
+ await fsPromises.rename(fullFromPath, fullToPath);
441
+
442
+ // 6. Clean up empty directories
443
+ await this.removeEmptyDirs(path.dirname(fullFromPath), projectRoot);
444
+
445
+ // 7. Invalidate project cache (skip if called from batch operation)
446
+ if (!options._cachedFiles && this.projectService) {
447
+ this.projectService.invalidate(projectRoot);
448
+ }
449
+
450
+ return { movedFile: toPath, updatedFiles };
451
+ }
452
+
453
+ /**
454
+ * Delete a file or directory
455
+ *
456
+ * @param {string} filePath - Absolute path to delete
457
+ */
458
+ async delete(filePath) {
459
+ const stat = await fsPromises.stat(filePath);
460
+ if (stat.isDirectory()) {
461
+ await fsPromises.rm(filePath, { recursive: true });
462
+ } else {
463
+ await fsPromises.unlink(filePath);
464
+ }
465
+ }
466
+
467
+ /**
468
+ * Read a file
469
+ *
470
+ * @param {string} filePath - Absolute path to read
471
+ * @returns {Promise<string>}
472
+ */
473
+ async read(filePath) {
474
+ return fsPromises.readFile(filePath, 'utf8');
475
+ }
476
+
477
+ /**
478
+ * Write a file
479
+ *
480
+ * @param {string} filePath - Absolute path to write
481
+ * @param {string} content - Content to write
482
+ */
483
+ async write(filePath, content) {
484
+ await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
485
+ await fsPromises.writeFile(filePath, content);
486
+ }
487
+
488
+ /**
489
+ * Check if a directory exists
490
+ */
491
+ async dirExists(dir) {
492
+ try {
493
+ const stat = await fsPromises.stat(dir);
494
+ return stat.isDirectory();
495
+ } catch {
496
+ return false;
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Remove empty directories up to a limit
502
+ */
503
+ async removeEmptyDirs(dir, stopAt) {
504
+ while (dir !== stopAt && dir.startsWith(stopAt)) {
505
+ try {
506
+ const entries = await fsPromises.readdir(dir);
507
+ if (entries.length === 0) {
508
+ await fsPromises.rmdir(dir);
509
+ dir = path.dirname(dir);
510
+ } else {
511
+ break;
512
+ }
513
+ } catch {
514
+ break;
515
+ }
516
+ }
517
+ }
518
+ }
519
+
520
+ export default FileService;