@noteplanco/noteplan-mcp 1.1.23 → 1.1.25
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/README.md +7 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/noteplan/attachments-paths.d.ts +13 -0
- package/dist/noteplan/attachments-paths.d.ts.map +1 -0
- package/dist/noteplan/attachments-paths.js +27 -0
- package/dist/noteplan/attachments-paths.js.map +1 -0
- package/dist/noteplan/embeddings.js +1 -1
- package/dist/noteplan/embeddings.js.map +1 -1
- package/dist/noteplan/file-reader.d.ts +37 -46
- package/dist/noteplan/file-reader.d.ts.map +1 -1
- package/dist/noteplan/file-reader.js +200 -202
- package/dist/noteplan/file-reader.js.map +1 -1
- package/dist/noteplan/file-reader.test.d.ts +2 -0
- package/dist/noteplan/file-reader.test.d.ts.map +1 -0
- package/dist/noteplan/file-reader.test.js +67 -0
- package/dist/noteplan/file-reader.test.js.map +1 -0
- package/dist/noteplan/file-writer.d.ts +35 -31
- package/dist/noteplan/file-writer.d.ts.map +1 -1
- package/dist/noteplan/file-writer.js +280 -164
- package/dist/noteplan/file-writer.js.map +1 -1
- package/dist/noteplan/file-writer.test.js +704 -191
- package/dist/noteplan/file-writer.test.js.map +1 -1
- package/dist/noteplan/filter-store.d.ts +5 -5
- package/dist/noteplan/filter-store.d.ts.map +1 -1
- package/dist/noteplan/filter-store.js +94 -79
- package/dist/noteplan/filter-store.js.map +1 -1
- package/dist/noteplan/ripgrep-search.d.ts +25 -2
- package/dist/noteplan/ripgrep-search.d.ts.map +1 -1
- package/dist/noteplan/ripgrep-search.js +75 -2
- package/dist/noteplan/ripgrep-search.js.map +1 -1
- package/dist/noteplan/space-row-utils.d.ts +20 -0
- package/dist/noteplan/space-row-utils.d.ts.map +1 -0
- package/dist/noteplan/space-row-utils.js +78 -0
- package/dist/noteplan/space-row-utils.js.map +1 -0
- package/dist/noteplan/space-row-utils.test.d.ts +2 -0
- package/dist/noteplan/space-row-utils.test.d.ts.map +1 -0
- package/dist/noteplan/space-row-utils.test.js +123 -0
- package/dist/noteplan/space-row-utils.test.js.map +1 -0
- package/dist/noteplan/sqlite-reader.d.ts +12 -27
- package/dist/noteplan/sqlite-reader.d.ts.map +1 -1
- package/dist/noteplan/sqlite-reader.js +315 -221
- package/dist/noteplan/sqlite-reader.js.map +1 -1
- package/dist/noteplan/sqlite-writer.d.ts +1 -1
- package/dist/noteplan/sqlite-writer.d.ts.map +1 -1
- package/dist/noteplan/sqlite-writer.js +2 -2
- package/dist/noteplan/sqlite-writer.js.map +1 -1
- package/dist/noteplan/unified-store.d.ts +41 -30
- package/dist/noteplan/unified-store.d.ts.map +1 -1
- package/dist/noteplan/unified-store.js +257 -159
- package/dist/noteplan/unified-store.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +142 -61
- package/dist/server.js.map +1 -1
- package/dist/tools/attachments.d.ts +9 -9
- package/dist/tools/attachments.d.ts.map +1 -1
- package/dist/tools/attachments.js +74 -83
- package/dist/tools/attachments.js.map +1 -1
- package/dist/tools/attachments.test.js +170 -129
- package/dist/tools/attachments.test.js.map +1 -1
- package/dist/tools/calendar.d.ts +16 -13
- package/dist/tools/calendar.d.ts.map +1 -1
- package/dist/tools/calendar.js +17 -16
- package/dist/tools/calendar.js.map +1 -1
- package/dist/tools/embeddings.d.ts +6 -6
- package/dist/tools/embeddings.d.ts.map +1 -1
- package/dist/tools/embeddings.js +6 -6
- package/dist/tools/embeddings.js.map +1 -1
- package/dist/tools/events.d.ts +7 -3
- package/dist/tools/events.d.ts.map +1 -1
- package/dist/tools/events.js +51 -16
- package/dist/tools/events.js.map +1 -1
- package/dist/tools/filters.d.ts +28 -33
- package/dist/tools/filters.d.ts.map +1 -1
- package/dist/tools/filters.js +42 -105
- package/dist/tools/filters.js.map +1 -1
- package/dist/tools/notes.d.ts +80 -218
- package/dist/tools/notes.d.ts.map +1 -1
- package/dist/tools/notes.js +180 -177
- package/dist/tools/notes.js.map +1 -1
- package/dist/tools/notes.test.js +242 -21
- package/dist/tools/notes.test.js.map +1 -1
- package/dist/tools/search.d.ts +4 -3
- package/dist/tools/search.d.ts.map +1 -1
- package/dist/tools/search.js +9 -5
- package/dist/tools/search.js.map +1 -1
- package/dist/tools/search.test.d.ts +2 -0
- package/dist/tools/search.test.d.ts.map +1 -0
- package/dist/tools/search.test.js +37 -0
- package/dist/tools/search.test.js.map +1 -0
- package/dist/tools/spaces.d.ts +20 -20
- package/dist/tools/spaces.d.ts.map +1 -1
- package/dist/tools/spaces.js +28 -28
- package/dist/tools/spaces.js.map +1 -1
- package/dist/tools/tasks.d.ts +22 -22
- package/dist/tools/tasks.d.ts.map +1 -1
- package/dist/tools/tasks.js +22 -22
- package/dist/tools/tasks.js.map +1 -1
- package/dist/tools/templates.d.ts +7 -7
- package/dist/tools/templates.d.ts.map +1 -1
- package/dist/tools/templates.js +4 -4
- package/dist/tools/templates.js.map +1 -1
- package/dist/tools/themes.d.ts.map +1 -1
- package/dist/tools/themes.js +26 -35
- package/dist/tools/themes.js.map +1 -1
- package/dist/transport/bridge-availability.d.ts +5 -0
- package/dist/transport/bridge-availability.d.ts.map +1 -0
- package/dist/transport/bridge-availability.js +92 -0
- package/dist/transport/bridge-availability.js.map +1 -0
- package/dist/transport/bridge-cascade.d.ts +18 -0
- package/dist/transport/bridge-cascade.d.ts.map +1 -0
- package/dist/transport/bridge-cascade.js +78 -0
- package/dist/transport/bridge-cascade.js.map +1 -0
- package/dist/transport/bridge-cascade.test.d.ts +2 -0
- package/dist/transport/bridge-cascade.test.d.ts.map +1 -0
- package/dist/transport/bridge-cascade.test.js +160 -0
- package/dist/transport/bridge-cascade.test.js.map +1 -0
- package/dist/transport/bridge-client.d.ts +197 -0
- package/dist/transport/bridge-client.d.ts.map +1 -0
- package/dist/transport/bridge-client.js +288 -0
- package/dist/transport/bridge-client.js.map +1 -0
- package/dist/transport/bridge-client.test.d.ts +2 -0
- package/dist/transport/bridge-client.test.d.ts.map +1 -0
- package/dist/transport/bridge-client.test.js +384 -0
- package/dist/transport/bridge-client.test.js.map +1 -0
- package/dist/transport/bridge-context.d.ts +10 -0
- package/dist/transport/bridge-context.d.ts.map +1 -0
- package/dist/transport/bridge-context.js +18 -0
- package/dist/transport/bridge-context.js.map +1 -0
- package/dist/transport/bridge-fs.d.ts +25 -0
- package/dist/transport/bridge-fs.d.ts.map +1 -0
- package/dist/transport/bridge-fs.js +129 -0
- package/dist/transport/bridge-fs.js.map +1 -0
- package/dist/utils/date-utils.d.ts +24 -0
- package/dist/utils/date-utils.d.ts.map +1 -1
- package/dist/utils/date-utils.js +55 -0
- package/dist/utils/date-utils.js.map +1 -1
- package/dist/utils/date-utils.test.d.ts +2 -0
- package/dist/utils/date-utils.test.d.ts.map +1 -0
- package/dist/utils/date-utils.test.js +109 -0
- package/dist/utils/date-utils.test.js.map +1 -0
- package/dist/utils/folder-access.d.ts +23 -0
- package/dist/utils/folder-access.d.ts.map +1 -0
- package/dist/utils/folder-access.js +131 -0
- package/dist/utils/folder-access.js.map +1 -0
- package/dist/utils/folder-access.test.d.ts +2 -0
- package/dist/utils/folder-access.test.d.ts.map +1 -0
- package/dist/utils/folder-access.test.js +182 -0
- package/dist/utils/folder-access.test.js.map +1 -0
- package/dist/utils/folder-matcher.d.ts.map +1 -1
- package/dist/utils/folder-matcher.js +16 -0
- package/dist/utils/folder-matcher.js.map +1 -1
- package/dist/utils/folder-matcher.test.js +42 -0
- package/dist/utils/folder-matcher.test.js.map +1 -1
- package/dist/utils/server-config.d.ts +10 -2
- package/dist/utils/server-config.d.ts.map +1 -1
- package/dist/utils/server-config.js +16 -2
- package/dist/utils/server-config.js.map +1 -1
- package/dist/utils/version.d.ts +2 -0
- package/dist/utils/version.d.ts.map +1 -1
- package/dist/utils/version.js +5 -1
- package/dist/utils/version.js.map +1 -1
- package/package.json +4 -3
- package/scripts/calendar-helper +0 -0
- package/scripts/reminders-helper +0 -0
|
@@ -2,50 +2,91 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
2
2
|
import * as fs from 'fs';
|
|
3
3
|
import * as path from 'path';
|
|
4
4
|
vi.mock('fs');
|
|
5
|
+
vi.mock('../transport/bridge-availability.js', () => ({
|
|
6
|
+
getBridgeClient: vi.fn(async () => null),
|
|
7
|
+
invalidateBridgeClient: vi.fn(),
|
|
8
|
+
}));
|
|
5
9
|
vi.mock('./file-reader.js', () => ({
|
|
6
10
|
getNotePlanPath: vi.fn(() => '/np'),
|
|
7
11
|
getNotesPath: vi.fn(() => '/np/Notes'),
|
|
8
12
|
getCalendarPath: vi.fn(() => '/np/Calendar'),
|
|
9
13
|
getFileExtension: vi.fn(() => '.md'),
|
|
10
14
|
hasYearSubfolders: vi.fn(() => false),
|
|
11
|
-
|
|
15
|
+
buildCalendarNotePathAsync: vi.fn(async (date) => `Calendar/${date}.md`),
|
|
12
16
|
getCalendarNote: vi.fn(() => null),
|
|
13
17
|
isValidNoteExtension: vi.fn((filename) => {
|
|
14
18
|
const ext = path.extname(filename).toLowerCase();
|
|
15
19
|
return ext === '.md' || ext === '.txt';
|
|
16
20
|
}),
|
|
17
21
|
}));
|
|
18
|
-
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';
|
|
22
|
+
import { writeNoteFile, createProjectNote, createCalendarNote, createCalendarNoteIfNew, ensureCalendarNote, appendToNote, prependToNote, updateNote, deleteNote, moveLocalNote, previewMoveLocalNote, restoreLocalNoteFromTrash, previewRestoreLocalNoteFromTrash, renameLocalNoteFile, previewRenameLocalNoteFile, createFolder, previewCreateFolder, deleteLocalFolder, previewDeleteLocalFolder, moveLocalFolder, previewMoveLocalFolder, renameLocalFolder, previewRenameLocalFolder, } from './file-writer.js';
|
|
19
23
|
import { getCalendarNote } from './file-reader.js';
|
|
20
24
|
const mockFs = vi.mocked(fs);
|
|
25
|
+
// Production code uses fs.promises; tests assert against fs.*Sync. The proxy
|
|
26
|
+
// forwards every promise call to its sync counterpart, with existsSync gating
|
|
27
|
+
// stat/readFile/access so ENOENT surfaces consistently.
|
|
21
28
|
beforeEach(() => {
|
|
22
29
|
vi.resetAllMocks();
|
|
23
|
-
// Default: directories exist, files do not
|
|
24
30
|
mockFs.existsSync.mockReturnValue(false);
|
|
31
|
+
const enoent = () => Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
|
32
|
+
mockFs.promises = {
|
|
33
|
+
writeFile: vi.fn(async (p, content, opts) => {
|
|
34
|
+
const normalized = typeof opts === 'string' ? { encoding: opts } : opts;
|
|
35
|
+
return mockFs.writeFileSync(p, content, normalized);
|
|
36
|
+
}),
|
|
37
|
+
mkdir: vi.fn(async (p, opts) => mockFs.mkdirSync(p, opts)),
|
|
38
|
+
rename: vi.fn(async (a, b) => mockFs.renameSync(a, b)),
|
|
39
|
+
copyFile: vi.fn(async (a, b) => mockFs.copyFileSync(a, b)),
|
|
40
|
+
unlink: vi.fn(async (p) => mockFs.unlinkSync(p)),
|
|
41
|
+
rm: vi.fn(async (p) => {
|
|
42
|
+
if (mockFs.rmSync)
|
|
43
|
+
mockFs.rmSync(p, { recursive: true });
|
|
44
|
+
else
|
|
45
|
+
mockFs.unlinkSync(p);
|
|
46
|
+
}),
|
|
47
|
+
readFile: vi.fn(async (p, enc) => {
|
|
48
|
+
if (!mockFs.existsSync(p))
|
|
49
|
+
throw enoent();
|
|
50
|
+
return mockFs.readFileSync(p, enc);
|
|
51
|
+
}),
|
|
52
|
+
readdir: vi.fn(async (p, opts) => mockFs.readdirSync(p, opts)),
|
|
53
|
+
stat: vi.fn(async (p) => {
|
|
54
|
+
if (!mockFs.existsSync(p))
|
|
55
|
+
throw enoent();
|
|
56
|
+
const explicit = mockFs.statSync(p);
|
|
57
|
+
if (explicit !== undefined && explicit !== null)
|
|
58
|
+
return explicit;
|
|
59
|
+
return { isDirectory: () => false, isFile: () => true };
|
|
60
|
+
}),
|
|
61
|
+
access: vi.fn(async (p) => {
|
|
62
|
+
if (!mockFs.existsSync(p))
|
|
63
|
+
throw enoent();
|
|
64
|
+
}),
|
|
65
|
+
};
|
|
25
66
|
});
|
|
26
67
|
// ---------------------------------------------------------------------------
|
|
27
68
|
// writeNoteFile
|
|
28
69
|
// ---------------------------------------------------------------------------
|
|
29
70
|
describe('writeNoteFile', () => {
|
|
30
|
-
it('normalizes CRLF to LF', () => {
|
|
71
|
+
it('normalizes CRLF to LF', async () => {
|
|
31
72
|
mockFs.existsSync.mockReturnValue(true); // dir exists, file exists
|
|
32
|
-
writeNoteFile('Notes/test.md', 'line1\r\nline2\r\n');
|
|
73
|
+
await writeNoteFile('Notes/test.md', 'line1\r\nline2\r\n');
|
|
33
74
|
expect(mockFs.writeFileSync).toHaveBeenCalledWith('/np/Notes/test.md', 'line1\nline2\n', { encoding: 'utf-8' });
|
|
34
75
|
});
|
|
35
|
-
it('creates parent directories when they do not exist', () => {
|
|
76
|
+
it('creates parent directories when they do not exist', async () => {
|
|
36
77
|
// First call: dir check -> false, second call: file check -> false
|
|
37
78
|
mockFs.existsSync.mockReturnValue(false);
|
|
38
|
-
writeNoteFile('Notes/sub/test.md', 'content');
|
|
79
|
+
await writeNoteFile('Notes/sub/test.md', 'content');
|
|
39
80
|
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/np/Notes/sub', { recursive: true });
|
|
40
81
|
});
|
|
41
|
-
it('does in-place write for existing files (no wx flag)', () => {
|
|
82
|
+
it('does in-place write for existing files (no wx flag)', async () => {
|
|
42
83
|
// dir exists, file exists
|
|
43
84
|
mockFs.existsSync.mockReturnValue(true);
|
|
44
|
-
writeNoteFile('Notes/existing.md', 'updated');
|
|
85
|
+
await writeNoteFile('Notes/existing.md', 'updated');
|
|
45
86
|
expect(mockFs.writeFileSync).toHaveBeenCalledTimes(1);
|
|
46
87
|
expect(mockFs.writeFileSync).toHaveBeenCalledWith('/np/Notes/existing.md', 'updated', { encoding: 'utf-8' });
|
|
47
88
|
});
|
|
48
|
-
it('uses wx flag for new files', () => {
|
|
89
|
+
it('uses wx flag for new files', async () => {
|
|
49
90
|
// dir exists (first call), file does not exist (second call)
|
|
50
91
|
mockFs.existsSync.mockImplementation((p) => {
|
|
51
92
|
const s = String(p);
|
|
@@ -53,10 +94,10 @@ describe('writeNoteFile', () => {
|
|
|
53
94
|
return true; // dir
|
|
54
95
|
return false; // file
|
|
55
96
|
});
|
|
56
|
-
writeNoteFile('Notes/new.md', 'hello');
|
|
97
|
+
await writeNoteFile('Notes/new.md', 'hello');
|
|
57
98
|
expect(mockFs.writeFileSync).toHaveBeenCalledWith('/np/Notes/new.md', 'hello', { encoding: 'utf-8', flag: 'wx' });
|
|
58
99
|
});
|
|
59
|
-
it('falls back to plain write on EPERM from wx', () => {
|
|
100
|
+
it('falls back to plain write on EPERM from wx', async () => {
|
|
60
101
|
mockFs.existsSync.mockImplementation((p) => {
|
|
61
102
|
if (String(p) === '/np/Notes')
|
|
62
103
|
return true;
|
|
@@ -66,11 +107,11 @@ describe('writeNoteFile', () => {
|
|
|
66
107
|
mockFs.writeFileSync.mockImplementationOnce(() => {
|
|
67
108
|
throw eperm;
|
|
68
109
|
});
|
|
69
|
-
writeNoteFile('Notes/new.md', 'data');
|
|
110
|
+
await writeNoteFile('Notes/new.md', 'data');
|
|
70
111
|
expect(mockFs.writeFileSync).toHaveBeenCalledTimes(2);
|
|
71
112
|
expect(mockFs.writeFileSync).toHaveBeenLastCalledWith('/np/Notes/new.md', 'data', { encoding: 'utf-8' });
|
|
72
113
|
});
|
|
73
|
-
it('falls back to plain write on EEXIST from wx', () => {
|
|
114
|
+
it('falls back to plain write on EEXIST from wx', async () => {
|
|
74
115
|
mockFs.existsSync.mockImplementation((p) => {
|
|
75
116
|
if (String(p) === '/np/Notes')
|
|
76
117
|
return true;
|
|
@@ -80,10 +121,10 @@ describe('writeNoteFile', () => {
|
|
|
80
121
|
mockFs.writeFileSync.mockImplementationOnce(() => {
|
|
81
122
|
throw eexist;
|
|
82
123
|
});
|
|
83
|
-
writeNoteFile('Notes/new.md', 'data');
|
|
124
|
+
await writeNoteFile('Notes/new.md', 'data');
|
|
84
125
|
expect(mockFs.writeFileSync).toHaveBeenCalledTimes(2);
|
|
85
126
|
});
|
|
86
|
-
it('re-throws non-EPERM/EEXIST errors', () => {
|
|
127
|
+
it('re-throws non-EPERM/EEXIST errors', async () => {
|
|
87
128
|
mockFs.existsSync.mockImplementation((p) => {
|
|
88
129
|
if (String(p) === '/np/Notes')
|
|
89
130
|
return true;
|
|
@@ -93,96 +134,191 @@ describe('writeNoteFile', () => {
|
|
|
93
134
|
mockFs.writeFileSync.mockImplementationOnce(() => {
|
|
94
135
|
throw eacces;
|
|
95
136
|
});
|
|
96
|
-
expect(
|
|
137
|
+
await expect(writeNoteFile('Notes/new.md', 'data')).rejects.toThrow('EACCES');
|
|
97
138
|
});
|
|
98
|
-
it('rejects paths outside NotePlan root', () => {
|
|
99
|
-
expect(
|
|
139
|
+
it('rejects paths outside NotePlan root', async () => {
|
|
140
|
+
await expect(writeNoteFile('/outside/path.md', 'x')).rejects.toThrow();
|
|
100
141
|
});
|
|
101
142
|
});
|
|
102
143
|
// ---------------------------------------------------------------------------
|
|
103
144
|
// createProjectNote
|
|
104
145
|
// ---------------------------------------------------------------------------
|
|
105
146
|
describe('createProjectNote', () => {
|
|
106
|
-
it('creates note with sanitized filename and returns relative path', () => {
|
|
147
|
+
it('creates note with sanitized filename and returns relative path', async () => {
|
|
107
148
|
mockFs.existsSync.mockReturnValue(false);
|
|
108
|
-
const result = createProjectNote('My Note');
|
|
149
|
+
const result = await createProjectNote('My Note');
|
|
109
150
|
expect(result).toBe(path.join('Notes', 'My Note.md'));
|
|
110
151
|
expect(mockFs.writeFileSync).toHaveBeenCalled();
|
|
111
152
|
});
|
|
112
|
-
it('uses default content when content is empty', () => {
|
|
153
|
+
it('uses default content when content is empty', async () => {
|
|
113
154
|
mockFs.existsSync.mockReturnValue(false);
|
|
114
|
-
createProjectNote('Title');
|
|
155
|
+
await createProjectNote('Title');
|
|
115
156
|
// The writeFileSync should be called with "# Title\n\n" (after CRLF normalization, still same)
|
|
116
157
|
const calls = mockFs.writeFileSync.mock.calls;
|
|
117
158
|
const contentArg = calls[0]?.[1];
|
|
118
159
|
expect(contentArg).toBe('# Title\n\n');
|
|
119
160
|
});
|
|
120
|
-
it('uses provided content when given', () => {
|
|
161
|
+
it('uses provided content when given', async () => {
|
|
121
162
|
mockFs.existsSync.mockReturnValue(false);
|
|
122
|
-
createProjectNote('Title', 'custom body');
|
|
163
|
+
await createProjectNote('Title', 'custom body');
|
|
123
164
|
const calls = mockFs.writeFileSync.mock.calls;
|
|
124
165
|
expect(calls[0]?.[1]).toBe('custom body');
|
|
125
166
|
});
|
|
126
|
-
it('creates note in specified folder', () => {
|
|
167
|
+
it('creates note in specified folder', async () => {
|
|
127
168
|
mockFs.existsSync.mockReturnValue(false);
|
|
128
|
-
const result = createProjectNote('Note', '', 'Work');
|
|
169
|
+
const result = await createProjectNote('Note', '', 'Work');
|
|
129
170
|
expect(result).toBe(path.join('Notes', 'Work', 'Note.md'));
|
|
130
171
|
});
|
|
131
|
-
it('strips Notes/ prefix from folder to avoid double-nesting', () => {
|
|
172
|
+
it('strips Notes/ prefix from folder to avoid double-nesting', async () => {
|
|
132
173
|
mockFs.existsSync.mockReturnValue(false);
|
|
133
|
-
const result = createProjectNote('Note', '', 'Notes/Work');
|
|
174
|
+
const result = await createProjectNote('Note', '', 'Notes/Work');
|
|
134
175
|
expect(result).toBe(path.join('Notes', 'Work', 'Note.md'));
|
|
135
176
|
});
|
|
136
|
-
it('
|
|
177
|
+
it('treats a bare "Notes" folder as the vault root (no Notes/Notes nesting)', async () => {
|
|
178
|
+
// Regression: when the folder resolver short-circuits on the
|
|
179
|
+
// reserved top-level name "Notes" and passes the literal through,
|
|
180
|
+
// we used to produce `Notes/Notes/<title>` because the prefix
|
|
181
|
+
// strip required a trailing slash. The regex now also matches
|
|
182
|
+
// end-of-string.
|
|
183
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
184
|
+
const result = await createProjectNote('Note', '', 'Notes');
|
|
185
|
+
expect(result).toBe(path.join('Notes', 'Note.md'));
|
|
186
|
+
});
|
|
187
|
+
it('throws if note already exists with same extension', async () => {
|
|
137
188
|
mockFs.existsSync.mockImplementation((p) => {
|
|
138
189
|
return String(p) === '/np/Notes/Dup.md';
|
|
139
190
|
});
|
|
140
|
-
expect(
|
|
191
|
+
await expect(createProjectNote('Dup')).rejects.toThrow('Note already exists');
|
|
141
192
|
});
|
|
142
|
-
it('throws if note exists with alternate extension (.txt)', () => {
|
|
193
|
+
it('throws if note exists with alternate extension (.txt)', async () => {
|
|
143
194
|
mockFs.existsSync.mockImplementation((p) => {
|
|
144
195
|
return String(p) === '/np/Notes/Dup.txt';
|
|
145
196
|
});
|
|
146
|
-
expect(
|
|
197
|
+
await expect(createProjectNote('Dup')).rejects.toThrow('Note already exists');
|
|
147
198
|
});
|
|
148
|
-
it('sanitizes special characters in title', () => {
|
|
199
|
+
it('sanitizes special characters in title', async () => {
|
|
149
200
|
mockFs.existsSync.mockReturnValue(false);
|
|
150
|
-
const result = createProjectNote('Hello/World?');
|
|
201
|
+
const result = await createProjectNote('Hello/World?');
|
|
151
202
|
expect(result).toBe(path.join('Notes', 'Hello-World-.md'));
|
|
152
203
|
});
|
|
153
|
-
it('sanitizes all illegal filename chars', () => {
|
|
204
|
+
it('sanitizes all illegal filename chars', async () => {
|
|
154
205
|
mockFs.existsSync.mockReturnValue(false);
|
|
155
|
-
const result = createProjectNote('a\\b?c%d*e:f|g"h<i>j');
|
|
206
|
+
const result = await createProjectNote('a\\b?c%d*e:f|g"h<i>j');
|
|
156
207
|
// Each illegal char replaced with -
|
|
157
208
|
expect(result).toBe(path.join('Notes', 'a-b-c-d-e-f-g-h-i-j.md'));
|
|
158
209
|
});
|
|
210
|
+
// Regression: the explicit `filename` parameter used to be silently dropped
|
|
211
|
+
// by the schema, so callers had to do create + rename in two steps. These
|
|
212
|
+
// tests pin the new behavior — title and filename are independent inputs.
|
|
213
|
+
describe('explicit filename parameter', () => {
|
|
214
|
+
it('uses the explicit filename instead of deriving from title', async () => {
|
|
215
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
216
|
+
const result = await createProjectNote('Human Readable Title', '', undefined, '_context.md');
|
|
217
|
+
expect(result).toBe(path.join('Notes', '_context.md'));
|
|
218
|
+
});
|
|
219
|
+
it('appends the configured default extension when filename has none', async () => {
|
|
220
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
221
|
+
const result = await createProjectNote('Title', '', undefined, '_context');
|
|
222
|
+
expect(result).toBe(path.join('Notes', '_context.md'));
|
|
223
|
+
});
|
|
224
|
+
it('keeps an explicit .txt extension', async () => {
|
|
225
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
226
|
+
const result = await createProjectNote('Title', '', undefined, '_context.txt');
|
|
227
|
+
expect(result).toBe(path.join('Notes', '_context.txt'));
|
|
228
|
+
});
|
|
229
|
+
it('combines explicit filename with the folder argument', async () => {
|
|
230
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
231
|
+
const result = await createProjectNote('Title', '', 'Work', 'shortname');
|
|
232
|
+
expect(result).toBe(path.join('Notes', 'Work', 'shortname.md'));
|
|
233
|
+
});
|
|
234
|
+
it('preserves the title-derived heading even when filename overrides the on-disk name', async () => {
|
|
235
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
236
|
+
await createProjectNote('Human Title', '', undefined, '_short.md');
|
|
237
|
+
const calls = mockFs.writeFileSync.mock.calls;
|
|
238
|
+
// Default content path: "# {title}\n\n"
|
|
239
|
+
expect(calls[0]?.[1]).toBe('# Human Title\n\n');
|
|
240
|
+
});
|
|
241
|
+
it('rejects a filename containing a path separator', async () => {
|
|
242
|
+
await expect(createProjectNote('Title', '', undefined, 'Work/_context.md')).rejects.toThrow(/path separator/);
|
|
243
|
+
});
|
|
244
|
+
it('rejects path traversal via ".."', async () => {
|
|
245
|
+
// The regex catches `/`/`\\` first, but a bare ".." with no separator
|
|
246
|
+
// would also be split-checked. Verify both rejections.
|
|
247
|
+
await expect(createProjectNote('Title', '', undefined, '../escape.md')).rejects.toThrow();
|
|
248
|
+
});
|
|
249
|
+
it('rejects an empty filename', async () => {
|
|
250
|
+
await expect(createProjectNote('Title', '', undefined, ' ')).rejects.toThrow(/empty/);
|
|
251
|
+
});
|
|
252
|
+
it('replaces illegal filename chars (matching title-derived behavior)', async () => {
|
|
253
|
+
// `*` is illegal in filenames; sanitizeFilename replaces it with `-`,
|
|
254
|
+
// matching the behavior we apply to title-derived filenames.
|
|
255
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
256
|
+
const result = await createProjectNote('Title', '', undefined, '*.md');
|
|
257
|
+
expect(result).toBe(path.join('Notes', '-.md'));
|
|
258
|
+
});
|
|
259
|
+
it('detects collision against the same extension', async () => {
|
|
260
|
+
mockFs.existsSync.mockImplementation((p) => String(p) === '/np/Notes/_dup.md');
|
|
261
|
+
await expect(createProjectNote('Title', '', undefined, '_dup.md')).rejects.toThrow(/already exists/);
|
|
262
|
+
});
|
|
263
|
+
it('detects collision against the alternate extension', async () => {
|
|
264
|
+
// _dup.md requested but _dup.txt already on disk → still a collision.
|
|
265
|
+
mockFs.existsSync.mockImplementation((p) => String(p) === '/np/Notes/_dup.txt');
|
|
266
|
+
await expect(createProjectNote('Title', '', undefined, '_dup.md')).rejects.toThrow(/already exists/);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
159
269
|
});
|
|
160
270
|
// ---------------------------------------------------------------------------
|
|
161
271
|
// createCalendarNote
|
|
162
272
|
// ---------------------------------------------------------------------------
|
|
163
273
|
describe('createCalendarNote', () => {
|
|
164
|
-
it('creates calendar note and returns path', () => {
|
|
274
|
+
it('creates calendar note and returns path', async () => {
|
|
165
275
|
mockFs.existsSync.mockReturnValue(false);
|
|
166
|
-
const result = createCalendarNote('20240115', '# Jan 15');
|
|
276
|
+
const result = await createCalendarNote('20240115', '# Jan 15');
|
|
167
277
|
expect(result).toBe('Calendar/20240115.md');
|
|
168
278
|
});
|
|
169
279
|
});
|
|
170
280
|
// ---------------------------------------------------------------------------
|
|
281
|
+
// createCalendarNoteIfNew — collision-aware variant used by the
|
|
282
|
+
// noteplan_manage_note(action: create) calendar path.
|
|
283
|
+
// ---------------------------------------------------------------------------
|
|
284
|
+
describe('createCalendarNoteIfNew', () => {
|
|
285
|
+
it('creates a new calendar note when no file exists at either extension', async () => {
|
|
286
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
287
|
+
const result = await createCalendarNoteIfNew('2026-W16', '## Goals');
|
|
288
|
+
expect(result).toBe('Calendar/2026-W16.md');
|
|
289
|
+
expect(mockFs.writeFileSync).toHaveBeenCalled();
|
|
290
|
+
});
|
|
291
|
+
it('writes the provided content (empty string allowed)', async () => {
|
|
292
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
293
|
+
await createCalendarNoteIfNew('2026-Q2', '');
|
|
294
|
+
const calls = mockFs.writeFileSync.mock.calls;
|
|
295
|
+
expect(calls[0]?.[1]).toBe('');
|
|
296
|
+
});
|
|
297
|
+
it('rejects when a calendar note already exists at the same extension', async () => {
|
|
298
|
+
mockFs.existsSync.mockImplementation((p) => String(p) === '/np/Calendar/2026-W16.md');
|
|
299
|
+
await expect(createCalendarNoteIfNew('2026-W16', '')).rejects.toThrow(/already exists/);
|
|
300
|
+
});
|
|
301
|
+
it('rejects when a calendar note exists at the alternate extension', async () => {
|
|
302
|
+
mockFs.existsSync.mockImplementation((p) => String(p) === '/np/Calendar/2026-W16.txt');
|
|
303
|
+
await expect(createCalendarNoteIfNew('2026-W16', '')).rejects.toThrow(/already exists/);
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
171
307
|
// ensureCalendarNote
|
|
172
308
|
// ---------------------------------------------------------------------------
|
|
173
309
|
describe('ensureCalendarNote', () => {
|
|
174
|
-
it('returns existing note path when found', () => {
|
|
175
|
-
vi.mocked(getCalendarNote).
|
|
310
|
+
it('returns existing note path when found', async () => {
|
|
311
|
+
vi.mocked(getCalendarNote).mockResolvedValueOnce({
|
|
176
312
|
filename: 'Calendar/20240115.md',
|
|
177
313
|
});
|
|
178
|
-
const result = ensureCalendarNote('20240115');
|
|
314
|
+
const result = await ensureCalendarNote('20240115');
|
|
179
315
|
expect(result).toBe('Calendar/20240115.md');
|
|
180
316
|
expect(mockFs.writeFileSync).not.toHaveBeenCalled();
|
|
181
317
|
});
|
|
182
|
-
it('creates new note when none exists', () => {
|
|
183
|
-
vi.mocked(getCalendarNote).
|
|
318
|
+
it('creates new note when none exists', async () => {
|
|
319
|
+
vi.mocked(getCalendarNote).mockResolvedValueOnce(null);
|
|
184
320
|
mockFs.existsSync.mockReturnValue(false);
|
|
185
|
-
const result = ensureCalendarNote('20240115');
|
|
321
|
+
const result = await ensureCalendarNote('20240115');
|
|
186
322
|
expect(result).toBe('Calendar/20240115.md');
|
|
187
323
|
expect(mockFs.writeFileSync).toHaveBeenCalled();
|
|
188
324
|
});
|
|
@@ -191,70 +327,70 @@ describe('ensureCalendarNote', () => {
|
|
|
191
327
|
// appendToNote
|
|
192
328
|
// ---------------------------------------------------------------------------
|
|
193
329
|
describe('appendToNote', () => {
|
|
194
|
-
it('appends content to existing note', () => {
|
|
330
|
+
it('appends content to existing note', async () => {
|
|
195
331
|
mockFs.existsSync.mockReturnValue(true);
|
|
196
332
|
mockFs.readFileSync.mockReturnValue('existing\n');
|
|
197
|
-
appendToNote('Notes/test.md', 'appended');
|
|
333
|
+
await appendToNote('Notes/test.md', 'appended');
|
|
198
334
|
const written = mockFs.writeFileSync.mock.calls[0]?.[1];
|
|
199
335
|
expect(written).toBe('existing\nappended');
|
|
200
336
|
});
|
|
201
|
-
it('adds newline before content if note does not end with one', () => {
|
|
337
|
+
it('adds newline before content if note does not end with one', async () => {
|
|
202
338
|
mockFs.existsSync.mockReturnValue(true);
|
|
203
339
|
mockFs.readFileSync.mockReturnValue('existing');
|
|
204
|
-
appendToNote('Notes/test.md', 'appended');
|
|
340
|
+
await appendToNote('Notes/test.md', 'appended');
|
|
205
341
|
const written = mockFs.writeFileSync.mock.calls[0]?.[1];
|
|
206
342
|
expect(written).toBe('existing\nappended');
|
|
207
343
|
});
|
|
208
|
-
it('throws if note does not exist', () => {
|
|
344
|
+
it('throws if note does not exist', async () => {
|
|
209
345
|
mockFs.existsSync.mockReturnValue(false);
|
|
210
|
-
expect(
|
|
346
|
+
await expect(appendToNote('Notes/nope.md', 'x')).rejects.toThrow('Note not found');
|
|
211
347
|
});
|
|
212
348
|
});
|
|
213
349
|
// ---------------------------------------------------------------------------
|
|
214
350
|
// prependToNote
|
|
215
351
|
// ---------------------------------------------------------------------------
|
|
216
352
|
describe('prependToNote', () => {
|
|
217
|
-
it('inserts after frontmatter', () => {
|
|
353
|
+
it('inserts after frontmatter', async () => {
|
|
218
354
|
mockFs.existsSync.mockReturnValue(true);
|
|
219
355
|
mockFs.readFileSync.mockReturnValue('---\ntitle: X\n---\nbody');
|
|
220
|
-
prependToNote('Notes/test.md', 'PREPENDED');
|
|
356
|
+
await prependToNote('Notes/test.md', 'PREPENDED');
|
|
221
357
|
const written = mockFs.writeFileSync.mock.calls[0]?.[1];
|
|
222
358
|
// lines: ['---', 'title: X', '---', 'body']
|
|
223
359
|
// insertIndex = 3, so content goes at index 3
|
|
224
360
|
expect(written).toBe('---\ntitle: X\n---\nPREPENDED\nbody');
|
|
225
361
|
});
|
|
226
|
-
it('inserts at top if no frontmatter', () => {
|
|
362
|
+
it('inserts at top if no frontmatter', async () => {
|
|
227
363
|
mockFs.existsSync.mockReturnValue(true);
|
|
228
364
|
mockFs.readFileSync.mockReturnValue('# Title\nBody');
|
|
229
|
-
prependToNote('Notes/test.md', 'PREPENDED');
|
|
365
|
+
await prependToNote('Notes/test.md', 'PREPENDED');
|
|
230
366
|
const written = mockFs.writeFileSync.mock.calls[0]?.[1];
|
|
231
367
|
expect(written).toBe('PREPENDED\n# Title\nBody');
|
|
232
368
|
});
|
|
233
|
-
it('throws if note does not exist', () => {
|
|
369
|
+
it('throws if note does not exist', async () => {
|
|
234
370
|
mockFs.existsSync.mockReturnValue(false);
|
|
235
|
-
expect(
|
|
371
|
+
await expect(prependToNote('Notes/nope.md', 'x')).rejects.toThrow('Note not found');
|
|
236
372
|
});
|
|
237
373
|
});
|
|
238
374
|
// ---------------------------------------------------------------------------
|
|
239
375
|
// updateNote
|
|
240
376
|
// ---------------------------------------------------------------------------
|
|
241
377
|
describe('updateNote', () => {
|
|
242
|
-
it('replaces entire note content', () => {
|
|
378
|
+
it('replaces entire note content', async () => {
|
|
243
379
|
mockFs.existsSync.mockReturnValue(true);
|
|
244
|
-
updateNote('Notes/test.md', 'new content');
|
|
380
|
+
await updateNote('Notes/test.md', 'new content');
|
|
245
381
|
const written = mockFs.writeFileSync.mock.calls[0]?.[1];
|
|
246
382
|
expect(written).toBe('new content');
|
|
247
383
|
});
|
|
248
|
-
it('throws if note does not exist', () => {
|
|
384
|
+
it('throws if note does not exist', async () => {
|
|
249
385
|
mockFs.existsSync.mockReturnValue(false);
|
|
250
|
-
expect(
|
|
386
|
+
await expect(updateNote('Notes/nope.md', 'x')).rejects.toThrow('Note not found');
|
|
251
387
|
});
|
|
252
388
|
});
|
|
253
389
|
// ---------------------------------------------------------------------------
|
|
254
390
|
// deleteNote
|
|
255
391
|
// ---------------------------------------------------------------------------
|
|
256
392
|
describe('deleteNote', () => {
|
|
257
|
-
it('moves file to @Trash folder', () => {
|
|
393
|
+
it('moves file to @Trash folder', async () => {
|
|
258
394
|
mockFs.existsSync.mockImplementation((p) => {
|
|
259
395
|
const s = String(p);
|
|
260
396
|
if (s === '/np/Notes/test.md')
|
|
@@ -263,21 +399,21 @@ describe('deleteNote', () => {
|
|
|
263
399
|
return true;
|
|
264
400
|
return false; // trash target does not exist yet
|
|
265
401
|
});
|
|
266
|
-
const result = deleteNote('Notes/test.md');
|
|
402
|
+
const result = await deleteNote('Notes/test.md');
|
|
267
403
|
expect(result).toBe(path.join('Notes', '@Trash', 'test.md'));
|
|
268
404
|
expect(mockFs.renameSync).toHaveBeenCalledWith('/np/Notes/test.md', '/np/Notes/@Trash/test.md');
|
|
269
405
|
});
|
|
270
|
-
it('creates @Trash if it does not exist', () => {
|
|
406
|
+
it('creates @Trash if it does not exist', async () => {
|
|
271
407
|
mockFs.existsSync.mockImplementation((p) => {
|
|
272
408
|
const s = String(p);
|
|
273
409
|
if (s === '/np/Notes/test.md')
|
|
274
410
|
return true;
|
|
275
411
|
return false;
|
|
276
412
|
});
|
|
277
|
-
deleteNote('Notes/test.md');
|
|
413
|
+
await deleteNote('Notes/test.md');
|
|
278
414
|
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/np/Notes/@Trash', { recursive: true });
|
|
279
415
|
});
|
|
280
|
-
it('handles duplicate names in trash (appends -1, -2, etc.)', () => {
|
|
416
|
+
it('handles duplicate names in trash (appends -1, -2, etc.)', async () => {
|
|
281
417
|
let callCount = 0;
|
|
282
418
|
mockFs.existsSync.mockImplementation((p) => {
|
|
283
419
|
const s = String(p);
|
|
@@ -293,14 +429,14 @@ describe('deleteNote', () => {
|
|
|
293
429
|
return false; // free
|
|
294
430
|
return false;
|
|
295
431
|
});
|
|
296
|
-
const result = deleteNote('Notes/test.md');
|
|
432
|
+
const result = await deleteNote('Notes/test.md');
|
|
297
433
|
expect(result).toBe(path.join('Notes', '@Trash', 'test-2.md'));
|
|
298
434
|
});
|
|
299
|
-
it('throws if file does not exist', () => {
|
|
435
|
+
it('throws if file does not exist', async () => {
|
|
300
436
|
mockFs.existsSync.mockReturnValue(false);
|
|
301
|
-
expect(
|
|
437
|
+
await expect(deleteNote('Notes/nope.md')).rejects.toThrow('Note not found');
|
|
302
438
|
});
|
|
303
|
-
it('uses EPERM fallback for moveFile (copy + delete)', () => {
|
|
439
|
+
it('uses EPERM fallback for moveFile (copy + delete)', async () => {
|
|
304
440
|
mockFs.existsSync.mockImplementation((p) => {
|
|
305
441
|
const s = String(p);
|
|
306
442
|
if (s === '/np/Notes/test.md')
|
|
@@ -313,11 +449,11 @@ describe('deleteNote', () => {
|
|
|
313
449
|
mockFs.renameSync.mockImplementationOnce(() => {
|
|
314
450
|
throw eperm;
|
|
315
451
|
});
|
|
316
|
-
deleteNote('Notes/test.md');
|
|
452
|
+
await deleteNote('Notes/test.md');
|
|
317
453
|
expect(mockFs.copyFileSync).toHaveBeenCalledWith('/np/Notes/test.md', '/np/Notes/@Trash/test.md');
|
|
318
454
|
expect(mockFs.unlinkSync).toHaveBeenCalledWith('/np/Notes/test.md');
|
|
319
455
|
});
|
|
320
|
-
it('uses EXDEV fallback for moveFile (copy + delete)', () => {
|
|
456
|
+
it('uses EXDEV fallback for moveFile (copy + delete)', async () => {
|
|
321
457
|
mockFs.existsSync.mockImplementation((p) => {
|
|
322
458
|
const s = String(p);
|
|
323
459
|
if (s === '/np/Notes/test.md')
|
|
@@ -330,10 +466,92 @@ describe('deleteNote', () => {
|
|
|
330
466
|
mockFs.renameSync.mockImplementationOnce(() => {
|
|
331
467
|
throw exdev;
|
|
332
468
|
});
|
|
333
|
-
deleteNote('Notes/test.md');
|
|
469
|
+
await deleteNote('Notes/test.md');
|
|
334
470
|
expect(mockFs.copyFileSync).toHaveBeenCalled();
|
|
335
471
|
expect(mockFs.unlinkSync).toHaveBeenCalled();
|
|
336
472
|
});
|
|
473
|
+
it('also trashes the sibling _attachments folder', async () => {
|
|
474
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
475
|
+
const s = String(p);
|
|
476
|
+
if (s === '/np/Notes/Photo.md')
|
|
477
|
+
return true;
|
|
478
|
+
if (s === '/np/Notes/Photo_attachments')
|
|
479
|
+
return true;
|
|
480
|
+
if (s === '/np/Notes/@Trash')
|
|
481
|
+
return true;
|
|
482
|
+
return false;
|
|
483
|
+
});
|
|
484
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
485
|
+
await deleteNote('Notes/Photo.md');
|
|
486
|
+
const renameCalls = mockFs.renameSync.mock.calls.map((c) => [String(c[0]), String(c[1])]);
|
|
487
|
+
expect(renameCalls).toContainEqual(['/np/Notes/Photo.md', '/np/Notes/@Trash/Photo.md']);
|
|
488
|
+
expect(renameCalls).toContainEqual([
|
|
489
|
+
'/np/Notes/Photo_attachments',
|
|
490
|
+
'/np/Notes/@Trash/Photo_attachments',
|
|
491
|
+
]);
|
|
492
|
+
});
|
|
493
|
+
it('matches the disambiguated trash basename when the note collides', async () => {
|
|
494
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
495
|
+
const s = String(p);
|
|
496
|
+
if (s === '/np/Notes/Dup.md')
|
|
497
|
+
return true;
|
|
498
|
+
if (s === '/np/Notes/Dup_attachments')
|
|
499
|
+
return true;
|
|
500
|
+
if (s === '/np/Notes/@Trash')
|
|
501
|
+
return true;
|
|
502
|
+
if (s === '/np/Notes/@Trash/Dup.md')
|
|
503
|
+
return true; // collision → suffix to Dup-1
|
|
504
|
+
return false;
|
|
505
|
+
});
|
|
506
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
507
|
+
const result = await deleteNote('Notes/Dup.md');
|
|
508
|
+
expect(result).toBe(path.join('Notes', '@Trash', 'Dup-1.md'));
|
|
509
|
+
const renameCalls = mockFs.renameSync.mock.calls.map((c) => [String(c[0]), String(c[1])]);
|
|
510
|
+
// The attachments folder follows the disambiguated note name so a
|
|
511
|
+
// restore would still find them sitting next to the .md file.
|
|
512
|
+
expect(renameCalls).toContainEqual([
|
|
513
|
+
'/np/Notes/Dup_attachments',
|
|
514
|
+
'/np/Notes/@Trash/Dup-1_attachments',
|
|
515
|
+
]);
|
|
516
|
+
});
|
|
517
|
+
// When trash collision suffixes the basename (Dup → Dup-1), the in-content
|
|
518
|
+
// attachment links still point at the original Dup_attachments/. Without
|
|
519
|
+
// rewriting them, restoring would produce broken images.
|
|
520
|
+
it('rewrites in-content attachment links when the trash basename gets a suffix', async () => {
|
|
521
|
+
let onDiskContent = '# Dup\n\n\n[file](Dup_attachments/notes.pdf)\n';
|
|
522
|
+
let trashed = false;
|
|
523
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
524
|
+
const s = String(p);
|
|
525
|
+
if (s === '/np/Notes/Dup.md')
|
|
526
|
+
return !trashed;
|
|
527
|
+
if (s === '/np/Notes/@Trash/Dup-1.md')
|
|
528
|
+
return trashed;
|
|
529
|
+
if (s === '/np/Notes/@Trash')
|
|
530
|
+
return true;
|
|
531
|
+
if (s === '/np/Notes/@Trash/Dup.md')
|
|
532
|
+
return true; // forces -1 suffix
|
|
533
|
+
return false;
|
|
534
|
+
});
|
|
535
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
536
|
+
mockFs.renameSync.mockImplementation((from, to) => {
|
|
537
|
+
if (String(from) === '/np/Notes/Dup.md' && String(to) === '/np/Notes/@Trash/Dup-1.md') {
|
|
538
|
+
trashed = true;
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
mockFs.readFileSync.mockImplementation((p) => {
|
|
542
|
+
if (String(p) === '/np/Notes/@Trash/Dup-1.md')
|
|
543
|
+
return onDiskContent;
|
|
544
|
+
throw new Error('unexpected read: ' + p);
|
|
545
|
+
});
|
|
546
|
+
mockFs.writeFileSync.mockImplementation((p, content) => {
|
|
547
|
+
if (String(p) === '/np/Notes/@Trash/Dup-1.md')
|
|
548
|
+
onDiskContent = content;
|
|
549
|
+
});
|
|
550
|
+
await deleteNote('Notes/Dup.md');
|
|
551
|
+
expect(onDiskContent).toContain('Dup-1_attachments/photo.png');
|
|
552
|
+
expect(onDiskContent).toContain('Dup-1_attachments/notes.pdf');
|
|
553
|
+
expect(onDiskContent).not.toContain('Dup_attachments/');
|
|
554
|
+
});
|
|
337
555
|
});
|
|
338
556
|
// ---------------------------------------------------------------------------
|
|
339
557
|
// previewMoveLocalNote
|
|
@@ -348,28 +566,28 @@ describe('previewMoveLocalNote', () => {
|
|
|
348
566
|
});
|
|
349
567
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
350
568
|
}
|
|
351
|
-
it('returns preview with fromFilename, toFilename, destinationFolder', () => {
|
|
569
|
+
it('returns preview with fromFilename, toFilename, destinationFolder', async () => {
|
|
352
570
|
setupMovePreview();
|
|
353
|
-
const result = previewMoveLocalNote('Notes/test.md', 'Notes/Work');
|
|
571
|
+
const result = await previewMoveLocalNote('Notes/test.md', 'Notes/Work');
|
|
354
572
|
expect(result.fromFilename).toBe(path.join('Notes', 'test.md'));
|
|
355
573
|
expect(result.toFilename).toBe(path.join('Notes', 'Work', 'test.md'));
|
|
356
574
|
expect(result.destinationFolder).toBe('Notes/Work');
|
|
357
575
|
});
|
|
358
|
-
it('validates source exists', () => {
|
|
576
|
+
it('validates source exists', async () => {
|
|
359
577
|
mockFs.existsSync.mockReturnValue(false);
|
|
360
|
-
expect(
|
|
578
|
+
await expect(previewMoveLocalNote('Notes/nope.md', 'Notes/Work')).rejects.toThrow('Note not found');
|
|
361
579
|
});
|
|
362
|
-
it('rejects directories', () => {
|
|
580
|
+
it('rejects directories', async () => {
|
|
363
581
|
mockFs.existsSync.mockReturnValue(true);
|
|
364
582
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true, isFile: () => false });
|
|
365
|
-
expect(
|
|
583
|
+
await expect(previewMoveLocalNote('Notes/folder', 'Notes/Work')).rejects.toThrow('Not a note file');
|
|
366
584
|
});
|
|
367
|
-
it('rejects if source is outside Notes folder', () => {
|
|
585
|
+
it('rejects if source is outside Notes folder', async () => {
|
|
368
586
|
mockFs.existsSync.mockReturnValue(true);
|
|
369
587
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
370
|
-
expect(
|
|
588
|
+
await expect(previewMoveLocalNote('Calendar/20240101.md', 'Notes/Work')).rejects.toThrow('must be inside Notes');
|
|
371
589
|
});
|
|
372
|
-
it('rejects if already at destination', () => {
|
|
590
|
+
it('rejects if already at destination', async () => {
|
|
373
591
|
mockFs.existsSync.mockImplementation((p) => {
|
|
374
592
|
const s = String(p);
|
|
375
593
|
if (s === '/np/Notes/Work/test.md')
|
|
@@ -377,9 +595,9 @@ describe('previewMoveLocalNote', () => {
|
|
|
377
595
|
return false;
|
|
378
596
|
});
|
|
379
597
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
380
|
-
expect(
|
|
598
|
+
await expect(previewMoveLocalNote('Notes/Work/test.md', 'Notes/Work')).rejects.toThrow('already in the destination');
|
|
381
599
|
});
|
|
382
|
-
it('rejects if conflict at destination', () => {
|
|
600
|
+
it('rejects if conflict at destination', async () => {
|
|
383
601
|
mockFs.existsSync.mockImplementation((p) => {
|
|
384
602
|
const s = String(p);
|
|
385
603
|
if (s === '/np/Notes/test.md')
|
|
@@ -389,25 +607,25 @@ describe('previewMoveLocalNote', () => {
|
|
|
389
607
|
return false;
|
|
390
608
|
});
|
|
391
609
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
392
|
-
expect(
|
|
610
|
+
await expect(previewMoveLocalNote('Notes/test.md', 'Notes/Work')).rejects.toThrow('already exists at destination');
|
|
393
611
|
});
|
|
394
|
-
it('handles folder input without Notes/ prefix', () => {
|
|
612
|
+
it('handles folder input without Notes/ prefix', async () => {
|
|
395
613
|
setupMovePreview();
|
|
396
|
-
const result = previewMoveLocalNote('Notes/test.md', 'Work');
|
|
614
|
+
const result = await previewMoveLocalNote('Notes/test.md', 'Work');
|
|
397
615
|
expect(result.destinationFolder).toBe('Notes/Work');
|
|
398
616
|
});
|
|
399
|
-
it('handles folder input with trailing slash', () => {
|
|
617
|
+
it('handles folder input with trailing slash', async () => {
|
|
400
618
|
setupMovePreview();
|
|
401
|
-
const result = previewMoveLocalNote('Notes/test.md', 'Work/');
|
|
619
|
+
const result = await previewMoveLocalNote('Notes/test.md', 'Work/');
|
|
402
620
|
expect(result.destinationFolder).toBe('Notes/Work');
|
|
403
621
|
});
|
|
404
|
-
it('rejects when destinationFolder looks like a different filename', () => {
|
|
622
|
+
it('rejects when destinationFolder looks like a different filename', async () => {
|
|
405
623
|
setupMovePreview();
|
|
406
|
-
expect(
|
|
624
|
+
await expect(previewMoveLocalNote('Notes/test.md', 'Work/other.md')).rejects.toThrow('must be a folder path, not a filename');
|
|
407
625
|
});
|
|
408
|
-
it('strips same filename from destination path', () => {
|
|
626
|
+
it('strips same filename from destination path', async () => {
|
|
409
627
|
setupMovePreview();
|
|
410
|
-
const result = previewMoveLocalNote('Notes/test.md', 'Work/test.md');
|
|
628
|
+
const result = await previewMoveLocalNote('Notes/test.md', 'Work/test.md');
|
|
411
629
|
expect(result.destinationFolder).toBe('Notes/Work');
|
|
412
630
|
});
|
|
413
631
|
});
|
|
@@ -415,7 +633,7 @@ describe('previewMoveLocalNote', () => {
|
|
|
415
633
|
// moveLocalNote
|
|
416
634
|
// ---------------------------------------------------------------------------
|
|
417
635
|
describe('moveLocalNote', () => {
|
|
418
|
-
it('moves note between folders', () => {
|
|
636
|
+
it('moves note between folders', async () => {
|
|
419
637
|
mockFs.existsSync.mockImplementation((p) => {
|
|
420
638
|
const s = String(p);
|
|
421
639
|
if (s === '/np/Notes/test.md')
|
|
@@ -425,12 +643,12 @@ describe('moveLocalNote', () => {
|
|
|
425
643
|
return false;
|
|
426
644
|
});
|
|
427
645
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
428
|
-
const result = moveLocalNote('Notes/test.md', 'Work');
|
|
646
|
+
const result = await moveLocalNote('Notes/test.md', 'Work');
|
|
429
647
|
expect(result).toBe(path.join('Notes', 'Work', 'test.md'));
|
|
430
648
|
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/np/Notes/Work', { recursive: true });
|
|
431
649
|
expect(mockFs.renameSync).toHaveBeenCalled();
|
|
432
650
|
});
|
|
433
|
-
it('creates destination folder if needed', () => {
|
|
651
|
+
it('creates destination folder if needed', async () => {
|
|
434
652
|
mockFs.existsSync.mockImplementation((p) => {
|
|
435
653
|
const s = String(p);
|
|
436
654
|
if (s === '/np/Notes/test.md')
|
|
@@ -438,15 +656,51 @@ describe('moveLocalNote', () => {
|
|
|
438
656
|
return false;
|
|
439
657
|
});
|
|
440
658
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
441
|
-
moveLocalNote('Notes/test.md', 'NewFolder');
|
|
659
|
+
await moveLocalNote('Notes/test.md', 'NewFolder');
|
|
442
660
|
expect(mockFs.mkdirSync).toHaveBeenCalled();
|
|
443
661
|
});
|
|
662
|
+
// Regression: previously the move only relocated the .md file, leaving
|
|
663
|
+
// the sibling _attachments folder behind and silently breaking every
|
|
664
|
+
// embedded image/file reference in the moved note.
|
|
665
|
+
it('also moves the sibling _attachments folder when present', async () => {
|
|
666
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
667
|
+
const s = String(p);
|
|
668
|
+
// Note + its attachments folder both exist; destination is empty.
|
|
669
|
+
if (s === '/np/Notes/Photo.md')
|
|
670
|
+
return true;
|
|
671
|
+
if (s === '/np/Notes/Photo_attachments')
|
|
672
|
+
return true;
|
|
673
|
+
return false;
|
|
674
|
+
});
|
|
675
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
676
|
+
await moveLocalNote('Notes/Photo.md', 'Albums');
|
|
677
|
+
const renameCalls = mockFs.renameSync.mock.calls.map((c) => [String(c[0]), String(c[1])]);
|
|
678
|
+
expect(renameCalls).toContainEqual(['/np/Notes/Photo.md', '/np/Notes/Albums/Photo.md']);
|
|
679
|
+
expect(renameCalls).toContainEqual([
|
|
680
|
+
'/np/Notes/Photo_attachments',
|
|
681
|
+
'/np/Notes/Albums/Photo_attachments',
|
|
682
|
+
]);
|
|
683
|
+
});
|
|
684
|
+
it('skips the attachments move when the folder is absent', async () => {
|
|
685
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
686
|
+
const s = String(p);
|
|
687
|
+
if (s === '/np/Notes/Plain.md')
|
|
688
|
+
return true;
|
|
689
|
+
return false; // no Plain_attachments
|
|
690
|
+
});
|
|
691
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
692
|
+
await moveLocalNote('Notes/Plain.md', 'Work');
|
|
693
|
+
const renameCalls = mockFs.renameSync.mock.calls.map((c) => [String(c[0]), String(c[1])]);
|
|
694
|
+
// Only the file itself was moved, no attachments folder rename.
|
|
695
|
+
expect(renameCalls).toHaveLength(1);
|
|
696
|
+
expect(renameCalls[0]).toEqual(['/np/Notes/Plain.md', '/np/Notes/Work/Plain.md']);
|
|
697
|
+
});
|
|
444
698
|
});
|
|
445
699
|
// ---------------------------------------------------------------------------
|
|
446
700
|
// previewRestoreLocalNoteFromTrash
|
|
447
701
|
// ---------------------------------------------------------------------------
|
|
448
702
|
describe('previewRestoreLocalNoteFromTrash', () => {
|
|
449
|
-
it('returns preview for restoring from trash', () => {
|
|
703
|
+
it('returns preview for restoring from trash', async () => {
|
|
450
704
|
mockFs.existsSync.mockImplementation((p) => {
|
|
451
705
|
const s = String(p);
|
|
452
706
|
if (s === '/np/Notes/@Trash/test.md')
|
|
@@ -454,20 +708,20 @@ describe('previewRestoreLocalNoteFromTrash', () => {
|
|
|
454
708
|
return false;
|
|
455
709
|
});
|
|
456
710
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
457
|
-
const result = previewRestoreLocalNoteFromTrash('Notes/@Trash/test.md', 'Notes');
|
|
711
|
+
const result = await previewRestoreLocalNoteFromTrash('Notes/@Trash/test.md', 'Notes');
|
|
458
712
|
expect(result.fromFilename).toBe(path.join('Notes', '@Trash', 'test.md'));
|
|
459
713
|
expect(result.toFilename).toBe(path.join('Notes', 'test.md'));
|
|
460
714
|
});
|
|
461
|
-
it('throws if source not found', () => {
|
|
715
|
+
it('throws if source not found', async () => {
|
|
462
716
|
mockFs.existsSync.mockReturnValue(false);
|
|
463
|
-
expect(
|
|
717
|
+
await expect(previewRestoreLocalNoteFromTrash('Notes/@Trash/nope.md', 'Notes')).rejects.toThrow('Note not found');
|
|
464
718
|
});
|
|
465
|
-
it('throws if source is not inside @Trash', () => {
|
|
719
|
+
it('throws if source is not inside @Trash', async () => {
|
|
466
720
|
mockFs.existsSync.mockReturnValue(true);
|
|
467
721
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
468
|
-
expect(
|
|
722
|
+
await expect(previewRestoreLocalNoteFromTrash('Notes/test.md', 'Notes')).rejects.toThrow('must be inside @Trash');
|
|
469
723
|
});
|
|
470
|
-
it('throws if conflict at destination', () => {
|
|
724
|
+
it('throws if conflict at destination', async () => {
|
|
471
725
|
mockFs.existsSync.mockImplementation((p) => {
|
|
472
726
|
const s = String(p);
|
|
473
727
|
if (s === '/np/Notes/@Trash/test.md')
|
|
@@ -477,14 +731,14 @@ describe('previewRestoreLocalNoteFromTrash', () => {
|
|
|
477
731
|
return false;
|
|
478
732
|
});
|
|
479
733
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
480
|
-
expect(
|
|
734
|
+
await expect(previewRestoreLocalNoteFromTrash('Notes/@Trash/test.md', 'Notes')).rejects.toThrow('already exists at destination');
|
|
481
735
|
});
|
|
482
736
|
});
|
|
483
737
|
// ---------------------------------------------------------------------------
|
|
484
738
|
// restoreLocalNoteFromTrash
|
|
485
739
|
// ---------------------------------------------------------------------------
|
|
486
740
|
describe('restoreLocalNoteFromTrash', () => {
|
|
487
|
-
it('restores note from trash to destination', () => {
|
|
741
|
+
it('restores note from trash to destination', async () => {
|
|
488
742
|
mockFs.existsSync.mockImplementation((p) => {
|
|
489
743
|
const s = String(p);
|
|
490
744
|
if (s === '/np/Notes/@Trash/test.md')
|
|
@@ -494,10 +748,30 @@ describe('restoreLocalNoteFromTrash', () => {
|
|
|
494
748
|
return false;
|
|
495
749
|
});
|
|
496
750
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
497
|
-
const result = restoreLocalNoteFromTrash('Notes/@Trash/test.md', 'Notes');
|
|
751
|
+
const result = await restoreLocalNoteFromTrash('Notes/@Trash/test.md', 'Notes');
|
|
498
752
|
expect(result).toBe(path.join('Notes', 'test.md'));
|
|
499
753
|
expect(mockFs.renameSync).toHaveBeenCalled();
|
|
500
754
|
});
|
|
755
|
+
it('also restores the sibling _attachments folder when present', async () => {
|
|
756
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
757
|
+
const s = String(p);
|
|
758
|
+
if (s === '/np/Notes/@Trash/Photo.md')
|
|
759
|
+
return true;
|
|
760
|
+
if (s === '/np/Notes/@Trash/Photo_attachments')
|
|
761
|
+
return true;
|
|
762
|
+
if (s === '/np/Notes')
|
|
763
|
+
return true;
|
|
764
|
+
return false;
|
|
765
|
+
});
|
|
766
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
767
|
+
await restoreLocalNoteFromTrash('Notes/@Trash/Photo.md', 'Notes');
|
|
768
|
+
const renameCalls = mockFs.renameSync.mock.calls.map((c) => [String(c[0]), String(c[1])]);
|
|
769
|
+
expect(renameCalls).toContainEqual(['/np/Notes/@Trash/Photo.md', '/np/Notes/Photo.md']);
|
|
770
|
+
expect(renameCalls).toContainEqual([
|
|
771
|
+
'/np/Notes/@Trash/Photo_attachments',
|
|
772
|
+
'/np/Notes/Photo_attachments',
|
|
773
|
+
]);
|
|
774
|
+
});
|
|
501
775
|
});
|
|
502
776
|
// ---------------------------------------------------------------------------
|
|
503
777
|
// previewRenameLocalNoteFile
|
|
@@ -512,28 +786,28 @@ describe('previewRenameLocalNoteFile', () => {
|
|
|
512
786
|
});
|
|
513
787
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
514
788
|
}
|
|
515
|
-
it('returns preview with fromFilename and toFilename', () => {
|
|
789
|
+
it('returns preview with fromFilename and toFilename', async () => {
|
|
516
790
|
setupRenamePreview();
|
|
517
|
-
const result = previewRenameLocalNoteFile('Notes/old.md', 'new');
|
|
791
|
+
const result = await previewRenameLocalNoteFile('Notes/old.md', 'new');
|
|
518
792
|
expect(result.fromFilename).toBe(path.join('Notes', 'old.md'));
|
|
519
793
|
expect(result.toFilename).toBe(path.join('Notes', 'new.md'));
|
|
520
794
|
});
|
|
521
|
-
it('keepExtension=true preserves original extension', () => {
|
|
795
|
+
it('keepExtension=true preserves original extension', async () => {
|
|
522
796
|
setupRenamePreview();
|
|
523
|
-
const result = previewRenameLocalNoteFile('Notes/old.md', 'new.txt', true);
|
|
797
|
+
const result = await previewRenameLocalNoteFile('Notes/old.md', 'new.txt', true);
|
|
524
798
|
// keepExtension=true means keep .md
|
|
525
799
|
expect(result.toFilename).toBe(path.join('Notes', 'new.md'));
|
|
526
800
|
});
|
|
527
|
-
it('keepExtension=false uses provided extension', () => {
|
|
801
|
+
it('keepExtension=false uses provided extension', async () => {
|
|
528
802
|
setupRenamePreview();
|
|
529
|
-
const result = previewRenameLocalNoteFile('Notes/old.md', 'new.txt', false);
|
|
803
|
+
const result = await previewRenameLocalNoteFile('Notes/old.md', 'new.txt', false);
|
|
530
804
|
expect(result.toFilename).toBe(path.join('Notes', 'new.txt'));
|
|
531
805
|
});
|
|
532
|
-
it('throws if new name matches current name', () => {
|
|
806
|
+
it('throws if new name matches current name', async () => {
|
|
533
807
|
setupRenamePreview();
|
|
534
|
-
expect(
|
|
808
|
+
await expect(previewRenameLocalNoteFile('Notes/old.md', 'old')).rejects.toThrow('matches current filename');
|
|
535
809
|
});
|
|
536
|
-
it('throws if new name already exists', () => {
|
|
810
|
+
it('throws if new name already exists', async () => {
|
|
537
811
|
mockFs.existsSync.mockImplementation((p) => {
|
|
538
812
|
const s = String(p);
|
|
539
813
|
if (s === '/np/Notes/old.md')
|
|
@@ -543,23 +817,23 @@ describe('previewRenameLocalNoteFile', () => {
|
|
|
543
817
|
return false;
|
|
544
818
|
});
|
|
545
819
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
546
|
-
expect(
|
|
820
|
+
await expect(previewRenameLocalNoteFile('Notes/old.md', 'taken')).rejects.toThrow('already exists with filename');
|
|
547
821
|
});
|
|
548
|
-
it('throws if source not found', () => {
|
|
822
|
+
it('throws if source not found', async () => {
|
|
549
823
|
mockFs.existsSync.mockReturnValue(false);
|
|
550
|
-
expect(
|
|
824
|
+
await expect(previewRenameLocalNoteFile('Notes/nope.md', 'new')).rejects.toThrow('Note not found');
|
|
551
825
|
});
|
|
552
|
-
it('throws if source is a directory', () => {
|
|
826
|
+
it('throws if source is a directory', async () => {
|
|
553
827
|
mockFs.existsSync.mockReturnValue(true);
|
|
554
828
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true, isFile: () => false });
|
|
555
|
-
expect(
|
|
829
|
+
await expect(previewRenameLocalNoteFile('Notes/folder', 'new')).rejects.toThrow('Not a note file');
|
|
556
830
|
});
|
|
557
|
-
it('sanitizes special characters in rename', () => {
|
|
831
|
+
it('sanitizes special characters in rename', async () => {
|
|
558
832
|
setupRenamePreview();
|
|
559
|
-
const result = previewRenameLocalNoteFile('Notes/old.md', 'he?lo');
|
|
833
|
+
const result = await previewRenameLocalNoteFile('Notes/old.md', 'he?lo');
|
|
560
834
|
expect(result.toFilename).toBe(path.join('Notes', 'he-lo.md'));
|
|
561
835
|
});
|
|
562
|
-
it('rejects rename that changes folder', () => {
|
|
836
|
+
it('rejects rename that changes folder', async () => {
|
|
563
837
|
mockFs.existsSync.mockImplementation((p) => {
|
|
564
838
|
const s = String(p);
|
|
565
839
|
if (s === '/np/Notes/old.md')
|
|
@@ -567,14 +841,14 @@ describe('previewRenameLocalNoteFile', () => {
|
|
|
567
841
|
return false;
|
|
568
842
|
});
|
|
569
843
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
570
|
-
expect(
|
|
844
|
+
await expect(previewRenameLocalNoteFile('Notes/old.md', 'OtherFolder/new')).rejects.toThrow('must stay in the same folder');
|
|
571
845
|
});
|
|
572
846
|
});
|
|
573
847
|
// ---------------------------------------------------------------------------
|
|
574
848
|
// renameLocalNoteFile
|
|
575
849
|
// ---------------------------------------------------------------------------
|
|
576
850
|
describe('renameLocalNoteFile', () => {
|
|
577
|
-
it('renames file within same folder', () => {
|
|
851
|
+
it('renames file within same folder', async () => {
|
|
578
852
|
mockFs.existsSync.mockImplementation((p) => {
|
|
579
853
|
const s = String(p);
|
|
580
854
|
if (s === '/np/Notes/old.md')
|
|
@@ -582,49 +856,119 @@ describe('renameLocalNoteFile', () => {
|
|
|
582
856
|
return false;
|
|
583
857
|
});
|
|
584
858
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
585
|
-
const result = renameLocalNoteFile('Notes/old.md', 'new');
|
|
859
|
+
const result = await renameLocalNoteFile('Notes/old.md', 'new');
|
|
586
860
|
expect(result).toBe(path.join('Notes', 'new.md'));
|
|
587
861
|
expect(mockFs.renameSync).toHaveBeenCalledWith('/np/Notes/old.md', '/np/Notes/new.md');
|
|
588
862
|
});
|
|
863
|
+
it('also renames the sibling _attachments folder', async () => {
|
|
864
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
865
|
+
const s = String(p);
|
|
866
|
+
if (s === '/np/Notes/old.md')
|
|
867
|
+
return true;
|
|
868
|
+
if (s === '/np/Notes/old_attachments')
|
|
869
|
+
return true;
|
|
870
|
+
// Renamed-note read-back sees no content (file's been moved) — the
|
|
871
|
+
// link-rewriter just no-ops. Exercised by the next test.
|
|
872
|
+
return false;
|
|
873
|
+
});
|
|
874
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
875
|
+
await renameLocalNoteFile('Notes/old.md', 'fresh');
|
|
876
|
+
const renameCalls = mockFs.renameSync.mock.calls.map((c) => [String(c[0]), String(c[1])]);
|
|
877
|
+
expect(renameCalls).toContainEqual(['/np/Notes/old.md', '/np/Notes/fresh.md']);
|
|
878
|
+
expect(renameCalls).toContainEqual([
|
|
879
|
+
'/np/Notes/old_attachments',
|
|
880
|
+
'/np/Notes/fresh_attachments',
|
|
881
|
+
]);
|
|
882
|
+
});
|
|
883
|
+
it('rewrites attachment-folder references in the renamed note content', async () => {
|
|
884
|
+
let onDiskContent = '# Old\n\nSee  and [doc](old_attachments/notes.pdf).\n';
|
|
885
|
+
let renamed = false;
|
|
886
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
887
|
+
const s = String(p);
|
|
888
|
+
if (s === '/np/Notes/old.md')
|
|
889
|
+
return !renamed; // present until rename
|
|
890
|
+
if (s === '/np/Notes/fresh.md')
|
|
891
|
+
return renamed; // appears post-rename
|
|
892
|
+
if (s === '/np/Notes/old_attachments')
|
|
893
|
+
return !renamed;
|
|
894
|
+
if (s === '/np/Notes/fresh_attachments')
|
|
895
|
+
return renamed;
|
|
896
|
+
return false;
|
|
897
|
+
});
|
|
898
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
899
|
+
mockFs.renameSync.mockImplementation((from, to) => {
|
|
900
|
+
if (String(from) === '/np/Notes/old.md' && String(to) === '/np/Notes/fresh.md') {
|
|
901
|
+
renamed = true;
|
|
902
|
+
}
|
|
903
|
+
});
|
|
904
|
+
mockFs.readFileSync.mockImplementation((p) => {
|
|
905
|
+
if (String(p) === '/np/Notes/fresh.md')
|
|
906
|
+
return onDiskContent;
|
|
907
|
+
throw new Error('unexpected read: ' + p);
|
|
908
|
+
});
|
|
909
|
+
mockFs.writeFileSync.mockImplementation((p, content) => {
|
|
910
|
+
if (String(p) === '/np/Notes/fresh.md')
|
|
911
|
+
onDiskContent = content;
|
|
912
|
+
});
|
|
913
|
+
await renameLocalNoteFile('Notes/old.md', 'fresh');
|
|
914
|
+
expect(onDiskContent).toContain('fresh_attachments/photo.png');
|
|
915
|
+
expect(onDiskContent).toContain('fresh_attachments/notes.pdf');
|
|
916
|
+
expect(onDiskContent).not.toContain('old_attachments/');
|
|
917
|
+
});
|
|
918
|
+
it('skips link rewrite when the basename did not change (extension-only rename)', async () => {
|
|
919
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
920
|
+
const s = String(p);
|
|
921
|
+
if (s === '/np/Notes/Same.md')
|
|
922
|
+
return true;
|
|
923
|
+
return false;
|
|
924
|
+
});
|
|
925
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
926
|
+
await renameLocalNoteFile('Notes/Same.md', 'Same.txt', false);
|
|
927
|
+
// No content read/write should happen when basename is unchanged —
|
|
928
|
+
// attachment folder name is identical, so rewriteAttachmentLinks short-
|
|
929
|
+
// circuits before touching the file.
|
|
930
|
+
expect(mockFs.readFileSync).not.toHaveBeenCalled();
|
|
931
|
+
expect(mockFs.writeFileSync).not.toHaveBeenCalled();
|
|
932
|
+
});
|
|
589
933
|
});
|
|
590
934
|
// ---------------------------------------------------------------------------
|
|
591
935
|
// previewCreateFolder
|
|
592
936
|
// ---------------------------------------------------------------------------
|
|
593
937
|
describe('previewCreateFolder', () => {
|
|
594
|
-
it('returns normalized folder path', () => {
|
|
938
|
+
it('returns normalized folder path', async () => {
|
|
595
939
|
mockFs.existsSync.mockReturnValue(false);
|
|
596
|
-
const result = previewCreateFolder('MyFolder');
|
|
940
|
+
const result = await previewCreateFolder('MyFolder');
|
|
597
941
|
expect(result).toBe('MyFolder');
|
|
598
942
|
});
|
|
599
|
-
it('strips Notes/ prefix', () => {
|
|
943
|
+
it('strips Notes/ prefix', async () => {
|
|
600
944
|
mockFs.existsSync.mockReturnValue(false);
|
|
601
|
-
const result = previewCreateFolder('Notes/MyFolder');
|
|
945
|
+
const result = await previewCreateFolder('Notes/MyFolder');
|
|
602
946
|
expect(result).toBe('MyFolder');
|
|
603
947
|
});
|
|
604
|
-
it('throws if folder already exists', () => {
|
|
948
|
+
it('throws if folder already exists', async () => {
|
|
605
949
|
mockFs.existsSync.mockReturnValue(true);
|
|
606
|
-
expect(
|
|
950
|
+
await expect(previewCreateFolder('ExistingFolder')).rejects.toThrow('Folder already exists');
|
|
607
951
|
});
|
|
608
|
-
it('throws on empty folder path', () => {
|
|
609
|
-
expect(
|
|
952
|
+
it('throws on empty folder path', async () => {
|
|
953
|
+
await expect(previewCreateFolder(' ')).rejects.toThrow();
|
|
610
954
|
});
|
|
611
|
-
it('throws on invalid segments (..)', () => {
|
|
612
|
-
expect(
|
|
955
|
+
it('throws on invalid segments (..)', async () => {
|
|
956
|
+
await expect(previewCreateFolder('a/../b')).rejects.toThrow('invalid');
|
|
613
957
|
});
|
|
614
958
|
});
|
|
615
959
|
// ---------------------------------------------------------------------------
|
|
616
960
|
// createFolder
|
|
617
961
|
// ---------------------------------------------------------------------------
|
|
618
962
|
describe('createFolder', () => {
|
|
619
|
-
it('creates folder under Notes and returns normalized path', () => {
|
|
963
|
+
it('creates folder under Notes and returns normalized path', async () => {
|
|
620
964
|
mockFs.existsSync.mockReturnValue(false);
|
|
621
|
-
const result = createFolder('Projects');
|
|
965
|
+
const result = await createFolder('Projects');
|
|
622
966
|
expect(result).toBe('Projects');
|
|
623
967
|
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/np/Notes/Projects', { recursive: true });
|
|
624
968
|
});
|
|
625
|
-
it('creates nested folder', () => {
|
|
969
|
+
it('creates nested folder', async () => {
|
|
626
970
|
mockFs.existsSync.mockReturnValue(false);
|
|
627
|
-
const result = createFolder('Projects/Work');
|
|
971
|
+
const result = await createFolder('Projects/Work');
|
|
628
972
|
expect(result).toBe('Projects/Work');
|
|
629
973
|
});
|
|
630
974
|
});
|
|
@@ -632,35 +976,35 @@ describe('createFolder', () => {
|
|
|
632
976
|
// previewDeleteLocalFolder
|
|
633
977
|
// ---------------------------------------------------------------------------
|
|
634
978
|
describe('previewDeleteLocalFolder', () => {
|
|
635
|
-
it('returns normalized folder path', () => {
|
|
979
|
+
it('returns normalized folder path', async () => {
|
|
636
980
|
mockFs.existsSync.mockReturnValue(true);
|
|
637
981
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
638
|
-
const result = previewDeleteLocalFolder('MyFolder');
|
|
982
|
+
const result = await previewDeleteLocalFolder('MyFolder');
|
|
639
983
|
expect(result).toBe('MyFolder');
|
|
640
984
|
});
|
|
641
|
-
it('throws if folder does not exist', () => {
|
|
985
|
+
it('throws if folder does not exist', async () => {
|
|
642
986
|
mockFs.existsSync.mockReturnValue(false);
|
|
643
|
-
expect(
|
|
987
|
+
await expect(previewDeleteLocalFolder('Nope')).rejects.toThrow('Folder not found');
|
|
644
988
|
});
|
|
645
|
-
it('throws if target is not a directory', () => {
|
|
989
|
+
it('throws if target is not a directory', async () => {
|
|
646
990
|
mockFs.existsSync.mockReturnValue(true);
|
|
647
991
|
mockFs.statSync.mockReturnValue({ isDirectory: () => false });
|
|
648
|
-
expect(
|
|
992
|
+
await expect(previewDeleteLocalFolder('file.md')).rejects.toThrow('Not a folder');
|
|
649
993
|
});
|
|
650
|
-
it('cannot delete @Trash folder', () => {
|
|
994
|
+
it('cannot delete @Trash folder', async () => {
|
|
651
995
|
mockFs.existsSync.mockReturnValue(true);
|
|
652
996
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
653
|
-
expect(
|
|
997
|
+
await expect(previewDeleteLocalFolder('@Trash')).rejects.toThrow('Cannot delete the @Trash folder');
|
|
654
998
|
});
|
|
655
|
-
it('throws on empty path', () => {
|
|
656
|
-
expect(
|
|
999
|
+
it('throws on empty path', async () => {
|
|
1000
|
+
await expect(previewDeleteLocalFolder(' ')).rejects.toThrow();
|
|
657
1001
|
});
|
|
658
1002
|
});
|
|
659
1003
|
// ---------------------------------------------------------------------------
|
|
660
1004
|
// deleteLocalFolder
|
|
661
1005
|
// ---------------------------------------------------------------------------
|
|
662
1006
|
describe('deleteLocalFolder', () => {
|
|
663
|
-
it('moves folder to @Trash', () => {
|
|
1007
|
+
it('moves folder to @Trash', async () => {
|
|
664
1008
|
mockFs.existsSync.mockImplementation((p) => {
|
|
665
1009
|
const s = String(p);
|
|
666
1010
|
if (s === '/np/Notes/Old')
|
|
@@ -672,11 +1016,11 @@ describe('deleteLocalFolder', () => {
|
|
|
672
1016
|
return false;
|
|
673
1017
|
});
|
|
674
1018
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
675
|
-
const result = deleteLocalFolder('Old');
|
|
1019
|
+
const result = await deleteLocalFolder('Old');
|
|
676
1020
|
expect(result).toBe(path.join('Notes', '@Trash', 'Old'));
|
|
677
1021
|
expect(mockFs.renameSync).toHaveBeenCalled();
|
|
678
1022
|
});
|
|
679
|
-
it('handles duplicate folder names in trash', () => {
|
|
1023
|
+
it('handles duplicate folder names in trash', async () => {
|
|
680
1024
|
mockFs.existsSync.mockImplementation((p) => {
|
|
681
1025
|
const s = String(p);
|
|
682
1026
|
if (s === '/np/Notes/Old')
|
|
@@ -690,10 +1034,10 @@ describe('deleteLocalFolder', () => {
|
|
|
690
1034
|
return false;
|
|
691
1035
|
});
|
|
692
1036
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
693
|
-
const result = deleteLocalFolder('Old');
|
|
1037
|
+
const result = await deleteLocalFolder('Old');
|
|
694
1038
|
expect(result).toBe(path.join('Notes', '@Trash', 'Old-1'));
|
|
695
1039
|
});
|
|
696
|
-
it('creates @Trash if it does not exist', () => {
|
|
1040
|
+
it('creates @Trash if it does not exist', async () => {
|
|
697
1041
|
mockFs.existsSync.mockImplementation((p) => {
|
|
698
1042
|
const s = String(p);
|
|
699
1043
|
if (s === '/np/Notes/Old')
|
|
@@ -703,7 +1047,7 @@ describe('deleteLocalFolder', () => {
|
|
|
703
1047
|
return false;
|
|
704
1048
|
});
|
|
705
1049
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
706
|
-
deleteLocalFolder('Old');
|
|
1050
|
+
await deleteLocalFolder('Old');
|
|
707
1051
|
expect(mockFs.mkdirSync).toHaveBeenCalledWith('/np/Notes/@Trash', { recursive: true });
|
|
708
1052
|
});
|
|
709
1053
|
});
|
|
@@ -722,18 +1066,18 @@ describe('previewMoveLocalFolder', () => {
|
|
|
722
1066
|
});
|
|
723
1067
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
724
1068
|
}
|
|
725
|
-
it('returns preview with fromFolder and toFolder', () => {
|
|
1069
|
+
it('returns preview with fromFolder and toFolder', async () => {
|
|
726
1070
|
setupFolderMove();
|
|
727
|
-
const result = previewMoveLocalFolder('Source', 'Dest');
|
|
1071
|
+
const result = await previewMoveLocalFolder('Source', 'Dest');
|
|
728
1072
|
expect(result.fromFolder).toBe('Source');
|
|
729
1073
|
expect(result.toFolder).toBe('Dest/Source');
|
|
730
1074
|
});
|
|
731
|
-
it('cannot move folder into itself', () => {
|
|
1075
|
+
it('cannot move folder into itself', async () => {
|
|
732
1076
|
mockFs.existsSync.mockReturnValue(true);
|
|
733
1077
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
734
|
-
expect(
|
|
1078
|
+
await expect(previewMoveLocalFolder('Source', 'Source')).rejects.toThrow('Cannot move a folder into itself');
|
|
735
1079
|
});
|
|
736
|
-
it('cannot move folder into its descendants', () => {
|
|
1080
|
+
it('cannot move folder into its descendants', async () => {
|
|
737
1081
|
mockFs.existsSync.mockImplementation((p) => {
|
|
738
1082
|
const s = String(p);
|
|
739
1083
|
if (s === '/np/Notes/Source')
|
|
@@ -743,13 +1087,13 @@ describe('previewMoveLocalFolder', () => {
|
|
|
743
1087
|
return false;
|
|
744
1088
|
});
|
|
745
1089
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
746
|
-
expect(
|
|
1090
|
+
await expect(previewMoveLocalFolder('Source', 'Source/Child')).rejects.toThrow('Cannot move a folder into itself');
|
|
747
1091
|
});
|
|
748
|
-
it('throws if source folder not found', () => {
|
|
1092
|
+
it('throws if source folder not found', async () => {
|
|
749
1093
|
mockFs.existsSync.mockReturnValue(false);
|
|
750
|
-
expect(
|
|
1094
|
+
await expect(previewMoveLocalFolder('Nope', 'Dest')).rejects.toThrow('Source folder not found');
|
|
751
1095
|
});
|
|
752
|
-
it('throws if destination folder not found', () => {
|
|
1096
|
+
it('throws if destination folder not found', async () => {
|
|
753
1097
|
mockFs.existsSync.mockImplementation((p) => {
|
|
754
1098
|
const s = String(p);
|
|
755
1099
|
if (s === '/np/Notes/Source')
|
|
@@ -757,9 +1101,9 @@ describe('previewMoveLocalFolder', () => {
|
|
|
757
1101
|
return false;
|
|
758
1102
|
});
|
|
759
1103
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
760
|
-
expect(
|
|
1104
|
+
await expect(previewMoveLocalFolder('Source', 'NoDest')).rejects.toThrow('Destination folder not found');
|
|
761
1105
|
});
|
|
762
|
-
it('throws if folder already at destination', () => {
|
|
1106
|
+
it('throws if folder already at destination', async () => {
|
|
763
1107
|
// Source is already inside Dest, meaning the target path resolves to the same place
|
|
764
1108
|
mockFs.existsSync.mockImplementation((p) => {
|
|
765
1109
|
const s = String(p);
|
|
@@ -770,9 +1114,9 @@ describe('previewMoveLocalFolder', () => {
|
|
|
770
1114
|
return false;
|
|
771
1115
|
});
|
|
772
1116
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
773
|
-
expect(
|
|
1117
|
+
await expect(previewMoveLocalFolder('Dest/Source', 'Dest')).rejects.toThrow('already in the destination');
|
|
774
1118
|
});
|
|
775
|
-
it('throws if conflict at destination', () => {
|
|
1119
|
+
it('throws if conflict at destination', async () => {
|
|
776
1120
|
mockFs.existsSync.mockImplementation((p) => {
|
|
777
1121
|
const s = String(p);
|
|
778
1122
|
if (s === '/np/Notes/Source')
|
|
@@ -784,9 +1128,9 @@ describe('previewMoveLocalFolder', () => {
|
|
|
784
1128
|
return false;
|
|
785
1129
|
});
|
|
786
1130
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
787
|
-
expect(
|
|
1131
|
+
await expect(previewMoveLocalFolder('Source', 'Dest')).rejects.toThrow('already exists at destination');
|
|
788
1132
|
});
|
|
789
|
-
it('allows moving to Notes root', () => {
|
|
1133
|
+
it('allows moving to Notes root', async () => {
|
|
790
1134
|
mockFs.existsSync.mockImplementation((p) => {
|
|
791
1135
|
const s = String(p);
|
|
792
1136
|
if (s === '/np/Notes/Sub/Source')
|
|
@@ -796,7 +1140,7 @@ describe('previewMoveLocalFolder', () => {
|
|
|
796
1140
|
return false;
|
|
797
1141
|
});
|
|
798
1142
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
799
|
-
const result = previewMoveLocalFolder('Sub/Source', 'Notes');
|
|
1143
|
+
const result = await previewMoveLocalFolder('Sub/Source', 'Notes');
|
|
800
1144
|
expect(result.toFolder).toBe('Source');
|
|
801
1145
|
expect(result.destinationFolder).toBe('Notes');
|
|
802
1146
|
});
|
|
@@ -805,7 +1149,7 @@ describe('previewMoveLocalFolder', () => {
|
|
|
805
1149
|
// moveLocalFolder
|
|
806
1150
|
// ---------------------------------------------------------------------------
|
|
807
1151
|
describe('moveLocalFolder', () => {
|
|
808
|
-
it('moves folder to destination', () => {
|
|
1152
|
+
it('moves folder to destination', async () => {
|
|
809
1153
|
mockFs.existsSync.mockImplementation((p) => {
|
|
810
1154
|
const s = String(p);
|
|
811
1155
|
if (s === '/np/Notes/Source')
|
|
@@ -815,7 +1159,7 @@ describe('moveLocalFolder', () => {
|
|
|
815
1159
|
return false;
|
|
816
1160
|
});
|
|
817
1161
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
818
|
-
const result = moveLocalFolder('Source', 'Dest');
|
|
1162
|
+
const result = await moveLocalFolder('Source', 'Dest');
|
|
819
1163
|
expect(result.fromFolder).toBe('Source');
|
|
820
1164
|
expect(result.toFolder).toBe('Dest/Source');
|
|
821
1165
|
expect(mockFs.renameSync).toHaveBeenCalledWith('/np/Notes/Source', '/np/Notes/Dest/Source');
|
|
@@ -834,22 +1178,22 @@ describe('previewRenameLocalFolder', () => {
|
|
|
834
1178
|
});
|
|
835
1179
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
836
1180
|
}
|
|
837
|
-
it('returns preview with fromFolder and toFolder', () => {
|
|
1181
|
+
it('returns preview with fromFolder and toFolder', async () => {
|
|
838
1182
|
setupFolderRename();
|
|
839
|
-
const result = previewRenameLocalFolder('Old', 'New');
|
|
1183
|
+
const result = await previewRenameLocalFolder('Old', 'New');
|
|
840
1184
|
expect(result.fromFolder).toBe('Old');
|
|
841
1185
|
expect(result.toFolder).toBe('New');
|
|
842
1186
|
});
|
|
843
|
-
it('sanitizes new folder name', () => {
|
|
1187
|
+
it('sanitizes new folder name', async () => {
|
|
844
1188
|
setupFolderRename();
|
|
845
|
-
const result = previewRenameLocalFolder('Old', 'He?lo');
|
|
1189
|
+
const result = await previewRenameLocalFolder('Old', 'He?lo');
|
|
846
1190
|
expect(result.toFolder).toBe('He-lo');
|
|
847
1191
|
});
|
|
848
|
-
it('throws if new name matches current name', () => {
|
|
1192
|
+
it('throws if new name matches current name', async () => {
|
|
849
1193
|
setupFolderRename();
|
|
850
|
-
expect(
|
|
1194
|
+
await expect(previewRenameLocalFolder('Old', 'Old')).rejects.toThrow('matches current name');
|
|
851
1195
|
});
|
|
852
|
-
it('throws if new name already exists', () => {
|
|
1196
|
+
it('throws if new name already exists', async () => {
|
|
853
1197
|
mockFs.existsSync.mockImplementation((p) => {
|
|
854
1198
|
const s = String(p);
|
|
855
1199
|
if (s === '/np/Notes/Old')
|
|
@@ -859,17 +1203,17 @@ describe('previewRenameLocalFolder', () => {
|
|
|
859
1203
|
return false;
|
|
860
1204
|
});
|
|
861
1205
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
862
|
-
expect(
|
|
1206
|
+
await expect(previewRenameLocalFolder('Old', 'Taken')).rejects.toThrow('already exists');
|
|
863
1207
|
});
|
|
864
|
-
it('throws if source not found', () => {
|
|
1208
|
+
it('throws if source not found', async () => {
|
|
865
1209
|
mockFs.existsSync.mockReturnValue(false);
|
|
866
|
-
expect(
|
|
1210
|
+
await expect(previewRenameLocalFolder('Nope', 'New')).rejects.toThrow('Source folder not found');
|
|
867
1211
|
});
|
|
868
|
-
it('throws on empty new name', () => {
|
|
1212
|
+
it('throws on empty new name', async () => {
|
|
869
1213
|
setupFolderRename();
|
|
870
|
-
expect(
|
|
1214
|
+
await expect(previewRenameLocalFolder('Old', ' ')).rejects.toThrow('required');
|
|
871
1215
|
});
|
|
872
|
-
it('must stay in same parent folder', () => {
|
|
1216
|
+
it('must stay in same parent folder', async () => {
|
|
873
1217
|
mockFs.existsSync.mockImplementation((p) => {
|
|
874
1218
|
const s = String(p);
|
|
875
1219
|
if (s === '/np/Notes/Old')
|
|
@@ -877,14 +1221,14 @@ describe('previewRenameLocalFolder', () => {
|
|
|
877
1221
|
return false;
|
|
878
1222
|
});
|
|
879
1223
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
880
|
-
expect(
|
|
1224
|
+
await expect(previewRenameLocalFolder('Old', 'Other/New')).rejects.toThrow('must stay in the same parent folder');
|
|
881
1225
|
});
|
|
882
1226
|
});
|
|
883
1227
|
// ---------------------------------------------------------------------------
|
|
884
1228
|
// renameLocalFolder
|
|
885
1229
|
// ---------------------------------------------------------------------------
|
|
886
1230
|
describe('renameLocalFolder', () => {
|
|
887
|
-
it('renames folder and returns preview', () => {
|
|
1231
|
+
it('renames folder and returns preview', async () => {
|
|
888
1232
|
mockFs.existsSync.mockImplementation((p) => {
|
|
889
1233
|
const s = String(p);
|
|
890
1234
|
if (s === '/np/Notes/Old')
|
|
@@ -892,10 +1236,179 @@ describe('renameLocalFolder', () => {
|
|
|
892
1236
|
return false;
|
|
893
1237
|
});
|
|
894
1238
|
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
895
|
-
const result = renameLocalFolder('Old', 'New');
|
|
1239
|
+
const result = await renameLocalFolder('Old', 'New');
|
|
896
1240
|
expect(result.fromFolder).toBe('Old');
|
|
897
1241
|
expect(result.toFolder).toBe('New');
|
|
898
1242
|
expect(mockFs.renameSync).toHaveBeenCalledWith('/np/Notes/Old', '/np/Notes/New');
|
|
899
1243
|
});
|
|
900
1244
|
});
|
|
1245
|
+
// ---------------------------------------------------------------------------
|
|
1246
|
+
// Folder access rules — wiring into write paths
|
|
1247
|
+
// ---------------------------------------------------------------------------
|
|
1248
|
+
import { __resetFolderAccessConfigForTests } from '../utils/folder-access.js';
|
|
1249
|
+
describe('folder-access wiring', () => {
|
|
1250
|
+
beforeEach(() => {
|
|
1251
|
+
delete process.env.NOTEPLAN_ALLOWED_FOLDERS;
|
|
1252
|
+
delete process.env.NOTEPLAN_DENIED_FOLDERS;
|
|
1253
|
+
__resetFolderAccessConfigForTests();
|
|
1254
|
+
});
|
|
1255
|
+
it('createProjectNote rejects when target folder is denied', async () => {
|
|
1256
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
|
|
1257
|
+
__resetFolderAccessConfigForTests();
|
|
1258
|
+
await expect(createProjectNote('Diary', '', 'Personal')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1259
|
+
expect(mockFs.writeFileSync).not.toHaveBeenCalled();
|
|
1260
|
+
});
|
|
1261
|
+
it('createProjectNote allows targets in undenied folders', async () => {
|
|
1262
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
|
|
1263
|
+
__resetFolderAccessConfigForTests();
|
|
1264
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
1265
|
+
const result = await createProjectNote('Plan', '', 'Work');
|
|
1266
|
+
expect(result).toBe(path.join('Notes', 'Work', 'Plan.md'));
|
|
1267
|
+
});
|
|
1268
|
+
it('createCalendarNoteIfNew rejects when Calendar/ is denied', async () => {
|
|
1269
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Calendar';
|
|
1270
|
+
__resetFolderAccessConfigForTests();
|
|
1271
|
+
await expect(createCalendarNoteIfNew('20260507', '')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1272
|
+
});
|
|
1273
|
+
it('updateNote rejects when the note sits in a denied folder', async () => {
|
|
1274
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
|
|
1275
|
+
__resetFolderAccessConfigForTests();
|
|
1276
|
+
await expect(updateNote('Notes/Personal/Diary.md', 'new content')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1277
|
+
expect(mockFs.writeFileSync).not.toHaveBeenCalled();
|
|
1278
|
+
});
|
|
1279
|
+
it('deleteNote rejects when the note sits in a denied folder', async () => {
|
|
1280
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
|
|
1281
|
+
__resetFolderAccessConfigForTests();
|
|
1282
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
1283
|
+
await expect(deleteNote('Notes/Personal/Diary.md')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1284
|
+
expect(mockFs.renameSync).not.toHaveBeenCalled();
|
|
1285
|
+
});
|
|
1286
|
+
it('moveLocalNote rejects when EITHER source or destination is denied', async () => {
|
|
1287
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
|
|
1288
|
+
__resetFolderAccessConfigForTests();
|
|
1289
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
1290
|
+
const s = String(p);
|
|
1291
|
+
if (s === '/np/Notes/Plan.md')
|
|
1292
|
+
return true;
|
|
1293
|
+
if (s === '/np/Notes/Personal/Diary.md')
|
|
1294
|
+
return true;
|
|
1295
|
+
return false;
|
|
1296
|
+
});
|
|
1297
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
1298
|
+
// Moving INTO a denied folder is rejected.
|
|
1299
|
+
await expect(moveLocalNote('Notes/Plan.md', 'Personal')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1300
|
+
// Moving OUT of a denied folder is also rejected (would otherwise be
|
|
1301
|
+
// an exfiltration path).
|
|
1302
|
+
await expect(moveLocalNote('Notes/Personal/Diary.md', 'Work')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1303
|
+
});
|
|
1304
|
+
it('renameLocalNoteFile rejects when the source folder is denied', async () => {
|
|
1305
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
|
|
1306
|
+
__resetFolderAccessConfigForTests();
|
|
1307
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
1308
|
+
return String(p) === '/np/Notes/Personal/Diary.md';
|
|
1309
|
+
});
|
|
1310
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
1311
|
+
await expect(renameLocalNoteFile('Notes/Personal/Diary.md', 'Renamed')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1312
|
+
});
|
|
1313
|
+
it('restoreLocalNoteFromTrash rejects when destination is denied', async () => {
|
|
1314
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
|
|
1315
|
+
__resetFolderAccessConfigForTests();
|
|
1316
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
1317
|
+
return String(p) === '/np/Notes/@Trash/Diary.md';
|
|
1318
|
+
});
|
|
1319
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
1320
|
+
await expect(restoreLocalNoteFromTrash('Notes/@Trash/Diary.md', 'Personal')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1321
|
+
});
|
|
1322
|
+
it('appendToNote rejects when target is in a denied folder', async () => {
|
|
1323
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
|
|
1324
|
+
__resetFolderAccessConfigForTests();
|
|
1325
|
+
await expect(appendToNote('Notes/Personal/Diary.md', 'new line')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1326
|
+
expect(mockFs.writeFileSync).not.toHaveBeenCalled();
|
|
1327
|
+
});
|
|
1328
|
+
it('prependToNote rejects when target is in a denied folder', async () => {
|
|
1329
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
|
|
1330
|
+
__resetFolderAccessConfigForTests();
|
|
1331
|
+
await expect(prependToNote('Notes/Personal/Diary.md', 'preface')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1332
|
+
expect(mockFs.writeFileSync).not.toHaveBeenCalled();
|
|
1333
|
+
});
|
|
1334
|
+
it('ensureCalendarNote rejects when Calendar/ is denied (closes the addToToday gap)', async () => {
|
|
1335
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Calendar';
|
|
1336
|
+
__resetFolderAccessConfigForTests();
|
|
1337
|
+
// No existing calendar note → falls through to the create branch
|
|
1338
|
+
// where the assert fires. Without the guard the .md would land on
|
|
1339
|
+
// disk before the downstream writer's own filter could reject.
|
|
1340
|
+
vi.mocked(getCalendarNote).mockResolvedValueOnce(null);
|
|
1341
|
+
await expect(ensureCalendarNote('20260507')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1342
|
+
expect(mockFs.writeFileSync).not.toHaveBeenCalled();
|
|
1343
|
+
});
|
|
1344
|
+
// Dry-run leak guard: the preview functions used to skip the access check
|
|
1345
|
+
// entirely, so an agent could call them to learn whether a path resolves
|
|
1346
|
+
// (path-existence side channel). The asserts now live inside the preview
|
|
1347
|
+
// functions themselves — both dry-run and execute pass through the same
|
|
1348
|
+
// gate.
|
|
1349
|
+
it('previewMoveLocalNote rejects when destination is denied', async () => {
|
|
1350
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
|
|
1351
|
+
__resetFolderAccessConfigForTests();
|
|
1352
|
+
mockFs.existsSync.mockImplementation((p) => String(p) === '/np/Notes/Plan.md');
|
|
1353
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
1354
|
+
await expect(previewMoveLocalNote('Notes/Plan.md', 'Personal')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1355
|
+
});
|
|
1356
|
+
it('previewRestoreLocalNoteFromTrash rejects when destination is denied', async () => {
|
|
1357
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
|
|
1358
|
+
__resetFolderAccessConfigForTests();
|
|
1359
|
+
mockFs.existsSync.mockImplementation((p) => String(p) === '/np/Notes/@Trash/Diary.md');
|
|
1360
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
1361
|
+
await expect(previewRestoreLocalNoteFromTrash('Notes/@Trash/Diary.md', 'Personal')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1362
|
+
});
|
|
1363
|
+
it('previewRenameLocalNoteFile rejects when source folder is denied', async () => {
|
|
1364
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
|
|
1365
|
+
__resetFolderAccessConfigForTests();
|
|
1366
|
+
mockFs.existsSync.mockImplementation((p) => String(p) === '/np/Notes/Personal/Diary.md');
|
|
1367
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => false, isFile: () => true });
|
|
1368
|
+
await expect(previewRenameLocalNoteFile('Notes/Personal/Diary.md', 'Renamed')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1369
|
+
});
|
|
1370
|
+
// Folder-level operations were initially un-gated, so an agent could
|
|
1371
|
+
// create / delete / rename / move a denied folder outright (or move a
|
|
1372
|
+
// benign folder INTO a denied subtree) and bypass the rule for every
|
|
1373
|
+
// file inside. The asserts now live at the preview layer so dry-run
|
|
1374
|
+
// and execute share the same gate.
|
|
1375
|
+
it('previewCreateFolder rejects when target sits inside a denied subtree', async () => {
|
|
1376
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
|
|
1377
|
+
__resetFolderAccessConfigForTests();
|
|
1378
|
+
mockFs.existsSync.mockReturnValue(false);
|
|
1379
|
+
await expect(previewCreateFolder('Personal/Sub')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1380
|
+
});
|
|
1381
|
+
it('previewDeleteLocalFolder rejects when target is denied', async () => {
|
|
1382
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
|
|
1383
|
+
__resetFolderAccessConfigForTests();
|
|
1384
|
+
mockFs.existsSync.mockReturnValue(true);
|
|
1385
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
1386
|
+
await expect(previewDeleteLocalFolder('Personal')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1387
|
+
expect(mockFs.renameSync).not.toHaveBeenCalled();
|
|
1388
|
+
});
|
|
1389
|
+
it('previewMoveLocalFolder rejects when EITHER source or destination is denied', async () => {
|
|
1390
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
|
|
1391
|
+
__resetFolderAccessConfigForTests();
|
|
1392
|
+
mockFs.existsSync.mockImplementation((p) => {
|
|
1393
|
+
const s = String(p);
|
|
1394
|
+
if (s === '/np/Notes/Personal')
|
|
1395
|
+
return true;
|
|
1396
|
+
if (s === '/np/Notes/Work')
|
|
1397
|
+
return true;
|
|
1398
|
+
return false;
|
|
1399
|
+
});
|
|
1400
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
1401
|
+
// Moving a denied folder out is rejected (would expose its contents).
|
|
1402
|
+
await expect(previewMoveLocalFolder('Personal', 'Work')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1403
|
+
// Moving anything INTO a denied subtree is rejected (smuggling).
|
|
1404
|
+
await expect(previewMoveLocalFolder('Work', 'Personal')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1405
|
+
});
|
|
1406
|
+
it('previewRenameLocalFolder rejects when source folder is denied', async () => {
|
|
1407
|
+
process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
|
|
1408
|
+
__resetFolderAccessConfigForTests();
|
|
1409
|
+
mockFs.existsSync.mockImplementation((p) => String(p) === '/np/Notes/Personal');
|
|
1410
|
+
mockFs.statSync.mockReturnValue({ isDirectory: () => true });
|
|
1411
|
+
await expect(previewRenameLocalFolder('Personal', 'Renamed')).rejects.toThrow(/NOTEPLAN_DENIED_FOLDERS/);
|
|
1412
|
+
});
|
|
1413
|
+
});
|
|
901
1414
|
//# sourceMappingURL=file-writer.test.js.map
|