@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.
- package/dist/noteplan/file-writer.d.ts.map +1 -1
- package/dist/noteplan/file-writer.js +73 -16
- package/dist/noteplan/file-writer.js.map +1 -1
- package/dist/noteplan/file-writer.test.d.ts +2 -0
- package/dist/noteplan/file-writer.test.d.ts.map +1 -0
- package/dist/noteplan/file-writer.test.js +892 -0
- package/dist/noteplan/file-writer.test.js.map +1 -0
- package/dist/noteplan/filter-store.d.ts.map +1 -1
- package/dist/noteplan/filter-store.js +13 -1
- package/dist/noteplan/filter-store.js.map +1 -1
- package/dist/noteplan/frontmatter-parser.d.ts +10 -1
- package/dist/noteplan/frontmatter-parser.d.ts.map +1 -1
- package/dist/noteplan/frontmatter-parser.js +66 -10
- package/dist/noteplan/frontmatter-parser.js.map +1 -1
- package/dist/noteplan/frontmatter-parser.test.js +484 -1
- package/dist/noteplan/frontmatter-parser.test.js.map +1 -1
- package/dist/noteplan/markdown-parser.d.ts +6 -1
- package/dist/noteplan/markdown-parser.d.ts.map +1 -1
- package/dist/noteplan/markdown-parser.js +21 -44
- package/dist/noteplan/markdown-parser.js.map +1 -1
- package/dist/noteplan/markdown-parser.test.d.ts +2 -0
- package/dist/noteplan/markdown-parser.test.d.ts.map +1 -0
- package/dist/noteplan/markdown-parser.test.js +653 -0
- package/dist/noteplan/markdown-parser.test.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +405 -205
- package/dist/server.js.map +1 -1
- package/dist/tools/attachments.d.ts +151 -0
- package/dist/tools/attachments.d.ts.map +1 -0
- package/dist/tools/attachments.js +421 -0
- package/dist/tools/attachments.js.map +1 -0
- package/dist/tools/attachments.test.d.ts +2 -0
- package/dist/tools/attachments.test.d.ts.map +1 -0
- package/dist/tools/attachments.test.js +561 -0
- package/dist/tools/attachments.test.js.map +1 -0
- package/dist/tools/calendar.d.ts +5 -5
- package/dist/tools/notes.d.ts +67 -26
- package/dist/tools/notes.d.ts.map +1 -1
- package/dist/tools/notes.js +124 -33
- package/dist/tools/notes.js.map +1 -1
- package/dist/tools/notes.test.d.ts +2 -0
- package/dist/tools/notes.test.d.ts.map +1 -0
- package/dist/tools/notes.test.js +598 -0
- package/dist/tools/notes.test.js.map +1 -0
- package/dist/tools/reminders.d.ts +4 -4
- package/dist/tools/tasks.d.ts +10 -10
- package/dist/tools/tasks.d.ts.map +1 -1
- package/dist/tools/tasks.js +14 -27
- package/dist/tools/tasks.js.map +1 -1
- package/dist/tools/templates.d.ts +69 -0
- package/dist/tools/templates.d.ts.map +1 -0
- package/dist/tools/templates.js +145 -0
- package/dist/tools/templates.js.map +1 -0
- package/dist/tools/templates.test.d.ts +2 -0
- package/dist/tools/templates.test.d.ts.map +1 -0
- package/dist/tools/templates.test.js +48 -0
- package/dist/tools/templates.test.js.map +1 -0
- package/dist/tools/ui.d.ts +2 -0
- package/dist/tools/ui.d.ts.map +1 -1
- package/dist/tools/ui.js +24 -0
- package/dist/tools/ui.js.map +1 -1
- package/dist/utils/applescript.d.ts.map +1 -1
- package/dist/utils/applescript.js +21 -0
- package/dist/utils/applescript.js.map +1 -1
- package/dist/utils/confirmation-tokens.test.d.ts +2 -0
- package/dist/utils/confirmation-tokens.test.d.ts.map +1 -0
- package/dist/utils/confirmation-tokens.test.js +159 -0
- package/dist/utils/confirmation-tokens.test.js.map +1 -0
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/version.d.ts.map +1 -1
- package/dist/utils/version.js +4 -0
- package/dist/utils/version.js.map +1 -1
- 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
|