@noteplanco/noteplan-mcp 1.1.23 → 1.1.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (164) hide show
  1. package/README.md +7 -0
  2. package/dist/index.js +6 -0
  3. package/dist/index.js.map +1 -1
  4. package/dist/noteplan/attachments-paths.d.ts +13 -0
  5. package/dist/noteplan/attachments-paths.d.ts.map +1 -0
  6. package/dist/noteplan/attachments-paths.js +27 -0
  7. package/dist/noteplan/attachments-paths.js.map +1 -0
  8. package/dist/noteplan/embeddings.js +1 -1
  9. package/dist/noteplan/embeddings.js.map +1 -1
  10. package/dist/noteplan/file-reader.d.ts +37 -46
  11. package/dist/noteplan/file-reader.d.ts.map +1 -1
  12. package/dist/noteplan/file-reader.js +200 -202
  13. package/dist/noteplan/file-reader.js.map +1 -1
  14. package/dist/noteplan/file-reader.test.d.ts +2 -0
  15. package/dist/noteplan/file-reader.test.d.ts.map +1 -0
  16. package/dist/noteplan/file-reader.test.js +67 -0
  17. package/dist/noteplan/file-reader.test.js.map +1 -0
  18. package/dist/noteplan/file-writer.d.ts +35 -31
  19. package/dist/noteplan/file-writer.d.ts.map +1 -1
  20. package/dist/noteplan/file-writer.js +280 -164
  21. package/dist/noteplan/file-writer.js.map +1 -1
  22. package/dist/noteplan/file-writer.test.js +704 -191
  23. package/dist/noteplan/file-writer.test.js.map +1 -1
  24. package/dist/noteplan/filter-store.d.ts +5 -5
  25. package/dist/noteplan/filter-store.d.ts.map +1 -1
  26. package/dist/noteplan/filter-store.js +94 -79
  27. package/dist/noteplan/filter-store.js.map +1 -1
  28. package/dist/noteplan/ripgrep-search.d.ts +25 -2
  29. package/dist/noteplan/ripgrep-search.d.ts.map +1 -1
  30. package/dist/noteplan/ripgrep-search.js +75 -2
  31. package/dist/noteplan/ripgrep-search.js.map +1 -1
  32. package/dist/noteplan/space-row-utils.d.ts +20 -0
  33. package/dist/noteplan/space-row-utils.d.ts.map +1 -0
  34. package/dist/noteplan/space-row-utils.js +78 -0
  35. package/dist/noteplan/space-row-utils.js.map +1 -0
  36. package/dist/noteplan/space-row-utils.test.d.ts +2 -0
  37. package/dist/noteplan/space-row-utils.test.d.ts.map +1 -0
  38. package/dist/noteplan/space-row-utils.test.js +123 -0
  39. package/dist/noteplan/space-row-utils.test.js.map +1 -0
  40. package/dist/noteplan/sqlite-reader.d.ts +12 -27
  41. package/dist/noteplan/sqlite-reader.d.ts.map +1 -1
  42. package/dist/noteplan/sqlite-reader.js +315 -221
  43. package/dist/noteplan/sqlite-reader.js.map +1 -1
  44. package/dist/noteplan/sqlite-writer.d.ts +1 -1
  45. package/dist/noteplan/sqlite-writer.d.ts.map +1 -1
  46. package/dist/noteplan/sqlite-writer.js +2 -2
  47. package/dist/noteplan/sqlite-writer.js.map +1 -1
  48. package/dist/noteplan/unified-store.d.ts +41 -30
  49. package/dist/noteplan/unified-store.d.ts.map +1 -1
  50. package/dist/noteplan/unified-store.js +257 -159
  51. package/dist/noteplan/unified-store.js.map +1 -1
  52. package/dist/server.d.ts.map +1 -1
  53. package/dist/server.js +142 -61
  54. package/dist/server.js.map +1 -1
  55. package/dist/tools/attachments.d.ts +9 -9
  56. package/dist/tools/attachments.d.ts.map +1 -1
  57. package/dist/tools/attachments.js +74 -83
  58. package/dist/tools/attachments.js.map +1 -1
  59. package/dist/tools/attachments.test.js +170 -129
  60. package/dist/tools/attachments.test.js.map +1 -1
  61. package/dist/tools/calendar.d.ts +16 -13
  62. package/dist/tools/calendar.d.ts.map +1 -1
  63. package/dist/tools/calendar.js +17 -16
  64. package/dist/tools/calendar.js.map +1 -1
  65. package/dist/tools/embeddings.d.ts +6 -6
  66. package/dist/tools/embeddings.d.ts.map +1 -1
  67. package/dist/tools/embeddings.js +6 -6
  68. package/dist/tools/embeddings.js.map +1 -1
  69. package/dist/tools/events.d.ts +7 -3
  70. package/dist/tools/events.d.ts.map +1 -1
  71. package/dist/tools/events.js +51 -16
  72. package/dist/tools/events.js.map +1 -1
  73. package/dist/tools/filters.d.ts +28 -33
  74. package/dist/tools/filters.d.ts.map +1 -1
  75. package/dist/tools/filters.js +42 -105
  76. package/dist/tools/filters.js.map +1 -1
  77. package/dist/tools/notes.d.ts +80 -218
  78. package/dist/tools/notes.d.ts.map +1 -1
  79. package/dist/tools/notes.js +180 -177
  80. package/dist/tools/notes.js.map +1 -1
  81. package/dist/tools/notes.test.js +242 -21
  82. package/dist/tools/notes.test.js.map +1 -1
  83. package/dist/tools/search.d.ts +4 -3
  84. package/dist/tools/search.d.ts.map +1 -1
  85. package/dist/tools/search.js +9 -5
  86. package/dist/tools/search.js.map +1 -1
  87. package/dist/tools/search.test.d.ts +2 -0
  88. package/dist/tools/search.test.d.ts.map +1 -0
  89. package/dist/tools/search.test.js +37 -0
  90. package/dist/tools/search.test.js.map +1 -0
  91. package/dist/tools/spaces.d.ts +20 -20
  92. package/dist/tools/spaces.d.ts.map +1 -1
  93. package/dist/tools/spaces.js +28 -28
  94. package/dist/tools/spaces.js.map +1 -1
  95. package/dist/tools/tasks.d.ts +22 -22
  96. package/dist/tools/tasks.d.ts.map +1 -1
  97. package/dist/tools/tasks.js +22 -22
  98. package/dist/tools/tasks.js.map +1 -1
  99. package/dist/tools/templates.d.ts +7 -7
  100. package/dist/tools/templates.d.ts.map +1 -1
  101. package/dist/tools/templates.js +4 -4
  102. package/dist/tools/templates.js.map +1 -1
  103. package/dist/tools/themes.js +1 -1
  104. package/dist/tools/themes.js.map +1 -1
  105. package/dist/transport/bridge-availability.d.ts +5 -0
  106. package/dist/transport/bridge-availability.d.ts.map +1 -0
  107. package/dist/transport/bridge-availability.js +92 -0
  108. package/dist/transport/bridge-availability.js.map +1 -0
  109. package/dist/transport/bridge-cascade.d.ts +18 -0
  110. package/dist/transport/bridge-cascade.d.ts.map +1 -0
  111. package/dist/transport/bridge-cascade.js +78 -0
  112. package/dist/transport/bridge-cascade.js.map +1 -0
  113. package/dist/transport/bridge-cascade.test.d.ts +2 -0
  114. package/dist/transport/bridge-cascade.test.d.ts.map +1 -0
  115. package/dist/transport/bridge-cascade.test.js +160 -0
  116. package/dist/transport/bridge-cascade.test.js.map +1 -0
  117. package/dist/transport/bridge-client.d.ts +197 -0
  118. package/dist/transport/bridge-client.d.ts.map +1 -0
  119. package/dist/transport/bridge-client.js +288 -0
  120. package/dist/transport/bridge-client.js.map +1 -0
  121. package/dist/transport/bridge-client.test.d.ts +2 -0
  122. package/dist/transport/bridge-client.test.d.ts.map +1 -0
  123. package/dist/transport/bridge-client.test.js +384 -0
  124. package/dist/transport/bridge-client.test.js.map +1 -0
  125. package/dist/transport/bridge-context.d.ts +10 -0
  126. package/dist/transport/bridge-context.d.ts.map +1 -0
  127. package/dist/transport/bridge-context.js +18 -0
  128. package/dist/transport/bridge-context.js.map +1 -0
  129. package/dist/transport/bridge-fs.d.ts +25 -0
  130. package/dist/transport/bridge-fs.d.ts.map +1 -0
  131. package/dist/transport/bridge-fs.js +129 -0
  132. package/dist/transport/bridge-fs.js.map +1 -0
  133. package/dist/utils/date-utils.d.ts +24 -0
  134. package/dist/utils/date-utils.d.ts.map +1 -1
  135. package/dist/utils/date-utils.js +55 -0
  136. package/dist/utils/date-utils.js.map +1 -1
  137. package/dist/utils/date-utils.test.d.ts +2 -0
  138. package/dist/utils/date-utils.test.d.ts.map +1 -0
  139. package/dist/utils/date-utils.test.js +109 -0
  140. package/dist/utils/date-utils.test.js.map +1 -0
  141. package/dist/utils/folder-access.d.ts +23 -0
  142. package/dist/utils/folder-access.d.ts.map +1 -0
  143. package/dist/utils/folder-access.js +131 -0
  144. package/dist/utils/folder-access.js.map +1 -0
  145. package/dist/utils/folder-access.test.d.ts +2 -0
  146. package/dist/utils/folder-access.test.d.ts.map +1 -0
  147. package/dist/utils/folder-access.test.js +182 -0
  148. package/dist/utils/folder-access.test.js.map +1 -0
  149. package/dist/utils/folder-matcher.d.ts.map +1 -1
  150. package/dist/utils/folder-matcher.js +16 -0
  151. package/dist/utils/folder-matcher.js.map +1 -1
  152. package/dist/utils/folder-matcher.test.js +42 -0
  153. package/dist/utils/folder-matcher.test.js.map +1 -1
  154. package/dist/utils/server-config.d.ts +10 -2
  155. package/dist/utils/server-config.d.ts.map +1 -1
  156. package/dist/utils/server-config.js +16 -2
  157. package/dist/utils/server-config.js.map +1 -1
  158. package/dist/utils/version.d.ts +2 -0
  159. package/dist/utils/version.d.ts.map +1 -1
  160. package/dist/utils/version.js +5 -1
  161. package/dist/utils/version.js.map +1 -1
  162. package/package.json +4 -3
  163. package/scripts/calendar-helper +0 -0
  164. 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
- buildCalendarNotePath: vi.fn((date) => `Calendar/${date}.md`),
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(() => writeNoteFile('Notes/new.md', 'data')).toThrow('EACCES');
137
+ await expect(writeNoteFile('Notes/new.md', 'data')).rejects.toThrow('EACCES');
97
138
  });
98
- it('rejects paths outside NotePlan root', () => {
99
- expect(() => writeNoteFile('/outside/path.md', 'x')).toThrow();
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('throws if note already exists with same extension', () => {
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(() => createProjectNote('Dup')).toThrow('Note already exists');
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(() => createProjectNote('Dup')).toThrow('Note already exists');
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).mockReturnValueOnce({
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).mockReturnValueOnce(null);
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(() => appendToNote('Notes/nope.md', 'x')).toThrow('Note not found');
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(() => prependToNote('Notes/nope.md', 'x')).toThrow('Note not found');
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(() => updateNote('Notes/nope.md', 'x')).toThrow('Note not found');
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(() => deleteNote('Notes/nope.md')).toThrow('Note not found');
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![pic](Dup_attachments/photo.png)\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(() => previewMoveLocalNote('Notes/nope.md', 'Notes/Work')).toThrow('Note not found');
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(() => previewMoveLocalNote('Notes/folder', 'Notes/Work')).toThrow('Not a note file');
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(() => previewMoveLocalNote('Calendar/20240101.md', 'Notes/Work')).toThrow('must be inside Notes');
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(() => previewMoveLocalNote('Notes/Work/test.md', 'Notes/Work')).toThrow('already in the destination');
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(() => previewMoveLocalNote('Notes/test.md', 'Notes/Work')).toThrow('already exists at destination');
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(() => previewMoveLocalNote('Notes/test.md', 'Work/other.md')).toThrow('must be a folder path, not a filename');
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(() => previewRestoreLocalNoteFromTrash('Notes/@Trash/nope.md', 'Notes')).toThrow('Note not found');
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(() => previewRestoreLocalNoteFromTrash('Notes/test.md', 'Notes')).toThrow('must be inside @Trash');
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(() => previewRestoreLocalNoteFromTrash('Notes/@Trash/test.md', 'Notes')).toThrow('already exists at destination');
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(() => previewRenameLocalNoteFile('Notes/old.md', 'old')).toThrow('matches current filename');
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(() => previewRenameLocalNoteFile('Notes/old.md', 'taken')).toThrow('already exists with filename');
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(() => previewRenameLocalNoteFile('Notes/nope.md', 'new')).toThrow('Note not found');
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(() => previewRenameLocalNoteFile('Notes/folder', 'new')).toThrow('Not a note file');
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(() => previewRenameLocalNoteFile('Notes/old.md', 'OtherFolder/new')).toThrow('must stay in the same folder');
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 ![pic](old_attachments/photo.png) 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(() => previewCreateFolder('ExistingFolder')).toThrow('Folder already exists');
950
+ await expect(previewCreateFolder('ExistingFolder')).rejects.toThrow('Folder already exists');
607
951
  });
608
- it('throws on empty folder path', () => {
609
- expect(() => previewCreateFolder(' ')).toThrow();
952
+ it('throws on empty folder path', async () => {
953
+ await expect(previewCreateFolder(' ')).rejects.toThrow();
610
954
  });
611
- it('throws on invalid segments (..)', () => {
612
- expect(() => previewCreateFolder('a/../b')).toThrow('invalid');
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(() => previewDeleteLocalFolder('Nope')).toThrow('Folder not found');
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(() => previewDeleteLocalFolder('file.md')).toThrow('Not a folder');
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(() => previewDeleteLocalFolder('@Trash')).toThrow('Cannot delete the @Trash folder');
997
+ await expect(previewDeleteLocalFolder('@Trash')).rejects.toThrow('Cannot delete the @Trash folder');
654
998
  });
655
- it('throws on empty path', () => {
656
- expect(() => previewDeleteLocalFolder(' ')).toThrow();
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(() => previewMoveLocalFolder('Source', 'Source')).toThrow('Cannot move a folder into itself');
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(() => previewMoveLocalFolder('Source', 'Source/Child')).toThrow('Cannot move a folder into itself');
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(() => previewMoveLocalFolder('Nope', 'Dest')).toThrow('Source folder not found');
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(() => previewMoveLocalFolder('Source', 'NoDest')).toThrow('Destination folder not found');
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(() => previewMoveLocalFolder('Dest/Source', 'Dest')).toThrow('already in the destination');
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(() => previewMoveLocalFolder('Source', 'Dest')).toThrow('already exists at destination');
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(() => previewRenameLocalFolder('Old', 'Old')).toThrow('matches current name');
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(() => previewRenameLocalFolder('Old', 'Taken')).toThrow('already exists');
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(() => previewRenameLocalFolder('Nope', 'New')).toThrow('Source folder not found');
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(() => previewRenameLocalFolder('Old', ' ')).toThrow('required');
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(() => previewRenameLocalFolder('Old', 'Other/New')).toThrow('must stay in the same parent folder');
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