@noteplanco/noteplan-mcp 1.1.6 → 1.1.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.
Files changed (73) hide show
  1. package/dist/noteplan/file-writer.d.ts.map +1 -1
  2. package/dist/noteplan/file-writer.js +73 -16
  3. package/dist/noteplan/file-writer.js.map +1 -1
  4. package/dist/noteplan/file-writer.test.d.ts +2 -0
  5. package/dist/noteplan/file-writer.test.d.ts.map +1 -0
  6. package/dist/noteplan/file-writer.test.js +892 -0
  7. package/dist/noteplan/file-writer.test.js.map +1 -0
  8. package/dist/noteplan/filter-store.d.ts.map +1 -1
  9. package/dist/noteplan/filter-store.js +13 -1
  10. package/dist/noteplan/filter-store.js.map +1 -1
  11. package/dist/noteplan/frontmatter-parser.d.ts +10 -1
  12. package/dist/noteplan/frontmatter-parser.d.ts.map +1 -1
  13. package/dist/noteplan/frontmatter-parser.js +66 -10
  14. package/dist/noteplan/frontmatter-parser.js.map +1 -1
  15. package/dist/noteplan/frontmatter-parser.test.js +484 -1
  16. package/dist/noteplan/frontmatter-parser.test.js.map +1 -1
  17. package/dist/noteplan/markdown-parser.d.ts +6 -1
  18. package/dist/noteplan/markdown-parser.d.ts.map +1 -1
  19. package/dist/noteplan/markdown-parser.js +21 -44
  20. package/dist/noteplan/markdown-parser.js.map +1 -1
  21. package/dist/noteplan/markdown-parser.test.d.ts +2 -0
  22. package/dist/noteplan/markdown-parser.test.d.ts.map +1 -0
  23. package/dist/noteplan/markdown-parser.test.js +653 -0
  24. package/dist/noteplan/markdown-parser.test.js.map +1 -0
  25. package/dist/server.d.ts.map +1 -1
  26. package/dist/server.js +405 -205
  27. package/dist/server.js.map +1 -1
  28. package/dist/tools/attachments.d.ts +151 -0
  29. package/dist/tools/attachments.d.ts.map +1 -0
  30. package/dist/tools/attachments.js +421 -0
  31. package/dist/tools/attachments.js.map +1 -0
  32. package/dist/tools/attachments.test.d.ts +2 -0
  33. package/dist/tools/attachments.test.d.ts.map +1 -0
  34. package/dist/tools/attachments.test.js +561 -0
  35. package/dist/tools/attachments.test.js.map +1 -0
  36. package/dist/tools/calendar.d.ts +5 -5
  37. package/dist/tools/notes.d.ts +67 -26
  38. package/dist/tools/notes.d.ts.map +1 -1
  39. package/dist/tools/notes.js +124 -33
  40. package/dist/tools/notes.js.map +1 -1
  41. package/dist/tools/notes.test.d.ts +2 -0
  42. package/dist/tools/notes.test.d.ts.map +1 -0
  43. package/dist/tools/notes.test.js +598 -0
  44. package/dist/tools/notes.test.js.map +1 -0
  45. package/dist/tools/reminders.d.ts +4 -4
  46. package/dist/tools/tasks.d.ts +10 -10
  47. package/dist/tools/tasks.d.ts.map +1 -1
  48. package/dist/tools/tasks.js +14 -27
  49. package/dist/tools/tasks.js.map +1 -1
  50. package/dist/tools/templates.d.ts +69 -0
  51. package/dist/tools/templates.d.ts.map +1 -0
  52. package/dist/tools/templates.js +145 -0
  53. package/dist/tools/templates.js.map +1 -0
  54. package/dist/tools/templates.test.d.ts +2 -0
  55. package/dist/tools/templates.test.d.ts.map +1 -0
  56. package/dist/tools/templates.test.js +48 -0
  57. package/dist/tools/templates.test.js.map +1 -0
  58. package/dist/tools/ui.d.ts +2 -0
  59. package/dist/tools/ui.d.ts.map +1 -1
  60. package/dist/tools/ui.js +24 -0
  61. package/dist/tools/ui.js.map +1 -1
  62. package/dist/utils/applescript.d.ts.map +1 -1
  63. package/dist/utils/applescript.js +21 -0
  64. package/dist/utils/applescript.js.map +1 -1
  65. package/dist/utils/confirmation-tokens.test.d.ts +2 -0
  66. package/dist/utils/confirmation-tokens.test.d.ts.map +1 -0
  67. package/dist/utils/confirmation-tokens.test.js +159 -0
  68. package/dist/utils/confirmation-tokens.test.js.map +1 -0
  69. package/dist/utils/version.d.ts +2 -0
  70. package/dist/utils/version.d.ts.map +1 -1
  71. package/dist/utils/version.js +4 -0
  72. package/dist/utils/version.js.map +1 -1
  73. package/package.json +1 -1
@@ -0,0 +1,892 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ vi.mock('fs');
5
+ vi.mock('./file-reader.js', () => ({
6
+ getNotePlanPath: vi.fn(() => '/np'),
7
+ getNotesPath: vi.fn(() => '/np/Notes'),
8
+ getCalendarPath: vi.fn(() => '/np/Calendar'),
9
+ getFileExtension: vi.fn(() => '.md'),
10
+ hasYearSubfolders: vi.fn(() => false),
11
+ buildCalendarNotePath: vi.fn((date) => `Calendar/${date}.md`),
12
+ getCalendarNote: vi.fn(() => null),
13
+ }));
14
+ import { writeNoteFile, createProjectNote, createCalendarNote, ensureCalendarNote, appendToNote, prependToNote, updateNote, deleteNote, moveLocalNote, previewMoveLocalNote, restoreLocalNoteFromTrash, previewRestoreLocalNoteFromTrash, renameLocalNoteFile, previewRenameLocalNoteFile, createFolder, previewCreateFolder, deleteLocalFolder, previewDeleteLocalFolder, moveLocalFolder, previewMoveLocalFolder, renameLocalFolder, previewRenameLocalFolder, } from './file-writer.js';
15
+ import { getCalendarNote } from './file-reader.js';
16
+ const mockFs = vi.mocked(fs);
17
+ beforeEach(() => {
18
+ vi.resetAllMocks();
19
+ // Default: directories exist, files do not
20
+ mockFs.existsSync.mockReturnValue(false);
21
+ });
22
+ // ---------------------------------------------------------------------------
23
+ // writeNoteFile
24
+ // ---------------------------------------------------------------------------
25
+ describe('writeNoteFile', () => {
26
+ it('normalizes CRLF to LF', () => {
27
+ mockFs.existsSync.mockReturnValue(true); // dir exists, file exists
28
+ writeNoteFile('Notes/test.md', 'line1\r\nline2\r\n');
29
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith('/np/Notes/test.md', 'line1\nline2\n', { encoding: 'utf-8' });
30
+ });
31
+ it('creates parent directories when they do not exist', () => {
32
+ // First call: dir check -> false, second call: file check -> false
33
+ mockFs.existsSync.mockReturnValue(false);
34
+ writeNoteFile('Notes/sub/test.md', 'content');
35
+ expect(mockFs.mkdirSync).toHaveBeenCalledWith('/np/Notes/sub', { recursive: true });
36
+ });
37
+ it('does in-place write for existing files (no wx flag)', () => {
38
+ // dir exists, file exists
39
+ mockFs.existsSync.mockReturnValue(true);
40
+ writeNoteFile('Notes/existing.md', 'updated');
41
+ expect(mockFs.writeFileSync).toHaveBeenCalledTimes(1);
42
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith('/np/Notes/existing.md', 'updated', { encoding: 'utf-8' });
43
+ });
44
+ it('uses wx flag for new files', () => {
45
+ // dir exists (first call), file does not exist (second call)
46
+ mockFs.existsSync.mockImplementation((p) => {
47
+ const s = String(p);
48
+ if (s === '/np/Notes')
49
+ return true; // dir
50
+ return false; // file
51
+ });
52
+ writeNoteFile('Notes/new.md', 'hello');
53
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith('/np/Notes/new.md', 'hello', { encoding: 'utf-8', flag: 'wx' });
54
+ });
55
+ it('falls back to plain write on EPERM from wx', () => {
56
+ mockFs.existsSync.mockImplementation((p) => {
57
+ if (String(p) === '/np/Notes')
58
+ return true;
59
+ return false;
60
+ });
61
+ const eperm = Object.assign(new Error('EPERM'), { code: 'EPERM' });
62
+ mockFs.writeFileSync.mockImplementationOnce(() => {
63
+ throw eperm;
64
+ });
65
+ writeNoteFile('Notes/new.md', 'data');
66
+ expect(mockFs.writeFileSync).toHaveBeenCalledTimes(2);
67
+ expect(mockFs.writeFileSync).toHaveBeenLastCalledWith('/np/Notes/new.md', 'data', { encoding: 'utf-8' });
68
+ });
69
+ it('falls back to plain write on EEXIST from wx', () => {
70
+ mockFs.existsSync.mockImplementation((p) => {
71
+ if (String(p) === '/np/Notes')
72
+ return true;
73
+ return false;
74
+ });
75
+ const eexist = Object.assign(new Error('EEXIST'), { code: 'EEXIST' });
76
+ mockFs.writeFileSync.mockImplementationOnce(() => {
77
+ throw eexist;
78
+ });
79
+ writeNoteFile('Notes/new.md', 'data');
80
+ expect(mockFs.writeFileSync).toHaveBeenCalledTimes(2);
81
+ });
82
+ it('re-throws non-EPERM/EEXIST errors', () => {
83
+ mockFs.existsSync.mockImplementation((p) => {
84
+ if (String(p) === '/np/Notes')
85
+ return true;
86
+ return false;
87
+ });
88
+ const eacces = Object.assign(new Error('EACCES'), { code: 'EACCES' });
89
+ mockFs.writeFileSync.mockImplementationOnce(() => {
90
+ throw eacces;
91
+ });
92
+ expect(() => writeNoteFile('Notes/new.md', 'data')).toThrow('EACCES');
93
+ });
94
+ it('rejects paths outside NotePlan root', () => {
95
+ expect(() => writeNoteFile('/outside/path.md', 'x')).toThrow();
96
+ });
97
+ });
98
+ // ---------------------------------------------------------------------------
99
+ // createProjectNote
100
+ // ---------------------------------------------------------------------------
101
+ describe('createProjectNote', () => {
102
+ it('creates note with sanitized filename and returns relative path', () => {
103
+ mockFs.existsSync.mockReturnValue(false);
104
+ const result = createProjectNote('My Note');
105
+ expect(result).toBe(path.join('Notes', 'My Note.md'));
106
+ expect(mockFs.writeFileSync).toHaveBeenCalled();
107
+ });
108
+ it('uses default content when content is empty', () => {
109
+ mockFs.existsSync.mockReturnValue(false);
110
+ createProjectNote('Title');
111
+ // The writeFileSync should be called with "# Title\n\n" (after CRLF normalization, still same)
112
+ const calls = mockFs.writeFileSync.mock.calls;
113
+ const contentArg = calls[0]?.[1];
114
+ expect(contentArg).toBe('# Title\n\n');
115
+ });
116
+ it('uses provided content when given', () => {
117
+ mockFs.existsSync.mockReturnValue(false);
118
+ createProjectNote('Title', 'custom body');
119
+ const calls = mockFs.writeFileSync.mock.calls;
120
+ expect(calls[0]?.[1]).toBe('custom body');
121
+ });
122
+ it('creates note in specified folder', () => {
123
+ mockFs.existsSync.mockReturnValue(false);
124
+ const result = createProjectNote('Note', '', 'Work');
125
+ expect(result).toBe(path.join('Notes', 'Work', 'Note.md'));
126
+ });
127
+ it('throws if note already exists with same extension', () => {
128
+ mockFs.existsSync.mockImplementation((p) => {
129
+ return String(p) === '/np/Notes/Dup.md';
130
+ });
131
+ expect(() => createProjectNote('Dup')).toThrow('Note already exists');
132
+ });
133
+ it('throws if note exists with alternate extension (.txt)', () => {
134
+ mockFs.existsSync.mockImplementation((p) => {
135
+ return String(p) === '/np/Notes/Dup.txt';
136
+ });
137
+ expect(() => createProjectNote('Dup')).toThrow('Note already exists');
138
+ });
139
+ it('sanitizes special characters in title', () => {
140
+ mockFs.existsSync.mockReturnValue(false);
141
+ const result = createProjectNote('Hello/World?');
142
+ expect(result).toBe(path.join('Notes', 'Hello-World-.md'));
143
+ });
144
+ it('sanitizes all illegal filename chars', () => {
145
+ mockFs.existsSync.mockReturnValue(false);
146
+ const result = createProjectNote('a\\b?c%d*e:f|g"h<i>j');
147
+ // Each illegal char replaced with -
148
+ expect(result).toBe(path.join('Notes', 'a-b-c-d-e-f-g-h-i-j.md'));
149
+ });
150
+ });
151
+ // ---------------------------------------------------------------------------
152
+ // createCalendarNote
153
+ // ---------------------------------------------------------------------------
154
+ describe('createCalendarNote', () => {
155
+ it('creates calendar note and returns path', () => {
156
+ mockFs.existsSync.mockReturnValue(false);
157
+ const result = createCalendarNote('20240115', '# Jan 15');
158
+ expect(result).toBe('Calendar/20240115.md');
159
+ });
160
+ });
161
+ // ---------------------------------------------------------------------------
162
+ // ensureCalendarNote
163
+ // ---------------------------------------------------------------------------
164
+ describe('ensureCalendarNote', () => {
165
+ it('returns existing note path when found', () => {
166
+ vi.mocked(getCalendarNote).mockReturnValueOnce({
167
+ filename: 'Calendar/20240115.md',
168
+ });
169
+ const result = ensureCalendarNote('20240115');
170
+ expect(result).toBe('Calendar/20240115.md');
171
+ expect(mockFs.writeFileSync).not.toHaveBeenCalled();
172
+ });
173
+ it('creates new note when none exists', () => {
174
+ vi.mocked(getCalendarNote).mockReturnValueOnce(null);
175
+ mockFs.existsSync.mockReturnValue(false);
176
+ const result = ensureCalendarNote('20240115');
177
+ expect(result).toBe('Calendar/20240115.md');
178
+ expect(mockFs.writeFileSync).toHaveBeenCalled();
179
+ });
180
+ });
181
+ // ---------------------------------------------------------------------------
182
+ // appendToNote
183
+ // ---------------------------------------------------------------------------
184
+ describe('appendToNote', () => {
185
+ it('appends content to existing note', () => {
186
+ mockFs.existsSync.mockReturnValue(true);
187
+ mockFs.readFileSync.mockReturnValue('existing\n');
188
+ appendToNote('Notes/test.md', 'appended');
189
+ const written = mockFs.writeFileSync.mock.calls[0]?.[1];
190
+ expect(written).toBe('existing\nappended');
191
+ });
192
+ it('adds newline before content if note does not end with one', () => {
193
+ mockFs.existsSync.mockReturnValue(true);
194
+ mockFs.readFileSync.mockReturnValue('existing');
195
+ appendToNote('Notes/test.md', 'appended');
196
+ const written = mockFs.writeFileSync.mock.calls[0]?.[1];
197
+ expect(written).toBe('existing\nappended');
198
+ });
199
+ it('throws if note does not exist', () => {
200
+ mockFs.existsSync.mockReturnValue(false);
201
+ expect(() => appendToNote('Notes/nope.md', 'x')).toThrow('Note not found');
202
+ });
203
+ });
204
+ // ---------------------------------------------------------------------------
205
+ // prependToNote
206
+ // ---------------------------------------------------------------------------
207
+ describe('prependToNote', () => {
208
+ it('inserts after frontmatter', () => {
209
+ mockFs.existsSync.mockReturnValue(true);
210
+ mockFs.readFileSync.mockReturnValue('---\ntitle: X\n---\nbody');
211
+ prependToNote('Notes/test.md', 'PREPENDED');
212
+ const written = mockFs.writeFileSync.mock.calls[0]?.[1];
213
+ // lines: ['---', 'title: X', '---', 'body']
214
+ // insertIndex = 3, so content goes at index 3
215
+ expect(written).toBe('---\ntitle: X\n---\nPREPENDED\nbody');
216
+ });
217
+ it('inserts at top if no frontmatter', () => {
218
+ mockFs.existsSync.mockReturnValue(true);
219
+ mockFs.readFileSync.mockReturnValue('# Title\nBody');
220
+ prependToNote('Notes/test.md', 'PREPENDED');
221
+ const written = mockFs.writeFileSync.mock.calls[0]?.[1];
222
+ expect(written).toBe('PREPENDED\n# Title\nBody');
223
+ });
224
+ it('throws if note does not exist', () => {
225
+ mockFs.existsSync.mockReturnValue(false);
226
+ expect(() => prependToNote('Notes/nope.md', 'x')).toThrow('Note not found');
227
+ });
228
+ });
229
+ // ---------------------------------------------------------------------------
230
+ // updateNote
231
+ // ---------------------------------------------------------------------------
232
+ describe('updateNote', () => {
233
+ it('replaces entire note content', () => {
234
+ mockFs.existsSync.mockReturnValue(true);
235
+ updateNote('Notes/test.md', 'new content');
236
+ const written = mockFs.writeFileSync.mock.calls[0]?.[1];
237
+ expect(written).toBe('new content');
238
+ });
239
+ it('throws if note does not exist', () => {
240
+ mockFs.existsSync.mockReturnValue(false);
241
+ expect(() => updateNote('Notes/nope.md', 'x')).toThrow('Note not found');
242
+ });
243
+ });
244
+ // ---------------------------------------------------------------------------
245
+ // deleteNote
246
+ // ---------------------------------------------------------------------------
247
+ describe('deleteNote', () => {
248
+ it('moves file to @Trash folder', () => {
249
+ mockFs.existsSync.mockImplementation((p) => {
250
+ const s = String(p);
251
+ if (s === '/np/Notes/test.md')
252
+ return true;
253
+ if (s === '/np/Notes/@Trash')
254
+ return true;
255
+ return false; // trash target does not exist yet
256
+ });
257
+ const result = deleteNote('Notes/test.md');
258
+ expect(result).toBe(path.join('Notes', '@Trash', 'test.md'));
259
+ expect(mockFs.renameSync).toHaveBeenCalledWith('/np/Notes/test.md', '/np/Notes/@Trash/test.md');
260
+ });
261
+ it('creates @Trash if it does not exist', () => {
262
+ mockFs.existsSync.mockImplementation((p) => {
263
+ const s = String(p);
264
+ if (s === '/np/Notes/test.md')
265
+ return true;
266
+ return false;
267
+ });
268
+ deleteNote('Notes/test.md');
269
+ expect(mockFs.mkdirSync).toHaveBeenCalledWith('/np/Notes/@Trash', { recursive: true });
270
+ });
271
+ it('handles duplicate names in trash (appends -1, -2, etc.)', () => {
272
+ let callCount = 0;
273
+ mockFs.existsSync.mockImplementation((p) => {
274
+ const s = String(p);
275
+ if (s === '/np/Notes/test.md')
276
+ return true;
277
+ if (s === '/np/Notes/@Trash')
278
+ return true;
279
+ if (s === '/np/Notes/@Trash/test.md')
280
+ return true; // already taken
281
+ if (s === '/np/Notes/@Trash/test-1.md')
282
+ return true; // also taken
283
+ if (s === '/np/Notes/@Trash/test-2.md')
284
+ return false; // free
285
+ return false;
286
+ });
287
+ const result = deleteNote('Notes/test.md');
288
+ expect(result).toBe(path.join('Notes', '@Trash', 'test-2.md'));
289
+ });
290
+ it('throws if file does not exist', () => {
291
+ mockFs.existsSync.mockReturnValue(false);
292
+ expect(() => deleteNote('Notes/nope.md')).toThrow('Note not found');
293
+ });
294
+ it('uses EPERM fallback for moveFile (copy + delete)', () => {
295
+ mockFs.existsSync.mockImplementation((p) => {
296
+ const s = String(p);
297
+ if (s === '/np/Notes/test.md')
298
+ return true;
299
+ if (s === '/np/Notes/@Trash')
300
+ return true;
301
+ return false;
302
+ });
303
+ const eperm = Object.assign(new Error('EPERM'), { code: 'EPERM' });
304
+ mockFs.renameSync.mockImplementationOnce(() => {
305
+ throw eperm;
306
+ });
307
+ deleteNote('Notes/test.md');
308
+ expect(mockFs.copyFileSync).toHaveBeenCalledWith('/np/Notes/test.md', '/np/Notes/@Trash/test.md');
309
+ expect(mockFs.unlinkSync).toHaveBeenCalledWith('/np/Notes/test.md');
310
+ });
311
+ it('uses EXDEV fallback for moveFile (copy + delete)', () => {
312
+ mockFs.existsSync.mockImplementation((p) => {
313
+ const s = String(p);
314
+ if (s === '/np/Notes/test.md')
315
+ return true;
316
+ if (s === '/np/Notes/@Trash')
317
+ return true;
318
+ return false;
319
+ });
320
+ const exdev = Object.assign(new Error('EXDEV'), { code: 'EXDEV' });
321
+ mockFs.renameSync.mockImplementationOnce(() => {
322
+ throw exdev;
323
+ });
324
+ deleteNote('Notes/test.md');
325
+ expect(mockFs.copyFileSync).toHaveBeenCalled();
326
+ expect(mockFs.unlinkSync).toHaveBeenCalled();
327
+ });
328
+ });
329
+ // ---------------------------------------------------------------------------
330
+ // previewMoveLocalNote
331
+ // ---------------------------------------------------------------------------
332
+ describe('previewMoveLocalNote', () => {
333
+ function setupMovePreview() {
334
+ mockFs.existsSync.mockImplementation((p) => {
335
+ const s = String(p);
336
+ if (s === '/np/Notes/test.md')
337
+ return true;
338
+ return false;
339
+ });
340
+ mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
341
+ }
342
+ it('returns preview with fromFilename, toFilename, destinationFolder', () => {
343
+ setupMovePreview();
344
+ const result = previewMoveLocalNote('Notes/test.md', 'Notes/Work');
345
+ expect(result.fromFilename).toBe(path.join('Notes', 'test.md'));
346
+ expect(result.toFilename).toBe(path.join('Notes', 'Work', 'test.md'));
347
+ expect(result.destinationFolder).toBe('Notes/Work');
348
+ });
349
+ it('validates source exists', () => {
350
+ mockFs.existsSync.mockReturnValue(false);
351
+ expect(() => previewMoveLocalNote('Notes/nope.md', 'Notes/Work')).toThrow('Note not found');
352
+ });
353
+ it('rejects directories', () => {
354
+ mockFs.existsSync.mockReturnValue(true);
355
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true, isFile: () => false });
356
+ expect(() => previewMoveLocalNote('Notes/folder', 'Notes/Work')).toThrow('Not a note file');
357
+ });
358
+ it('rejects if source is outside Notes folder', () => {
359
+ mockFs.existsSync.mockReturnValue(true);
360
+ mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
361
+ expect(() => previewMoveLocalNote('Calendar/20240101.md', 'Notes/Work')).toThrow('must be inside Notes');
362
+ });
363
+ it('rejects if already at destination', () => {
364
+ mockFs.existsSync.mockImplementation((p) => {
365
+ const s = String(p);
366
+ if (s === '/np/Notes/Work/test.md')
367
+ return true;
368
+ return false;
369
+ });
370
+ mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
371
+ expect(() => previewMoveLocalNote('Notes/Work/test.md', 'Notes/Work')).toThrow('already in the destination');
372
+ });
373
+ it('rejects if conflict at destination', () => {
374
+ mockFs.existsSync.mockImplementation((p) => {
375
+ const s = String(p);
376
+ if (s === '/np/Notes/test.md')
377
+ return true;
378
+ if (s === '/np/Notes/Work/test.md')
379
+ return true; // conflict
380
+ return false;
381
+ });
382
+ mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
383
+ expect(() => previewMoveLocalNote('Notes/test.md', 'Notes/Work')).toThrow('already exists at destination');
384
+ });
385
+ it('handles folder input without Notes/ prefix', () => {
386
+ setupMovePreview();
387
+ const result = previewMoveLocalNote('Notes/test.md', 'Work');
388
+ expect(result.destinationFolder).toBe('Notes/Work');
389
+ });
390
+ it('handles folder input with trailing slash', () => {
391
+ setupMovePreview();
392
+ const result = previewMoveLocalNote('Notes/test.md', 'Work/');
393
+ expect(result.destinationFolder).toBe('Notes/Work');
394
+ });
395
+ it('rejects when destinationFolder looks like a different filename', () => {
396
+ setupMovePreview();
397
+ expect(() => previewMoveLocalNote('Notes/test.md', 'Work/other.md')).toThrow('must be a folder path, not a filename');
398
+ });
399
+ it('strips same filename from destination path', () => {
400
+ setupMovePreview();
401
+ const result = previewMoveLocalNote('Notes/test.md', 'Work/test.md');
402
+ expect(result.destinationFolder).toBe('Notes/Work');
403
+ });
404
+ });
405
+ // ---------------------------------------------------------------------------
406
+ // moveLocalNote
407
+ // ---------------------------------------------------------------------------
408
+ describe('moveLocalNote', () => {
409
+ it('moves note between folders', () => {
410
+ mockFs.existsSync.mockImplementation((p) => {
411
+ const s = String(p);
412
+ if (s === '/np/Notes/test.md')
413
+ return true;
414
+ if (s === '/np/Notes/Work')
415
+ return false; // will be created
416
+ return false;
417
+ });
418
+ mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
419
+ const result = moveLocalNote('Notes/test.md', 'Work');
420
+ expect(result).toBe(path.join('Notes', 'Work', 'test.md'));
421
+ expect(mockFs.mkdirSync).toHaveBeenCalledWith('/np/Notes/Work', { recursive: true });
422
+ expect(mockFs.renameSync).toHaveBeenCalled();
423
+ });
424
+ it('creates destination folder if needed', () => {
425
+ mockFs.existsSync.mockImplementation((p) => {
426
+ const s = String(p);
427
+ if (s === '/np/Notes/test.md')
428
+ return true;
429
+ return false;
430
+ });
431
+ mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
432
+ moveLocalNote('Notes/test.md', 'NewFolder');
433
+ expect(mockFs.mkdirSync).toHaveBeenCalled();
434
+ });
435
+ });
436
+ // ---------------------------------------------------------------------------
437
+ // previewRestoreLocalNoteFromTrash
438
+ // ---------------------------------------------------------------------------
439
+ describe('previewRestoreLocalNoteFromTrash', () => {
440
+ it('returns preview for restoring from trash', () => {
441
+ mockFs.existsSync.mockImplementation((p) => {
442
+ const s = String(p);
443
+ if (s === '/np/Notes/@Trash/test.md')
444
+ return true;
445
+ return false;
446
+ });
447
+ mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
448
+ const result = previewRestoreLocalNoteFromTrash('Notes/@Trash/test.md', 'Notes');
449
+ expect(result.fromFilename).toBe(path.join('Notes', '@Trash', 'test.md'));
450
+ expect(result.toFilename).toBe(path.join('Notes', 'test.md'));
451
+ });
452
+ it('throws if source not found', () => {
453
+ mockFs.existsSync.mockReturnValue(false);
454
+ expect(() => previewRestoreLocalNoteFromTrash('Notes/@Trash/nope.md', 'Notes')).toThrow('Note not found');
455
+ });
456
+ it('throws if source is not inside @Trash', () => {
457
+ mockFs.existsSync.mockReturnValue(true);
458
+ mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
459
+ expect(() => previewRestoreLocalNoteFromTrash('Notes/test.md', 'Notes')).toThrow('must be inside @Trash');
460
+ });
461
+ it('throws if conflict at destination', () => {
462
+ mockFs.existsSync.mockImplementation((p) => {
463
+ const s = String(p);
464
+ if (s === '/np/Notes/@Trash/test.md')
465
+ return true;
466
+ if (s === '/np/Notes/test.md')
467
+ return true; // conflict
468
+ return false;
469
+ });
470
+ mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
471
+ expect(() => previewRestoreLocalNoteFromTrash('Notes/@Trash/test.md', 'Notes')).toThrow('already exists at destination');
472
+ });
473
+ });
474
+ // ---------------------------------------------------------------------------
475
+ // restoreLocalNoteFromTrash
476
+ // ---------------------------------------------------------------------------
477
+ describe('restoreLocalNoteFromTrash', () => {
478
+ it('restores note from trash to destination', () => {
479
+ mockFs.existsSync.mockImplementation((p) => {
480
+ const s = String(p);
481
+ if (s === '/np/Notes/@Trash/test.md')
482
+ return true;
483
+ if (s === '/np/Notes')
484
+ return true;
485
+ return false;
486
+ });
487
+ mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
488
+ const result = restoreLocalNoteFromTrash('Notes/@Trash/test.md', 'Notes');
489
+ expect(result).toBe(path.join('Notes', 'test.md'));
490
+ expect(mockFs.renameSync).toHaveBeenCalled();
491
+ });
492
+ });
493
+ // ---------------------------------------------------------------------------
494
+ // previewRenameLocalNoteFile
495
+ // ---------------------------------------------------------------------------
496
+ describe('previewRenameLocalNoteFile', () => {
497
+ function setupRenamePreview() {
498
+ mockFs.existsSync.mockImplementation((p) => {
499
+ const s = String(p);
500
+ if (s === '/np/Notes/old.md')
501
+ return true;
502
+ return false;
503
+ });
504
+ mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
505
+ }
506
+ it('returns preview with fromFilename and toFilename', () => {
507
+ setupRenamePreview();
508
+ const result = previewRenameLocalNoteFile('Notes/old.md', 'new');
509
+ expect(result.fromFilename).toBe(path.join('Notes', 'old.md'));
510
+ expect(result.toFilename).toBe(path.join('Notes', 'new.md'));
511
+ });
512
+ it('keepExtension=true preserves original extension', () => {
513
+ setupRenamePreview();
514
+ const result = previewRenameLocalNoteFile('Notes/old.md', 'new.txt', true);
515
+ // keepExtension=true means keep .md
516
+ expect(result.toFilename).toBe(path.join('Notes', 'new.md'));
517
+ });
518
+ it('keepExtension=false uses provided extension', () => {
519
+ setupRenamePreview();
520
+ const result = previewRenameLocalNoteFile('Notes/old.md', 'new.txt', false);
521
+ expect(result.toFilename).toBe(path.join('Notes', 'new.txt'));
522
+ });
523
+ it('throws if new name matches current name', () => {
524
+ setupRenamePreview();
525
+ expect(() => previewRenameLocalNoteFile('Notes/old.md', 'old')).toThrow('matches current filename');
526
+ });
527
+ it('throws if new name already exists', () => {
528
+ mockFs.existsSync.mockImplementation((p) => {
529
+ const s = String(p);
530
+ if (s === '/np/Notes/old.md')
531
+ return true;
532
+ if (s === '/np/Notes/taken.md')
533
+ return true;
534
+ return false;
535
+ });
536
+ mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
537
+ expect(() => previewRenameLocalNoteFile('Notes/old.md', 'taken')).toThrow('already exists with filename');
538
+ });
539
+ it('throws if source not found', () => {
540
+ mockFs.existsSync.mockReturnValue(false);
541
+ expect(() => previewRenameLocalNoteFile('Notes/nope.md', 'new')).toThrow('Note not found');
542
+ });
543
+ it('throws if source is a directory', () => {
544
+ mockFs.existsSync.mockReturnValue(true);
545
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true, isFile: () => false });
546
+ expect(() => previewRenameLocalNoteFile('Notes/folder', 'new')).toThrow('Not a note file');
547
+ });
548
+ it('sanitizes special characters in rename', () => {
549
+ setupRenamePreview();
550
+ const result = previewRenameLocalNoteFile('Notes/old.md', 'he?lo');
551
+ expect(result.toFilename).toBe(path.join('Notes', 'he-lo.md'));
552
+ });
553
+ it('rejects rename that changes folder', () => {
554
+ mockFs.existsSync.mockImplementation((p) => {
555
+ const s = String(p);
556
+ if (s === '/np/Notes/old.md')
557
+ return true;
558
+ return false;
559
+ });
560
+ mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
561
+ expect(() => previewRenameLocalNoteFile('Notes/old.md', 'OtherFolder/new')).toThrow('must stay in the same folder');
562
+ });
563
+ });
564
+ // ---------------------------------------------------------------------------
565
+ // renameLocalNoteFile
566
+ // ---------------------------------------------------------------------------
567
+ describe('renameLocalNoteFile', () => {
568
+ it('renames file within same folder', () => {
569
+ mockFs.existsSync.mockImplementation((p) => {
570
+ const s = String(p);
571
+ if (s === '/np/Notes/old.md')
572
+ return true;
573
+ return false;
574
+ });
575
+ mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
576
+ const result = renameLocalNoteFile('Notes/old.md', 'new');
577
+ expect(result).toBe(path.join('Notes', 'new.md'));
578
+ expect(mockFs.renameSync).toHaveBeenCalledWith('/np/Notes/old.md', '/np/Notes/new.md');
579
+ });
580
+ });
581
+ // ---------------------------------------------------------------------------
582
+ // previewCreateFolder
583
+ // ---------------------------------------------------------------------------
584
+ describe('previewCreateFolder', () => {
585
+ it('returns normalized folder path', () => {
586
+ mockFs.existsSync.mockReturnValue(false);
587
+ const result = previewCreateFolder('MyFolder');
588
+ expect(result).toBe('MyFolder');
589
+ });
590
+ it('strips Notes/ prefix', () => {
591
+ mockFs.existsSync.mockReturnValue(false);
592
+ const result = previewCreateFolder('Notes/MyFolder');
593
+ expect(result).toBe('MyFolder');
594
+ });
595
+ it('throws if folder already exists', () => {
596
+ mockFs.existsSync.mockReturnValue(true);
597
+ expect(() => previewCreateFolder('ExistingFolder')).toThrow('Folder already exists');
598
+ });
599
+ it('throws on empty folder path', () => {
600
+ expect(() => previewCreateFolder(' ')).toThrow();
601
+ });
602
+ it('throws on invalid segments (..)', () => {
603
+ expect(() => previewCreateFolder('a/../b')).toThrow('invalid');
604
+ });
605
+ });
606
+ // ---------------------------------------------------------------------------
607
+ // createFolder
608
+ // ---------------------------------------------------------------------------
609
+ describe('createFolder', () => {
610
+ it('creates folder under Notes and returns normalized path', () => {
611
+ mockFs.existsSync.mockReturnValue(false);
612
+ const result = createFolder('Projects');
613
+ expect(result).toBe('Projects');
614
+ expect(mockFs.mkdirSync).toHaveBeenCalledWith('/np/Notes/Projects', { recursive: true });
615
+ });
616
+ it('creates nested folder', () => {
617
+ mockFs.existsSync.mockReturnValue(false);
618
+ const result = createFolder('Projects/Work');
619
+ expect(result).toBe('Projects/Work');
620
+ });
621
+ });
622
+ // ---------------------------------------------------------------------------
623
+ // previewDeleteLocalFolder
624
+ // ---------------------------------------------------------------------------
625
+ describe('previewDeleteLocalFolder', () => {
626
+ it('returns normalized folder path', () => {
627
+ mockFs.existsSync.mockReturnValue(true);
628
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true });
629
+ const result = previewDeleteLocalFolder('MyFolder');
630
+ expect(result).toBe('MyFolder');
631
+ });
632
+ it('throws if folder does not exist', () => {
633
+ mockFs.existsSync.mockReturnValue(false);
634
+ expect(() => previewDeleteLocalFolder('Nope')).toThrow('Folder not found');
635
+ });
636
+ it('throws if target is not a directory', () => {
637
+ mockFs.existsSync.mockReturnValue(true);
638
+ mockFs.statSync.mockReturnValue({ isDirectory: () => false });
639
+ expect(() => previewDeleteLocalFolder('file.md')).toThrow('Not a folder');
640
+ });
641
+ it('cannot delete @Trash folder', () => {
642
+ mockFs.existsSync.mockReturnValue(true);
643
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true });
644
+ expect(() => previewDeleteLocalFolder('@Trash')).toThrow('Cannot delete the @Trash folder');
645
+ });
646
+ it('throws on empty path', () => {
647
+ expect(() => previewDeleteLocalFolder(' ')).toThrow();
648
+ });
649
+ });
650
+ // ---------------------------------------------------------------------------
651
+ // deleteLocalFolder
652
+ // ---------------------------------------------------------------------------
653
+ describe('deleteLocalFolder', () => {
654
+ it('moves folder to @Trash', () => {
655
+ mockFs.existsSync.mockImplementation((p) => {
656
+ const s = String(p);
657
+ if (s === '/np/Notes/Old')
658
+ return true;
659
+ if (s === '/np/Notes/@Trash')
660
+ return true;
661
+ if (s === '/np/Notes/@Trash/Old')
662
+ return false;
663
+ return false;
664
+ });
665
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true });
666
+ const result = deleteLocalFolder('Old');
667
+ expect(result).toBe(path.join('Notes', '@Trash', 'Old'));
668
+ expect(mockFs.renameSync).toHaveBeenCalled();
669
+ });
670
+ it('handles duplicate folder names in trash', () => {
671
+ mockFs.existsSync.mockImplementation((p) => {
672
+ const s = String(p);
673
+ if (s === '/np/Notes/Old')
674
+ return true;
675
+ if (s === '/np/Notes/@Trash')
676
+ return true;
677
+ if (s === '/np/Notes/@Trash/Old')
678
+ return true; // taken
679
+ if (s === '/np/Notes/@Trash/Old-1')
680
+ return false; // free
681
+ return false;
682
+ });
683
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true });
684
+ const result = deleteLocalFolder('Old');
685
+ expect(result).toBe(path.join('Notes', '@Trash', 'Old-1'));
686
+ });
687
+ it('creates @Trash if it does not exist', () => {
688
+ mockFs.existsSync.mockImplementation((p) => {
689
+ const s = String(p);
690
+ if (s === '/np/Notes/Old')
691
+ return true;
692
+ if (s === '/np/Notes/@Trash')
693
+ return false; // needs creation
694
+ return false;
695
+ });
696
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true });
697
+ deleteLocalFolder('Old');
698
+ expect(mockFs.mkdirSync).toHaveBeenCalledWith('/np/Notes/@Trash', { recursive: true });
699
+ });
700
+ });
701
+ // ---------------------------------------------------------------------------
702
+ // previewMoveLocalFolder
703
+ // ---------------------------------------------------------------------------
704
+ describe('previewMoveLocalFolder', () => {
705
+ function setupFolderMove() {
706
+ mockFs.existsSync.mockImplementation((p) => {
707
+ const s = String(p);
708
+ if (s === '/np/Notes/Source')
709
+ return true;
710
+ if (s === '/np/Notes/Dest')
711
+ return true;
712
+ return false;
713
+ });
714
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true });
715
+ }
716
+ it('returns preview with fromFolder and toFolder', () => {
717
+ setupFolderMove();
718
+ const result = previewMoveLocalFolder('Source', 'Dest');
719
+ expect(result.fromFolder).toBe('Source');
720
+ expect(result.toFolder).toBe('Dest/Source');
721
+ });
722
+ it('cannot move folder into itself', () => {
723
+ mockFs.existsSync.mockReturnValue(true);
724
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true });
725
+ expect(() => previewMoveLocalFolder('Source', 'Source')).toThrow('Cannot move a folder into itself');
726
+ });
727
+ it('cannot move folder into its descendants', () => {
728
+ mockFs.existsSync.mockImplementation((p) => {
729
+ const s = String(p);
730
+ if (s === '/np/Notes/Source')
731
+ return true;
732
+ if (s === '/np/Notes/Source/Child')
733
+ return true;
734
+ return false;
735
+ });
736
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true });
737
+ expect(() => previewMoveLocalFolder('Source', 'Source/Child')).toThrow('Cannot move a folder into itself');
738
+ });
739
+ it('throws if source folder not found', () => {
740
+ mockFs.existsSync.mockReturnValue(false);
741
+ expect(() => previewMoveLocalFolder('Nope', 'Dest')).toThrow('Source folder not found');
742
+ });
743
+ it('throws if destination folder not found', () => {
744
+ mockFs.existsSync.mockImplementation((p) => {
745
+ const s = String(p);
746
+ if (s === '/np/Notes/Source')
747
+ return true;
748
+ return false;
749
+ });
750
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true });
751
+ expect(() => previewMoveLocalFolder('Source', 'NoDest')).toThrow('Destination folder not found');
752
+ });
753
+ it('throws if folder already at destination', () => {
754
+ // Source is already inside Dest, meaning the target path resolves to the same place
755
+ mockFs.existsSync.mockImplementation((p) => {
756
+ const s = String(p);
757
+ if (s === '/np/Notes/Dest/Source')
758
+ return true;
759
+ if (s === '/np/Notes/Dest')
760
+ return true;
761
+ return false;
762
+ });
763
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true });
764
+ expect(() => previewMoveLocalFolder('Dest/Source', 'Dest')).toThrow('already in the destination');
765
+ });
766
+ it('throws if conflict at destination', () => {
767
+ mockFs.existsSync.mockImplementation((p) => {
768
+ const s = String(p);
769
+ if (s === '/np/Notes/Source')
770
+ return true;
771
+ if (s === '/np/Notes/Dest')
772
+ return true;
773
+ if (s === '/np/Notes/Dest/Source')
774
+ return true; // conflict
775
+ return false;
776
+ });
777
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true });
778
+ expect(() => previewMoveLocalFolder('Source', 'Dest')).toThrow('already exists at destination');
779
+ });
780
+ it('allows moving to Notes root', () => {
781
+ mockFs.existsSync.mockImplementation((p) => {
782
+ const s = String(p);
783
+ if (s === '/np/Notes/Sub/Source')
784
+ return true;
785
+ if (s === '/np/Notes')
786
+ return true;
787
+ return false;
788
+ });
789
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true });
790
+ const result = previewMoveLocalFolder('Sub/Source', 'Notes');
791
+ expect(result.toFolder).toBe('Source');
792
+ expect(result.destinationFolder).toBe('Notes');
793
+ });
794
+ });
795
+ // ---------------------------------------------------------------------------
796
+ // moveLocalFolder
797
+ // ---------------------------------------------------------------------------
798
+ describe('moveLocalFolder', () => {
799
+ it('moves folder to destination', () => {
800
+ mockFs.existsSync.mockImplementation((p) => {
801
+ const s = String(p);
802
+ if (s === '/np/Notes/Source')
803
+ return true;
804
+ if (s === '/np/Notes/Dest')
805
+ return true;
806
+ return false;
807
+ });
808
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true });
809
+ const result = moveLocalFolder('Source', 'Dest');
810
+ expect(result.fromFolder).toBe('Source');
811
+ expect(result.toFolder).toBe('Dest/Source');
812
+ expect(mockFs.renameSync).toHaveBeenCalledWith('/np/Notes/Source', '/np/Notes/Dest/Source');
813
+ });
814
+ });
815
+ // ---------------------------------------------------------------------------
816
+ // previewRenameLocalFolder
817
+ // ---------------------------------------------------------------------------
818
+ describe('previewRenameLocalFolder', () => {
819
+ function setupFolderRename() {
820
+ mockFs.existsSync.mockImplementation((p) => {
821
+ const s = String(p);
822
+ if (s === '/np/Notes/Old')
823
+ return true;
824
+ return false;
825
+ });
826
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true });
827
+ }
828
+ it('returns preview with fromFolder and toFolder', () => {
829
+ setupFolderRename();
830
+ const result = previewRenameLocalFolder('Old', 'New');
831
+ expect(result.fromFolder).toBe('Old');
832
+ expect(result.toFolder).toBe('New');
833
+ });
834
+ it('sanitizes new folder name', () => {
835
+ setupFolderRename();
836
+ const result = previewRenameLocalFolder('Old', 'He?lo');
837
+ expect(result.toFolder).toBe('He-lo');
838
+ });
839
+ it('throws if new name matches current name', () => {
840
+ setupFolderRename();
841
+ expect(() => previewRenameLocalFolder('Old', 'Old')).toThrow('matches current name');
842
+ });
843
+ it('throws if new name already exists', () => {
844
+ mockFs.existsSync.mockImplementation((p) => {
845
+ const s = String(p);
846
+ if (s === '/np/Notes/Old')
847
+ return true;
848
+ if (s === '/np/Notes/Taken')
849
+ return true;
850
+ return false;
851
+ });
852
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true });
853
+ expect(() => previewRenameLocalFolder('Old', 'Taken')).toThrow('already exists');
854
+ });
855
+ it('throws if source not found', () => {
856
+ mockFs.existsSync.mockReturnValue(false);
857
+ expect(() => previewRenameLocalFolder('Nope', 'New')).toThrow('Source folder not found');
858
+ });
859
+ it('throws on empty new name', () => {
860
+ setupFolderRename();
861
+ expect(() => previewRenameLocalFolder('Old', ' ')).toThrow('required');
862
+ });
863
+ it('must stay in same parent folder', () => {
864
+ mockFs.existsSync.mockImplementation((p) => {
865
+ const s = String(p);
866
+ if (s === '/np/Notes/Old')
867
+ return true;
868
+ return false;
869
+ });
870
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true });
871
+ expect(() => previewRenameLocalFolder('Old', 'Other/New')).toThrow('must stay in the same parent folder');
872
+ });
873
+ });
874
+ // ---------------------------------------------------------------------------
875
+ // renameLocalFolder
876
+ // ---------------------------------------------------------------------------
877
+ describe('renameLocalFolder', () => {
878
+ it('renames folder and returns preview', () => {
879
+ mockFs.existsSync.mockImplementation((p) => {
880
+ const s = String(p);
881
+ if (s === '/np/Notes/Old')
882
+ return true;
883
+ return false;
884
+ });
885
+ mockFs.statSync.mockReturnValue({ isDirectory: () => true });
886
+ const result = renameLocalFolder('Old', 'New');
887
+ expect(result.fromFolder).toBe('Old');
888
+ expect(result.toFolder).toBe('New');
889
+ expect(mockFs.renameSync).toHaveBeenCalledWith('/np/Notes/Old', '/np/Notes/New');
890
+ });
891
+ });
892
+ //# sourceMappingURL=file-writer.test.js.map