@noteplanco/noteplan-mcp 1.1.21 → 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 (177) 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/frontmatter-parser.d.ts.map +1 -1
  29. package/dist/noteplan/frontmatter-parser.js +5 -4
  30. package/dist/noteplan/frontmatter-parser.js.map +1 -1
  31. package/dist/noteplan/frontmatter-parser.test.js +44 -0
  32. package/dist/noteplan/frontmatter-parser.test.js.map +1 -1
  33. package/dist/noteplan/markdown-parser.js +1 -1
  34. package/dist/noteplan/markdown-parser.js.map +1 -1
  35. package/dist/noteplan/markdown-parser.test.js +194 -0
  36. package/dist/noteplan/markdown-parser.test.js.map +1 -1
  37. package/dist/noteplan/preferences.d.ts +1 -0
  38. package/dist/noteplan/preferences.d.ts.map +1 -1
  39. package/dist/noteplan/preferences.js +1 -0
  40. package/dist/noteplan/preferences.js.map +1 -1
  41. package/dist/noteplan/ripgrep-search.d.ts +25 -2
  42. package/dist/noteplan/ripgrep-search.d.ts.map +1 -1
  43. package/dist/noteplan/ripgrep-search.js +75 -2
  44. package/dist/noteplan/ripgrep-search.js.map +1 -1
  45. package/dist/noteplan/space-row-utils.d.ts +20 -0
  46. package/dist/noteplan/space-row-utils.d.ts.map +1 -0
  47. package/dist/noteplan/space-row-utils.js +78 -0
  48. package/dist/noteplan/space-row-utils.js.map +1 -0
  49. package/dist/noteplan/space-row-utils.test.d.ts +2 -0
  50. package/dist/noteplan/space-row-utils.test.d.ts.map +1 -0
  51. package/dist/noteplan/space-row-utils.test.js +123 -0
  52. package/dist/noteplan/space-row-utils.test.js.map +1 -0
  53. package/dist/noteplan/sqlite-reader.d.ts +12 -27
  54. package/dist/noteplan/sqlite-reader.d.ts.map +1 -1
  55. package/dist/noteplan/sqlite-reader.js +325 -223
  56. package/dist/noteplan/sqlite-reader.js.map +1 -1
  57. package/dist/noteplan/sqlite-writer.d.ts +1 -1
  58. package/dist/noteplan/sqlite-writer.d.ts.map +1 -1
  59. package/dist/noteplan/sqlite-writer.js +2 -2
  60. package/dist/noteplan/sqlite-writer.js.map +1 -1
  61. package/dist/noteplan/unified-store.d.ts +41 -30
  62. package/dist/noteplan/unified-store.d.ts.map +1 -1
  63. package/dist/noteplan/unified-store.js +307 -161
  64. package/dist/noteplan/unified-store.js.map +1 -1
  65. package/dist/server.d.ts.map +1 -1
  66. package/dist/server.js +143 -62
  67. package/dist/server.js.map +1 -1
  68. package/dist/tools/attachments.d.ts +9 -9
  69. package/dist/tools/attachments.d.ts.map +1 -1
  70. package/dist/tools/attachments.js +74 -83
  71. package/dist/tools/attachments.js.map +1 -1
  72. package/dist/tools/attachments.test.js +170 -129
  73. package/dist/tools/attachments.test.js.map +1 -1
  74. package/dist/tools/calendar.d.ts +36 -13
  75. package/dist/tools/calendar.d.ts.map +1 -1
  76. package/dist/tools/calendar.js +44 -17
  77. package/dist/tools/calendar.js.map +1 -1
  78. package/dist/tools/embeddings.d.ts +6 -6
  79. package/dist/tools/embeddings.d.ts.map +1 -1
  80. package/dist/tools/embeddings.js +6 -6
  81. package/dist/tools/embeddings.js.map +1 -1
  82. package/dist/tools/events.d.ts +7 -3
  83. package/dist/tools/events.d.ts.map +1 -1
  84. package/dist/tools/events.js +51 -16
  85. package/dist/tools/events.js.map +1 -1
  86. package/dist/tools/filters.d.ts +28 -33
  87. package/dist/tools/filters.d.ts.map +1 -1
  88. package/dist/tools/filters.js +42 -105
  89. package/dist/tools/filters.js.map +1 -1
  90. package/dist/tools/notes.d.ts +80 -218
  91. package/dist/tools/notes.d.ts.map +1 -1
  92. package/dist/tools/notes.js +194 -180
  93. package/dist/tools/notes.js.map +1 -1
  94. package/dist/tools/notes.test.js +501 -21
  95. package/dist/tools/notes.test.js.map +1 -1
  96. package/dist/tools/search.d.ts +4 -3
  97. package/dist/tools/search.d.ts.map +1 -1
  98. package/dist/tools/search.js +9 -5
  99. package/dist/tools/search.js.map +1 -1
  100. package/dist/tools/search.test.d.ts +2 -0
  101. package/dist/tools/search.test.d.ts.map +1 -0
  102. package/dist/tools/search.test.js +37 -0
  103. package/dist/tools/search.test.js.map +1 -0
  104. package/dist/tools/spaces.d.ts +20 -20
  105. package/dist/tools/spaces.d.ts.map +1 -1
  106. package/dist/tools/spaces.js +28 -28
  107. package/dist/tools/spaces.js.map +1 -1
  108. package/dist/tools/tasks.d.ts +22 -22
  109. package/dist/tools/tasks.d.ts.map +1 -1
  110. package/dist/tools/tasks.js +22 -22
  111. package/dist/tools/tasks.js.map +1 -1
  112. package/dist/tools/templates.d.ts +7 -7
  113. package/dist/tools/templates.d.ts.map +1 -1
  114. package/dist/tools/templates.js +4 -4
  115. package/dist/tools/templates.js.map +1 -1
  116. package/dist/tools/themes.js +1 -1
  117. package/dist/tools/themes.js.map +1 -1
  118. package/dist/transport/bridge-availability.d.ts +5 -0
  119. package/dist/transport/bridge-availability.d.ts.map +1 -0
  120. package/dist/transport/bridge-availability.js +92 -0
  121. package/dist/transport/bridge-availability.js.map +1 -0
  122. package/dist/transport/bridge-cascade.d.ts +18 -0
  123. package/dist/transport/bridge-cascade.d.ts.map +1 -0
  124. package/dist/transport/bridge-cascade.js +78 -0
  125. package/dist/transport/bridge-cascade.js.map +1 -0
  126. package/dist/transport/bridge-cascade.test.d.ts +2 -0
  127. package/dist/transport/bridge-cascade.test.d.ts.map +1 -0
  128. package/dist/transport/bridge-cascade.test.js +160 -0
  129. package/dist/transport/bridge-cascade.test.js.map +1 -0
  130. package/dist/transport/bridge-client.d.ts +197 -0
  131. package/dist/transport/bridge-client.d.ts.map +1 -0
  132. package/dist/transport/bridge-client.js +288 -0
  133. package/dist/transport/bridge-client.js.map +1 -0
  134. package/dist/transport/bridge-client.test.d.ts +2 -0
  135. package/dist/transport/bridge-client.test.d.ts.map +1 -0
  136. package/dist/transport/bridge-client.test.js +384 -0
  137. package/dist/transport/bridge-client.test.js.map +1 -0
  138. package/dist/transport/bridge-context.d.ts +10 -0
  139. package/dist/transport/bridge-context.d.ts.map +1 -0
  140. package/dist/transport/bridge-context.js +18 -0
  141. package/dist/transport/bridge-context.js.map +1 -0
  142. package/dist/transport/bridge-fs.d.ts +25 -0
  143. package/dist/transport/bridge-fs.d.ts.map +1 -0
  144. package/dist/transport/bridge-fs.js +129 -0
  145. package/dist/transport/bridge-fs.js.map +1 -0
  146. package/dist/utils/date-utils.d.ts +24 -0
  147. package/dist/utils/date-utils.d.ts.map +1 -1
  148. package/dist/utils/date-utils.js +55 -0
  149. package/dist/utils/date-utils.js.map +1 -1
  150. package/dist/utils/date-utils.test.d.ts +2 -0
  151. package/dist/utils/date-utils.test.d.ts.map +1 -0
  152. package/dist/utils/date-utils.test.js +109 -0
  153. package/dist/utils/date-utils.test.js.map +1 -0
  154. package/dist/utils/folder-access.d.ts +23 -0
  155. package/dist/utils/folder-access.d.ts.map +1 -0
  156. package/dist/utils/folder-access.js +131 -0
  157. package/dist/utils/folder-access.js.map +1 -0
  158. package/dist/utils/folder-access.test.d.ts +2 -0
  159. package/dist/utils/folder-access.test.d.ts.map +1 -0
  160. package/dist/utils/folder-access.test.js +182 -0
  161. package/dist/utils/folder-access.test.js.map +1 -0
  162. package/dist/utils/folder-matcher.d.ts.map +1 -1
  163. package/dist/utils/folder-matcher.js +16 -0
  164. package/dist/utils/folder-matcher.js.map +1 -1
  165. package/dist/utils/folder-matcher.test.js +42 -0
  166. package/dist/utils/folder-matcher.test.js.map +1 -1
  167. package/dist/utils/server-config.d.ts +10 -2
  168. package/dist/utils/server-config.d.ts.map +1 -1
  169. package/dist/utils/server-config.js +16 -2
  170. package/dist/utils/server-config.js.map +1 -1
  171. package/dist/utils/version.d.ts +2 -0
  172. package/dist/utils/version.d.ts.map +1 -1
  173. package/dist/utils/version.js +5 -1
  174. package/dist/utils/version.js.map +1 -1
  175. package/package.json +4 -3
  176. package/scripts/calendar-helper +0 -0
  177. package/scripts/reminders-helper +0 -0
@@ -1,10 +1,11 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
2
  // Mock preferences before importing markdown-parser (which reads UserDefaults at runtime)
3
3
  vi.mock('../noteplan/preferences.js', () => ({
4
4
  getTaskMarkerConfigCached: vi.fn(() => ({
5
5
  isAsteriskTodo: true,
6
6
  isDashTodo: false,
7
7
  defaultTodoCharacter: '*',
8
+ todoCharacter: '*',
8
9
  useCheckbox: true,
9
10
  taskPrefix: '* [ ] ',
10
11
  })),
@@ -1383,7 +1384,7 @@ describe('matchesFrontmatterProperties – property filter edge cases', () => {
1383
1384
  // ---------------------------------------------------------------------------
1384
1385
  // We test the getNote logic by importing the function and mocking the readers.
1385
1386
  // Since getNote depends on sqliteReader and fileReader, we use vi.mock.
1386
- import { getNote } from '../noteplan/unified-store.js';
1387
+ import { getNote, listNotes } from '../noteplan/unified-store.js';
1387
1388
  import * as sqliteReader from '../noteplan/sqlite-reader.js';
1388
1389
  import * as fileReader from '../noteplan/file-reader.js';
1389
1390
  vi.mock('../noteplan/sqlite-reader.js', () => ({
@@ -1437,36 +1438,36 @@ describe('getNote id/filename resolution', () => {
1437
1438
  beforeEach(() => {
1438
1439
  vi.clearAllMocks();
1439
1440
  });
1440
- it('retrieves a local note via id when SQLite lookup returns null', () => {
1441
- vi.mocked(sqliteReader.getSpaceNote).mockReturnValue(null);
1442
- vi.mocked(fileReader.readNoteFile).mockReturnValue(localNote);
1443
- const result = getNote({ id: 'Notes/Books/my-book.md' });
1441
+ it('retrieves a local note via id when SQLite lookup returns null', async () => {
1442
+ vi.mocked(sqliteReader.getSpaceNote).mockResolvedValue(null);
1443
+ vi.mocked(fileReader.readNoteFile).mockResolvedValue(localNote);
1444
+ const result = await getNote({ id: 'Notes/Books/my-book.md' });
1444
1445
  expect(result).toEqual(localNote);
1445
1446
  expect(sqliteReader.getSpaceNote).toHaveBeenCalledWith('Notes/Books/my-book.md');
1446
1447
  expect(fileReader.readNoteFile).toHaveBeenCalledWith('Notes/Books/my-book.md');
1447
1448
  });
1448
- it('retrieves a local note via filename', () => {
1449
- vi.mocked(fileReader.readNoteFile).mockReturnValue(localNote);
1450
- const result = getNote({ filename: 'Notes/Books/my-book.md' });
1449
+ it('retrieves a local note via filename', async () => {
1450
+ vi.mocked(fileReader.readNoteFile).mockResolvedValue(localNote);
1451
+ const result = await getNote({ filename: 'Notes/Books/my-book.md' });
1451
1452
  expect(result).toEqual(localNote);
1452
1453
  });
1453
- it('id and filename return the same note for local notes', () => {
1454
- vi.mocked(sqliteReader.getSpaceNote).mockReturnValue(null);
1455
- vi.mocked(fileReader.readNoteFile).mockReturnValue(localNote);
1456
- const byId = getNote({ id: localNote.filename });
1457
- const byFilename = getNote({ filename: localNote.filename });
1454
+ it('id and filename return the same note for local notes', async () => {
1455
+ vi.mocked(sqliteReader.getSpaceNote).mockResolvedValue(null);
1456
+ vi.mocked(fileReader.readNoteFile).mockResolvedValue(localNote);
1457
+ const byId = await getNote({ id: localNote.filename });
1458
+ const byFilename = await getNote({ filename: localNote.filename });
1458
1459
  expect(byId).toEqual(byFilename);
1459
1460
  });
1460
- it('returns null for non-existent id', () => {
1461
- vi.mocked(sqliteReader.getSpaceNote).mockReturnValue(null);
1462
- vi.mocked(fileReader.readNoteFile).mockReturnValue(null);
1463
- const result = getNote({ id: 'Notes/nonexistent.md' });
1461
+ it('returns null for non-existent id', async () => {
1462
+ vi.mocked(sqliteReader.getSpaceNote).mockResolvedValue(null);
1463
+ vi.mocked(fileReader.readNoteFile).mockResolvedValue(null);
1464
+ const result = await getNote({ id: 'Notes/nonexistent.md' });
1464
1465
  expect(result).toBeNull();
1465
1466
  });
1466
- it('prefers space note when SQLite lookup succeeds', () => {
1467
+ it('prefers space note when SQLite lookup succeeds', async () => {
1467
1468
  const spaceNote = { ...localNote, source: 'space', id: 'space-uuid-123' };
1468
- vi.mocked(sqliteReader.getSpaceNote).mockReturnValue(spaceNote);
1469
- const result = getNote({ id: 'space-uuid-123' });
1469
+ vi.mocked(sqliteReader.getSpaceNote).mockResolvedValue(spaceNote);
1470
+ const result = await getNote({ id: 'space-uuid-123' });
1470
1471
  expect(result).toEqual(spaceNote);
1471
1472
  expect(fileReader.readNoteFile).not.toHaveBeenCalled();
1472
1473
  });
@@ -2218,4 +2219,483 @@ describe('type filtering with code and table', () => {
2218
2219
  expect(result.filteredCount).toBe(5); // 4 code + 1 task
2219
2220
  });
2220
2221
  });
2222
+ // ---------------------------------------------------------------------------
2223
+ // Title resolution and search: frontmatter title vs body title vs filename
2224
+ // Verifies that search/filter pipelines use the frontmatter title when present,
2225
+ // matching the Swift-side title resolution (Globals.swift / TemplateHelper).
2226
+ // ---------------------------------------------------------------------------
2227
+ import { extractTitle } from '../noteplan/markdown-parser.js';
2228
+ describe('title resolution for search – frontmatter title takes priority', () => {
2229
+ // Simulate how unified-store builds Note objects and how listNotes filters them
2230
+ function buildNote(content, filename) {
2231
+ return {
2232
+ id: filename,
2233
+ title: extractTitle(content),
2234
+ filename,
2235
+ content,
2236
+ type: 'note',
2237
+ source: 'local',
2238
+ };
2239
+ }
2240
+ // Replicates the listNotes query filter from notes.ts
2241
+ // Normalizes underscores to spaces; pipe = OR, spaces = AND
2242
+ function matchesListQuery(note, query) {
2243
+ const haystack = `${note.title} ${note.filename} ${note.folder || ''}`
2244
+ .toLowerCase()
2245
+ .replace(/_/g, ' ');
2246
+ const alternatives = query.toLowerCase().split('|').map((alt) => alt.trim().replace(/_/g, ' ')).filter(Boolean);
2247
+ return alternatives.some((alt) => {
2248
+ const words = alt.split(/\s+/).filter(Boolean);
2249
+ return words.every((word) => haystack.includes(word));
2250
+ });
2251
+ }
2252
+ // Replicates metadataScoreTokenAware from unified-store.ts (title_or_filename search)
2253
+ // Normalizes underscores to spaces; multi-word queries use AND matching
2254
+ function normalizeForMatch(value) {
2255
+ return value.replace(/_/g, ' ');
2256
+ }
2257
+ function metadataScore(value, term) {
2258
+ if (!value || !term)
2259
+ return 0;
2260
+ if (value === term)
2261
+ return 120;
2262
+ if (value.startsWith(term))
2263
+ return 100;
2264
+ if (value.includes(term))
2265
+ return 80;
2266
+ return 0;
2267
+ }
2268
+ function metadataScoreTokenAware(value, term) {
2269
+ if (!value || !term)
2270
+ return 0;
2271
+ const normalizedValue = normalizeForMatch(value);
2272
+ const normalizedTerm = normalizeForMatch(term);
2273
+ const words = normalizedTerm.split(/\s+/).filter(Boolean);
2274
+ if (words.length <= 1) {
2275
+ return Math.max(metadataScore(value, term), metadataScore(normalizedValue, normalizedTerm));
2276
+ }
2277
+ const allMatch = words.every((word) => normalizedValue.includes(word));
2278
+ if (!allMatch)
2279
+ return 0;
2280
+ if (normalizedValue === normalizedTerm)
2281
+ return 120;
2282
+ if (normalizedValue.startsWith(normalizedTerm))
2283
+ return 100;
2284
+ return 70 + Math.min(words.length, 10);
2285
+ }
2286
+ // Replicates splitSearchTerms from unified-store.ts (OR on `|`)
2287
+ function splitSearchTerms(query) {
2288
+ const tokens = query.split('|').map((t) => t.trim()).filter(Boolean);
2289
+ return tokens.length > 0 ? tokens : [query.trim()];
2290
+ }
2291
+ // Replicates scoreMetadataMatch: OR across `|`-separated terms,
2292
+ // AND within space-separated words of each term
2293
+ function matchesTitleOrFilename(note, query) {
2294
+ const lowerTitle = note.title.toLowerCase();
2295
+ const lowerFilename = note.filename.toLowerCase();
2296
+ const terms = splitSearchTerms(query);
2297
+ for (const rawTerm of terms) {
2298
+ const term = rawTerm.toLowerCase();
2299
+ if (metadataScoreTokenAware(lowerTitle, term) > 0)
2300
+ return true;
2301
+ if (metadataScoreTokenAware(lowerFilename, term) > 0)
2302
+ return true;
2303
+ }
2304
+ return false;
2305
+ }
2306
+ // --- Local notes ---
2307
+ it('local note: listNotes query matches frontmatter title, not just filename', () => {
2308
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body Heading', 'Notes/🟥_0012_project 2 2 2 2.txt');
2309
+ expect(note.title).toBe('🟥_0049_knuth_reviewer');
2310
+ expect(matchesListQuery(note, '0049')).toBe(true);
2311
+ expect(matchesListQuery(note, 'knuth')).toBe(true);
2312
+ });
2313
+ it('local note: listNotes query does NOT match filename-only content when frontmatter title is different', () => {
2314
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body Heading', 'Notes/🟥_0012_project 2 2 2 2.txt');
2315
+ // The filename contains "0012" but the frontmatter title does not — however
2316
+ // listNotes searches BOTH title and filename, so this should still match via filename
2317
+ expect(matchesListQuery(note, '0012')).toBe(true);
2318
+ });
2319
+ it('local note: title_or_filename search matches frontmatter title', () => {
2320
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body Heading', 'Notes/🟥_0012_project 2 2 2 2.txt');
2321
+ expect(matchesTitleOrFilename(note, '0049')).toBe(true);
2322
+ expect(matchesTitleOrFilename(note, 'knuth_reviewer')).toBe(true);
2323
+ });
2324
+ it('local note: title_or_filename search matches filename too', () => {
2325
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body Heading', 'Notes/🟥_0012_project 2 2 2 2.txt');
2326
+ expect(matchesTitleOrFilename(note, '0012')).toBe(true);
2327
+ });
2328
+ it('local note: uses body heading as title when frontmatter has no title/name', () => {
2329
+ const note = buildNote('---\ntype: project-note\ntags: #test\n---\n# My Project Title', 'Notes/random-filename.txt');
2330
+ expect(note.title).toBe('My Project Title');
2331
+ expect(matchesTitleOrFilename(note, 'my project')).toBe(true);
2332
+ expect(matchesListQuery(note, 'my project')).toBe(true);
2333
+ });
2334
+ it('local note: uses name property as title fallback', () => {
2335
+ const note = buildNote('---\nname: My Named Note\ntags: #test\n---\n# Different Heading', 'Notes/something.txt');
2336
+ expect(note.title).toBe('My Named Note');
2337
+ expect(matchesTitleOrFilename(note, 'named note')).toBe(true);
2338
+ });
2339
+ // --- Space notes (simulating rowToNote behavior) ---
2340
+ // This simulates the fixed rowToNote: content present → extractTitle(content) is used
2341
+ function buildSpaceNote(content, dbTitle, filename) {
2342
+ return {
2343
+ id: 'space-id-123',
2344
+ title: content ? extractTitle(content) : dbTitle || 'Untitled',
2345
+ filename,
2346
+ content,
2347
+ type: 'note',
2348
+ source: 'space',
2349
+ };
2350
+ }
2351
+ it('space note: uses frontmatter title, not SQLite title column', () => {
2352
+ const note = buildSpaceNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body Heading', '🟥_0012_project 2 2 2 2', // SQLite title column (filename-derived)
2353
+ '🟥_0012_project 2 2 2 2.md');
2354
+ expect(note.title).toBe('🟥_0049_knuth_reviewer');
2355
+ expect(matchesTitleOrFilename(note, '0049')).toBe(true);
2356
+ expect(matchesTitleOrFilename(note, 'knuth')).toBe(true);
2357
+ });
2358
+ it('space note: does NOT use the SQLite title when frontmatter title exists', () => {
2359
+ const note = buildSpaceNote('---\ntitle: Correct Title\n---\n# Body', 'Wrong DB Title', 'wrong-db-title.md');
2360
+ expect(note.title).toBe('Correct Title');
2361
+ expect(note.title).not.toBe('Wrong DB Title');
2362
+ });
2363
+ it('space note: uses body heading when frontmatter has no title', () => {
2364
+ const note = buildSpaceNote('---\ntags: #test\n---\n# Body Heading Title', 'db-filename-title', 'db-filename-title.md');
2365
+ expect(note.title).toBe('Body Heading Title');
2366
+ expect(matchesTitleOrFilename(note, 'body heading')).toBe(true);
2367
+ });
2368
+ it('space note: falls back to dbTitle when content is empty', () => {
2369
+ const note = buildSpaceNote('', 'DB Fallback Title', 'some-file.md');
2370
+ expect(note.title).toBe('DB Fallback Title');
2371
+ });
2372
+ it('space note: returns Untitled when both content and dbTitle are empty', () => {
2373
+ const note = buildSpaceNote('', '', 'empty.md');
2374
+ expect(note.title).toBe('Untitled');
2375
+ });
2376
+ it('space note: listNotes query matches frontmatter title from space note', () => {
2377
+ const note = buildSpaceNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', '🟥_0012_project 2 2 2 2', '🟥_0012_project 2 2 2 2.md');
2378
+ expect(matchesListQuery(note, '0049')).toBe(true);
2379
+ expect(matchesListQuery(note, 'knuth_reviewer')).toBe(true);
2380
+ });
2381
+ // --- Token-aware AND matching with underscore normalization ---
2382
+ it('title_or_filename: "knuth reviewer" (space) matches "knuth_reviewer" (underscore)', () => {
2383
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2384
+ expect(matchesTitleOrFilename(note, 'knuth reviewer')).toBe(true);
2385
+ });
2386
+ it('listNotes: "knuth reviewer" (space) matches "knuth_reviewer" (underscore)', () => {
2387
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2388
+ expect(matchesListQuery(note, 'knuth reviewer')).toBe(true);
2389
+ });
2390
+ it('multi-word query matches tokens in any order', () => {
2391
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2392
+ expect(matchesTitleOrFilename(note, 'reviewer knuth')).toBe(true);
2393
+ expect(matchesListQuery(note, 'reviewer knuth')).toBe(true);
2394
+ });
2395
+ it('multi-word query requires ALL tokens (AND logic)', () => {
2396
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2397
+ // "knuth" matches but "einstein" does not → should fail
2398
+ expect(matchesTitleOrFilename(note, 'knuth einstein')).toBe(false);
2399
+ expect(matchesListQuery(note, 'knuth einstein')).toBe(false);
2400
+ });
2401
+ it('single word with underscore in query matches underscore in title', () => {
2402
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2403
+ expect(matchesTitleOrFilename(note, 'knuth_reviewer')).toBe(true);
2404
+ expect(matchesListQuery(note, 'knuth_reviewer')).toBe(true);
2405
+ });
2406
+ it('number substring still matches within underscore-separated title', () => {
2407
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2408
+ expect(matchesTitleOrFilename(note, '0049')).toBe(true);
2409
+ expect(matchesListQuery(note, '0049')).toBe(true);
2410
+ });
2411
+ // Multi-word frontmatter keys (e.g. "start date") must not break title resolution
2412
+ it('local note: extracts frontmatter title when multi-word keys are present', () => {
2413
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\nstart date: 2026-02\ntags: #project/scope\ntype: project-note\n---\n# Body Heading', 'Notes/🟥_0012_project 2 2 2 2.txt');
2414
+ expect(note.title).toBe('🟥_0049_knuth_reviewer');
2415
+ expect(matchesTitleOrFilename(note, '0049')).toBe(true);
2416
+ expect(matchesListQuery(note, 'knuth')).toBe(true);
2417
+ });
2418
+ it('space note: extracts frontmatter title when multi-word keys are present', () => {
2419
+ const note = buildSpaceNote('---\ntitle: 🟥_0049_knuth_reviewer\nstart date: 2026-02\ntags: #project/scope\ntype: project-note\n---\n# Body Heading', '🟥_0012_project 2 2 2 2', '🟥_0012_project 2 2 2 2.md');
2420
+ expect(note.title).toBe('🟥_0049_knuth_reviewer');
2421
+ expect(matchesTitleOrFilename(note, '0049')).toBe(true);
2422
+ expect(matchesListQuery(note, 'knuth_reviewer')).toBe(true);
2423
+ });
2424
+ // --- OR separator (pipe |) and AND (spaces) ---
2425
+ it('pipe | gives OR: matches if either term matches', () => {
2426
+ const note = buildNote('---\ntitle: Meeting Notes\n---\n# Body', 'Notes/meeting-notes.txt');
2427
+ // "meeting" matches, "nonexistent" doesn't — OR means it should match
2428
+ expect(matchesTitleOrFilename(note, 'nonexistent | meeting')).toBe(true);
2429
+ });
2430
+ it('pipe | gives OR: fails if neither term matches', () => {
2431
+ const note = buildNote('---\ntitle: Meeting Notes\n---\n# Body', 'Notes/meeting-notes.txt');
2432
+ expect(matchesTitleOrFilename(note, 'nonexistent | alsonot')).toBe(false);
2433
+ });
2434
+ it('pipe | gives OR: matches on second alternative', () => {
2435
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2436
+ // First term doesn't match, second does
2437
+ expect(matchesTitleOrFilename(note, 'einstein | knuth')).toBe(true);
2438
+ });
2439
+ it('spaces give AND: all words must match', () => {
2440
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2441
+ expect(matchesTitleOrFilename(note, 'knuth reviewer')).toBe(true);
2442
+ expect(matchesTitleOrFilename(note, 'knuth 0049')).toBe(true);
2443
+ expect(matchesTitleOrFilename(note, 'knuth einstein')).toBe(false);
2444
+ });
2445
+ it('OR with AND: each pipe alternative is AND-matched independently', () => {
2446
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2447
+ // "knuth einstein" fails AND, but "0049 reviewer" passes AND → OR succeeds
2448
+ expect(matchesTitleOrFilename(note, 'knuth einstein | 0049 reviewer')).toBe(true);
2449
+ });
2450
+ it('OR with AND: fails when no alternative matches all its tokens', () => {
2451
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2452
+ expect(matchesTitleOrFilename(note, 'knuth einstein | foo bar')).toBe(false);
2453
+ });
2454
+ // --- listNotes also supports OR (pipe) ---
2455
+ it('listNotes: pipe | gives OR', () => {
2456
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2457
+ expect(matchesListQuery(note, 'nonexistent | knuth')).toBe(true);
2458
+ expect(matchesListQuery(note, 'nonexistent | alsonot')).toBe(false);
2459
+ });
2460
+ it('listNotes: OR with AND combined', () => {
2461
+ const note = buildNote('---\ntitle: 🟥_0049_knuth_reviewer\ntype: project-note\n---\n# Body', 'Notes/some-file.txt');
2462
+ expect(matchesListQuery(note, 'knuth einstein | 0049 reviewer')).toBe(true);
2463
+ expect(matchesListQuery(note, 'foo bar | baz qux')).toBe(false);
2464
+ });
2465
+ // --- Symmetric underscore normalization ---
2466
+ it('underscore in search term matches space in title', () => {
2467
+ const note = buildNote('---\ntitle: knuth reviewer notes\n---\n# Body', 'Notes/some-file.txt');
2468
+ // Title has spaces, query has underscore — should still match
2469
+ expect(matchesTitleOrFilename(note, 'knuth_reviewer')).toBe(true);
2470
+ });
2471
+ it('underscore in search term matches underscore in title', () => {
2472
+ const note = buildNote('---\ntitle: knuth_reviewer_notes\n---\n# Body', 'Notes/some-file.txt');
2473
+ expect(matchesTitleOrFilename(note, 'knuth_reviewer')).toBe(true);
2474
+ });
2475
+ it('space in search term matches underscore in title', () => {
2476
+ const note = buildNote('---\ntitle: knuth_reviewer_notes\n---\n# Body', 'Notes/some-file.txt');
2477
+ expect(matchesTitleOrFilename(note, 'knuth reviewer')).toBe(true);
2478
+ });
2479
+ });
2480
+ // ── listNotes — folder scope inside a space (regression: PR #6 by @steinwaywhw) ──
2481
+ // Ported from PR #6 to the post-bridge async architecture.
2482
+ describe('listNotes – folder scope inside a space', () => {
2483
+ const spaceId = 'space-001';
2484
+ const folderId = 'folder-001';
2485
+ const noteInFolder = {
2486
+ id: 'note-in-folder',
2487
+ title: 'Project Alpha',
2488
+ filename: `%%NotePlanCloud%%/${spaceId}/${folderId}/note-in-folder`,
2489
+ type: 'note',
2490
+ source: 'space',
2491
+ folder: folderId,
2492
+ content: '# Project Alpha',
2493
+ modifiedAt: new Date(),
2494
+ createdAt: new Date(),
2495
+ spaceId,
2496
+ };
2497
+ const noteInOtherFolder = {
2498
+ ...noteInFolder,
2499
+ id: 'note-other',
2500
+ title: 'Other Note',
2501
+ folder: 'folder-002',
2502
+ filename: `%%NotePlanCloud%%/${spaceId}/folder-002/note-other`,
2503
+ };
2504
+ beforeEach(() => {
2505
+ vi.clearAllMocks();
2506
+ vi.mocked(sqliteReader.listSpaces).mockResolvedValue([
2507
+ { id: spaceId, name: 'My Space', noteCount: 2 },
2508
+ ]);
2509
+ vi.mocked(sqliteReader.listSpaceNotes).mockResolvedValue([noteInFolder, noteInOtherFolder]);
2510
+ });
2511
+ it('returns only notes in the resolved folder', async () => {
2512
+ vi.mocked(sqliteReader.resolveSpaceFolder).mockResolvedValue({
2513
+ id: folderId,
2514
+ path: 'Active Projects',
2515
+ name: 'Active Projects',
2516
+ source: 'space',
2517
+ spaceId,
2518
+ });
2519
+ const result = await listNotes({ folder: 'Active Projects', space: 'My Space' });
2520
+ expect(result.map((n) => n.title)).toEqual(['Project Alpha']);
2521
+ expect(sqliteReader.resolveSpaceFolder).toHaveBeenCalledWith(spaceId, 'Active Projects');
2522
+ });
2523
+ it('returns empty when the folder does not resolve in the space', async () => {
2524
+ vi.mocked(sqliteReader.resolveSpaceFolder).mockResolvedValue(null);
2525
+ const result = await listNotes({ folder: 'Nonexistent', space: 'My Space' });
2526
+ expect(result).toHaveLength(0);
2527
+ });
2528
+ it('returns all space notes when no folder is specified', async () => {
2529
+ const result = await listNotes({ space: 'My Space' });
2530
+ expect(result).toHaveLength(2);
2531
+ expect(sqliteReader.resolveSpaceFolder).not.toHaveBeenCalled();
2532
+ });
2533
+ });
2534
+ // ---------------------------------------------------------------------------
2535
+ // Folder access rules — read-side filtering in unified-store
2536
+ // ---------------------------------------------------------------------------
2537
+ import { searchNotes, listFolders, listTags, getCalendarNote, invalidateListingCaches } from '../noteplan/unified-store.js';
2538
+ import { __resetFolderAccessConfigForTests } from '../utils/folder-access.js';
2539
+ describe('folder-access read-side filtering', () => {
2540
+ beforeEach(() => {
2541
+ vi.clearAllMocks();
2542
+ delete process.env.NOTEPLAN_ALLOWED_FOLDERS;
2543
+ delete process.env.NOTEPLAN_DENIED_FOLDERS;
2544
+ __resetFolderAccessConfigForTests();
2545
+ // The listNotes cache is module-level so a previous test's filtered
2546
+ // result would leak into this one. Reset before every case.
2547
+ invalidateListingCaches();
2548
+ // Reset readers to safe defaults — individual tests override.
2549
+ vi.mocked(sqliteReader.listSpaces).mockResolvedValue([]);
2550
+ vi.mocked(sqliteReader.listSpaceNotes).mockResolvedValue([]);
2551
+ vi.mocked(sqliteReader.searchSpaceNotesFTS).mockResolvedValue({ results: [], backend: 'sqlite' });
2552
+ vi.mocked(fileReader.listProjectNotes).mockResolvedValue([]);
2553
+ vi.mocked(fileReader.listCalendarNotes).mockResolvedValue([]);
2554
+ vi.mocked(fileReader.searchLocalNotes).mockResolvedValue([]);
2555
+ vi.mocked(fileReader.readNoteFile).mockResolvedValue(null);
2556
+ });
2557
+ afterEach(() => {
2558
+ delete process.env.NOTEPLAN_ALLOWED_FOLDERS;
2559
+ delete process.env.NOTEPLAN_DENIED_FOLDERS;
2560
+ __resetFolderAccessConfigForTests();
2561
+ });
2562
+ it('listNotes drops notes inside a denied folder', async () => {
2563
+ process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
2564
+ __resetFolderAccessConfigForTests();
2565
+ const personal = {
2566
+ id: 'Notes/Personal/Diary.md',
2567
+ title: 'Diary',
2568
+ filename: 'Notes/Personal/Diary.md',
2569
+ type: 'note',
2570
+ source: 'local',
2571
+ folder: 'Notes/Personal',
2572
+ content: '',
2573
+ modifiedAt: new Date(),
2574
+ createdAt: new Date(),
2575
+ spaceId: undefined,
2576
+ };
2577
+ const work = { ...personal, id: 'Notes/Work/Plan.md', filename: 'Notes/Work/Plan.md', folder: 'Notes/Work', title: 'Plan' };
2578
+ vi.mocked(fileReader.listProjectNotes).mockResolvedValue([personal, work]);
2579
+ const result = await listNotes();
2580
+ expect(result.map((n) => n.filename)).toEqual(['Notes/Work/Plan.md']);
2581
+ });
2582
+ it('getNote returns null for a denied filename even if the reader has it', async () => {
2583
+ process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
2584
+ __resetFolderAccessConfigForTests();
2585
+ const personal = {
2586
+ id: 'Notes/Personal/Diary.md',
2587
+ title: 'Diary',
2588
+ filename: 'Notes/Personal/Diary.md',
2589
+ type: 'note',
2590
+ source: 'local',
2591
+ folder: 'Notes/Personal',
2592
+ content: '',
2593
+ modifiedAt: new Date(),
2594
+ createdAt: new Date(),
2595
+ spaceId: undefined,
2596
+ };
2597
+ vi.mocked(fileReader.readNoteFile).mockResolvedValue(personal);
2598
+ vi.mocked(sqliteReader.getSpaceNote).mockResolvedValue(null);
2599
+ const result = await getNote({ filename: 'Notes/Personal/Diary.md' });
2600
+ expect(result).toBeNull();
2601
+ });
2602
+ it('searchNotes filters denied folders out of the result set', async () => {
2603
+ process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
2604
+ __resetFolderAccessConfigForTests();
2605
+ const personal = {
2606
+ id: 'Notes/Personal/Diary.md',
2607
+ title: 'Diary about work',
2608
+ filename: 'Notes/Personal/Diary.md',
2609
+ type: 'note',
2610
+ source: 'local',
2611
+ folder: 'Notes/Personal',
2612
+ content: 'meeting work plan',
2613
+ modifiedAt: new Date(),
2614
+ createdAt: new Date(),
2615
+ spaceId: undefined,
2616
+ };
2617
+ const work = { ...personal, id: 'Notes/Work/Plan.md', filename: 'Notes/Work/Plan.md', folder: 'Notes/Work', title: 'Diary at Work' };
2618
+ // Use the metadata-search branch (searchField=title) so we don't need to
2619
+ // wrangle the ripgrep mock — same filter site applies.
2620
+ vi.mocked(fileReader.listProjectNotes).mockResolvedValue([personal, work]);
2621
+ const result = await searchNotes('diary', { searchField: 'title' });
2622
+ // Both notes have "diary" in their title; the denied one must be filtered out.
2623
+ expect(result.results.map((r) => r.note.filename)).toEqual(['Notes/Work/Plan.md']);
2624
+ });
2625
+ // Folder *listings* themselves used to leak — `noteplan_folders(action: list/find/resolve)`
2626
+ // would return a denied folder by name. The fix prepends `Notes/` to the folder's relative
2627
+ // path so the same env-var match works ("Notes/Personal" rule against `Personal/Diary`).
2628
+ it('listFolders drops local folders inside a denied subtree', async () => {
2629
+ process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
2630
+ __resetFolderAccessConfigForTests();
2631
+ vi.mocked(fileReader.listFolders).mockResolvedValue([
2632
+ { path: 'Personal', name: 'Personal', source: 'local' },
2633
+ { path: 'Personal/Finance', name: 'Finance', source: 'local' },
2634
+ { path: 'Work', name: 'Work', source: 'local' },
2635
+ ]);
2636
+ const result = await listFolders();
2637
+ expect(result.map((f) => f.path)).toEqual(['Work']);
2638
+ });
2639
+ // getCalendarNote / getTodayNote / per-day fetches in getNotesInRange used
2640
+ // to call fileReader.getCalendarNote directly, bypassing isFolderAllowed.
2641
+ // Important: a user can deny `Calendar` outright to hide all calendar notes.
2642
+ it('getCalendarNote returns null when Calendar/ is denied', async () => {
2643
+ process.env.NOTEPLAN_DENIED_FOLDERS = 'Calendar';
2644
+ __resetFolderAccessConfigForTests();
2645
+ vi.mocked(fileReader.getCalendarNote).mockResolvedValue({
2646
+ id: 'Calendar/20260507.txt',
2647
+ title: '20260507',
2648
+ filename: 'Calendar/20260507.txt',
2649
+ type: 'calendar',
2650
+ source: 'local',
2651
+ folder: 'Calendar',
2652
+ content: 'private',
2653
+ modifiedAt: new Date(),
2654
+ createdAt: new Date(),
2655
+ spaceId: undefined,
2656
+ });
2657
+ const result = await getCalendarNote('20260507');
2658
+ expect(result).toBeNull();
2659
+ });
2660
+ // listTags walked the bridge / ripgrep / filesystem indiscriminately, so
2661
+ // tag NAMES from denied notes leaked through `noteplan_search(action:
2662
+ // list_tags)` even though the notes themselves were filtered. When rules
2663
+ // are configured we now iterate the already-filtered listNotes() output.
2664
+ it('listTags excludes tags that exist only inside a denied folder', async () => {
2665
+ process.env.NOTEPLAN_DENIED_FOLDERS = 'Notes/Personal';
2666
+ __resetFolderAccessConfigForTests();
2667
+ const personal = {
2668
+ id: 'Notes/Personal/Diary.md',
2669
+ title: 'Diary',
2670
+ filename: 'Notes/Personal/Diary.md',
2671
+ type: 'note',
2672
+ source: 'local',
2673
+ folder: 'Notes/Personal',
2674
+ content: 'session with #therapist about #anxiety',
2675
+ modifiedAt: new Date(),
2676
+ createdAt: new Date(),
2677
+ spaceId: undefined,
2678
+ };
2679
+ const work = {
2680
+ ...personal,
2681
+ id: 'Notes/Work/Plan.md',
2682
+ filename: 'Notes/Work/Plan.md',
2683
+ folder: 'Notes/Work',
2684
+ title: 'Plan',
2685
+ content: 'review #q2 plan with #team',
2686
+ };
2687
+ vi.mocked(fileReader.listProjectNotes).mockResolvedValue([personal, work]);
2688
+ const tags = await listTags();
2689
+ expect(tags).toEqual(expect.arrayContaining(['#q2', '#team']));
2690
+ expect(tags).not.toContain('#therapist');
2691
+ expect(tags).not.toContain('#anxiety');
2692
+ });
2693
+ // No rules configured → keep the fast bridge / ripgrep / extractAllTags path.
2694
+ it('listTags uses the fast extractAllTags path when no rules are configured', async () => {
2695
+ vi.mocked(fileReader.extractAllTags).mockResolvedValue(['#fast', '#path']);
2696
+ const tags = await listTags();
2697
+ expect(tags).toEqual(['#fast', '#path']);
2698
+ expect(fileReader.extractAllTags).toHaveBeenCalled();
2699
+ });
2700
+ });
2221
2701
  //# sourceMappingURL=notes.test.js.map