@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
@@ -1,4 +1,10 @@
1
1
  // File system reader for local NotePlan notes
2
+ //
3
+ // All exported I/O functions are async and route through the MCP bridge
4
+ // when NotePlan is running (avoiding TCC prompts), falling back to direct
5
+ // fs access otherwise. The path detection at the bottom of the file stays
6
+ // synchronous because it bootstraps once at startup before any caller can
7
+ // await anything.
2
8
  import * as fs from 'fs';
3
9
  import * as path from 'path';
4
10
  import * as os from 'os';
@@ -7,6 +13,9 @@ import { extractTitle, extractTagsFromContent } from './markdown-parser.js';
7
13
  import { extractDateFromFilename } from '../utils/date-utils.js';
8
14
  import { getDetectedAppName } from '../utils/version.js';
9
15
  import { normalizeFilename } from '../utils/filename-normalize.js';
16
+ import { getBridgeClient } from '../transport/bridge-availability.js';
17
+ import { readFileUtf8, statPath, readDir } from '../transport/bridge-fs.js';
18
+ import { isRipgrepAvailable, ripgrepOnlyMatching } from './ripgrep-search.js';
10
19
  /** Valid note file extensions in NotePlan */
11
20
  const VALID_NOTE_EXTENSIONS = ['.md', '.txt'];
12
21
  /**
@@ -18,6 +27,25 @@ export function isValidNoteExtension(filename) {
18
27
  const ext = path.extname(filename).toLowerCase();
19
28
  return VALID_NOTE_EXTENSIONS.includes(ext);
20
29
  }
30
+ /**
31
+ * Folders that should be skipped when listing/recursing the user's notes:
32
+ * - dot-prefixed (e.g. .DS_Store, .git)
33
+ * - NotePlan's @Trash and @Archive system folders
34
+ * - <NoteName>_attachments folders that NotePlan auto-creates next to
35
+ * notes that have images/files attached. They're not user-organized
36
+ * folders and surfacing them confuses tools that just want the
37
+ * organizational tree.
38
+ */
39
+ function isHiddenFolder(name) {
40
+ if (name.startsWith('.'))
41
+ return true;
42
+ if (name === '@Trash' || name === '@Archive')
43
+ return true;
44
+ if (name.endsWith('_attachments'))
45
+ return true;
46
+ return false;
47
+ }
48
+ // MARK: - Storage path detection (sync, runs once at startup)
21
49
  // Possible NotePlan storage paths (in order of preference)
22
50
  const POSSIBLE_PATHS = [
23
51
  // Direct local paths (AppStore version) - preferred for local dev
@@ -34,9 +62,6 @@ const POSSIBLE_PATHS = [
34
62
  path.join(os.homedir(), 'Library/Mobile Documents/iCloud~co~noteplan~NotePlan-setapp/Documents'),
35
63
  ];
36
64
  let cachedConfig = null;
37
- /**
38
- * Detect the file extension used in a directory by examining existing files
39
- */
40
65
  function detectFileExtension(calendarPath) {
41
66
  try {
42
67
  const entries = fs.readdirSync(calendarPath, { withFileTypes: true });
@@ -46,7 +71,6 @@ function detectFileExtension(calendarPath) {
46
71
  let newestMd = 0;
47
72
  for (const entry of entries) {
48
73
  if (entry.isFile()) {
49
- // Check for daily note pattern (YYYYMMDD)
50
74
  if (/^\d{8}\.(txt|md)$/.test(entry.name)) {
51
75
  const filePath = path.join(calendarPath, entry.name);
52
76
  const stats = fs.statSync(filePath);
@@ -61,30 +85,23 @@ function detectFileExtension(calendarPath) {
61
85
  }
62
86
  }
63
87
  }
64
- // Prefer the extension with the most recent file, then by count
65
88
  if (newestTxt > newestMd)
66
89
  return '.txt';
67
90
  if (newestMd > newestTxt)
68
91
  return '.md';
69
92
  if (txtCount > mdCount)
70
93
  return '.txt';
71
- return '.md'; // Default to .md
94
+ return '.md';
72
95
  }
73
96
  catch {
74
- return '.txt'; // Default to .txt
97
+ return '.txt';
75
98
  }
76
99
  }
77
- /**
78
- * Check if calendar notes use year subfolders (Calendar/2024/20240101.txt)
79
- * or flat structure (Calendar/20240101.txt)
80
- */
81
100
  function detectYearSubfolders(calendarPath) {
82
101
  try {
83
102
  const entries = fs.readdirSync(calendarPath, { withFileTypes: true });
84
- // Check for year directories (4 digits)
85
103
  for (const entry of entries) {
86
104
  if (entry.isDirectory() && /^\d{4}$/.test(entry.name)) {
87
- // Verify it contains calendar notes
88
105
  const yearPath = path.join(calendarPath, entry.name);
89
106
  const yearEntries = fs.readdirSync(yearPath);
90
107
  if (yearEntries.some(f => /^\d{8}\.(txt|md)$/.test(f))) {
@@ -92,39 +109,29 @@ function detectYearSubfolders(calendarPath) {
92
109
  }
93
110
  }
94
111
  }
95
- // Check for flat structure (files directly in Calendar/)
96
112
  for (const entry of entries) {
97
113
  if (entry.isFile() && /^\d{8}\.(txt|md)$/.test(entry.name)) {
98
114
  return false;
99
115
  }
100
116
  }
101
- return false; // Default to flat
117
+ return false;
102
118
  }
103
119
  catch {
104
120
  return false;
105
121
  }
106
122
  }
107
- /**
108
- * Score a storage path based on most recent modification time
109
- * Checks the root folder and top-level folders (Calendar, Notes) - folder mtime updates when contents change
110
- */
111
123
  function scoreStoragePath(storagePath) {
112
124
  let newestMtime = 0;
113
125
  try {
114
- // Check the root storage folder
115
126
  const rootStats = fs.statSync(storagePath);
116
127
  newestMtime = Math.max(newestMtime, rootStats.mtimeMs);
117
- // Check Calendar folder
118
128
  const calendarPath = path.join(storagePath, 'Calendar');
119
129
  if (fs.existsSync(calendarPath)) {
120
- const calendarStats = fs.statSync(calendarPath);
121
- newestMtime = Math.max(newestMtime, calendarStats.mtimeMs);
130
+ newestMtime = Math.max(newestMtime, fs.statSync(calendarPath).mtimeMs);
122
131
  }
123
- // Check Notes folder
124
132
  const notesPath = path.join(storagePath, 'Notes');
125
133
  if (fs.existsSync(notesPath)) {
126
- const notesStats = fs.statSync(notesPath);
127
- newestMtime = Math.max(newestMtime, notesStats.mtimeMs);
134
+ newestMtime = Math.max(newestMtime, fs.statSync(notesPath).mtimeMs);
128
135
  }
129
136
  }
130
137
  catch {
@@ -132,32 +139,22 @@ function scoreStoragePath(storagePath) {
132
139
  }
133
140
  return newestMtime;
134
141
  }
135
- /**
136
- * Check if a path contains a valid NotePlan structure (has Calendar or Notes folder)
137
- */
138
142
  function isValidNotePlanPath(storagePath) {
139
143
  if (!fs.existsSync(storagePath))
140
144
  return false;
141
- const hasCalendar = fs.existsSync(path.join(storagePath, 'Calendar'));
142
- const hasNotes = fs.existsSync(path.join(storagePath, 'Notes'));
143
- return hasCalendar || hasNotes;
145
+ return fs.existsSync(path.join(storagePath, 'Calendar')) ||
146
+ fs.existsSync(path.join(storagePath, 'Notes'));
144
147
  }
145
- /**
146
- * Ask the running NotePlan app for its storage path via AppleScript.
147
- * Returns the path if successful, null otherwise.
148
- */
149
148
  function detectStoragePathViaAppleScript() {
150
149
  try {
151
150
  const appName = getDetectedAppName();
152
- // Check if NotePlan is running first — avoid launching the app via AppleScript
153
151
  const isRunning = execFileSync('osascript', ['-e', `application "${appName}" is running`], {
154
152
  encoding: 'utf-8',
155
153
  stdio: ['pipe', 'pipe', 'pipe'],
156
154
  timeout: 3_000,
157
155
  }).trim();
158
- if (isRunning !== 'true') {
156
+ if (isRunning !== 'true')
159
157
  return null;
160
- }
161
158
  const result = execFileSync('osascript', ['-e', `tell application "${appName}" to getStoragePath`], {
162
159
  encoding: 'utf-8',
163
160
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -173,27 +170,16 @@ function detectStoragePathViaAppleScript() {
173
170
  }
174
171
  return null;
175
172
  }
176
- /**
177
- * CloudKit (container) paths — used when isUsingCloudKit is true
178
- */
179
173
  const CLOUDKIT_PATHS = [
180
174
  path.join(os.homedir(), 'Library/Containers/co.noteplan.NotePlan3/Data/Library/Application Support/co.noteplan.NotePlan3'),
181
175
  path.join(os.homedir(), 'Library/Containers/co.noteplan.NotePlan-setapp/Data/Library/Application Support/co.noteplan.NotePlan-setapp'),
182
176
  ];
183
- /**
184
- * iCloud Drive paths — used when isUsingCloudKit is false
185
- */
186
177
  const ICLOUD_DRIVE_PATHS = [
187
178
  path.join(os.homedir(), 'Library/Mobile Documents/iCloud~co~noteplan~Today/Documents'),
188
179
  path.join(os.homedir(), 'Library/Mobile Documents/iCloud~co~noteplan~NotePlan3/Documents'),
189
180
  path.join(os.homedir(), 'Library/Mobile Documents/iCloud~co~noteplan~NotePlan/Documents'),
190
181
  path.join(os.homedir(), 'Library/Mobile Documents/iCloud~co~noteplan~NotePlan-setapp/Documents'),
191
182
  ];
192
- /**
193
- * Read the sync method from NotePlan's UserDefaults and return the matching storage path.
194
- * More reliable than filesystem scoring since it reads the actual user preference.
195
- * Returns null if the preference can't be read or no valid path is found.
196
- */
197
183
  function detectStoragePathViaUserDefaults() {
198
184
  try {
199
185
  const result = execFileSync('defaults', ['read', 'co.noteplan.NotePlan3', 'isUsingCloudKit'], {
@@ -215,19 +201,13 @@ function detectStoragePathViaUserDefaults() {
215
201
  }
216
202
  return null;
217
203
  }
218
- /**
219
- * Detect and cache NotePlan configuration
220
- */
221
204
  function detectConfig() {
222
205
  if (cachedConfig)
223
206
  return cachedConfig;
224
- // First, ask the running app directly
225
207
  let bestPath = detectStoragePathViaAppleScript();
226
- // Try UserDefaults to determine sync method before falling back to filesystem scoring
227
208
  if (!bestPath) {
228
209
  bestPath = detectStoragePathViaUserDefaults();
229
210
  }
230
- // Last resort: filesystem scoring (least reliable — can pick wrong folder)
231
211
  if (!bestPath) {
232
212
  let bestScore = -1;
233
213
  for (const storagePath of POSSIBLE_PATHS) {
@@ -245,7 +225,6 @@ function detectConfig() {
245
225
  }
246
226
  const calendarPath = path.join(bestPath, 'Calendar');
247
227
  const hasYearSubfolders = detectYearSubfolders(calendarPath);
248
- // Detect extension in the right location
249
228
  const extensionDetectPath = hasYearSubfolders
250
229
  ? path.join(calendarPath, new Date().getFullYear().toString())
251
230
  : calendarPath;
@@ -260,45 +239,25 @@ function detectConfig() {
260
239
  console.error(`NotePlan config: ${bestPath} (ext: ${fileExtension}, yearFolders: ${hasYearSubfolders})`);
261
240
  return cachedConfig;
262
241
  }
263
- /**
264
- * Get the NotePlan storage path
265
- */
242
+ // MARK: - Synchronous path getters (read cached config)
266
243
  export function getNotePlanPath() {
267
244
  return detectConfig().storagePath;
268
245
  }
269
- /**
270
- * Get the detected file extension
271
- */
272
246
  export function getFileExtension() {
273
247
  return detectConfig().fileExtension;
274
248
  }
275
- /**
276
- * Get whether year subfolders are used
277
- */
278
249
  export function hasYearSubfolders() {
279
250
  return detectConfig().hasYearSubfolders;
280
251
  }
281
- /**
282
- * Get all available NotePlan storage paths (for multi-source searching)
283
- */
284
252
  export function getAllNotePlanPaths() {
285
253
  return POSSIBLE_PATHS.filter(isValidNotePlanPath);
286
254
  }
287
- /**
288
- * Get the Calendar notes directory
289
- */
290
255
  export function getCalendarPath() {
291
256
  return path.join(getNotePlanPath(), 'Calendar');
292
257
  }
293
- /**
294
- * Get the project Notes directory
295
- */
296
258
  export function getNotesPath() {
297
259
  return path.join(getNotePlanPath(), 'Notes');
298
260
  }
299
- /**
300
- * Build the calendar note file path for a given date
301
- */
302
261
  export function buildCalendarNotePath(dateStr) {
303
262
  const config = detectConfig();
304
263
  const ext = config.fileExtension;
@@ -308,66 +267,102 @@ export function buildCalendarNotePath(dateStr) {
308
267
  }
309
268
  return `Calendar/${dateStr}${ext}`;
310
269
  }
270
+ // NotePlan stores `defaultNoteExtension` as a real preference; the fs
271
+ // heuristic in detectFileExtension only counts existing files, so a user
272
+ // who recently switched to .md will still get .txt for new calendar notes
273
+ // until the new files outnumber the old. Calendar notes are extension-
274
+ // sensitive (NotePlan ignores wrong-extension files), so we ask the
275
+ // bridge for the truth whenever NotePlan is running.
276
+ const BRIDGE_EXT_TTL_MS = 60_000;
277
+ let bridgeFileExtensionCache = null;
278
+ /** @internal exposed for tests; the cache TTL prevents real-world leaks. */
279
+ export function __resetCalendarExtensionCache() {
280
+ bridgeFileExtensionCache = null;
281
+ }
282
+ export async function resolveNotePlanFileExtension() {
283
+ if (bridgeFileExtensionCache && bridgeFileExtensionCache.expiresAt > Date.now()) {
284
+ return bridgeFileExtensionCache.ext;
285
+ }
286
+ const bridge = await getBridgeClient();
287
+ if (bridge) {
288
+ try {
289
+ const config = await bridge.config();
290
+ if (config.fileExtension === '.md' || config.fileExtension === '.txt') {
291
+ bridgeFileExtensionCache = {
292
+ ext: config.fileExtension,
293
+ expiresAt: Date.now() + BRIDGE_EXT_TTL_MS,
294
+ };
295
+ return config.fileExtension;
296
+ }
297
+ }
298
+ catch {
299
+ // Fall through to fs heuristic.
300
+ }
301
+ }
302
+ return detectConfig().fileExtension;
303
+ }
304
+ export async function buildCalendarNotePathAsync(dateStr) {
305
+ const ext = await resolveNotePlanFileExtension();
306
+ if (detectConfig().hasYearSubfolders) {
307
+ const year = dateStr.substring(0, 4);
308
+ return `Calendar/${year}/${dateStr}${ext}`;
309
+ }
310
+ return `Calendar/${dateStr}${ext}`;
311
+ }
312
+ // MARK: - Async I/O exports
311
313
  /**
312
- * Read a note file from the file system
314
+ * Read a note file from the file system (or via the bridge when NotePlan
315
+ * is running). Handles Unicode NFC/NFD path mismatches and rejects paths
316
+ * outside the storage root.
313
317
  */
314
- export function readNoteFile(filePath) {
318
+ export async function readNoteFile(filePath) {
315
319
  try {
316
320
  const fullPath = path.isAbsolute(filePath) ? filePath : path.join(getNotePlanPath(), filePath);
317
- // Reject paths outside the NotePlan data directory
318
321
  const resolvedFull = path.resolve(fullPath);
319
322
  const resolvedRoot = path.resolve(getNotePlanPath());
320
323
  if (resolvedFull !== resolvedRoot && !resolvedFull.startsWith(`${resolvedRoot}${path.sep}`)) {
321
324
  return null;
322
325
  }
323
326
  let resolvedPath = fullPath;
324
- if (!fs.existsSync(resolvedPath)) {
327
+ let stats = await statPath(resolvedPath);
328
+ if (!stats.exists) {
325
329
  // Try Unicode-normalized form (NFC) — handles NFD/NFC mismatches on macOS
326
330
  const nfcPath = normalizeFilename(resolvedPath);
327
- if (nfcPath !== resolvedPath && fs.existsSync(nfcPath)) {
328
- resolvedPath = nfcPath;
329
- }
330
- else {
331
- // Last resort: scan the parent directory for a Unicode-equivalent match
332
- const dir = path.dirname(fullPath);
333
- const targetBase = normalizeFilename(path.basename(fullPath));
334
- if (fs.existsSync(dir)) {
335
- try {
336
- const entries = fs.readdirSync(dir);
337
- const match = entries.find((e) => e.normalize('NFC') === targetBase);
338
- if (match) {
339
- resolvedPath = path.join(dir, match);
340
- }
341
- else {
342
- return null;
343
- }
344
- }
345
- catch {
346
- return null;
347
- }
348
- }
349
- else {
350
- return null;
331
+ if (nfcPath !== resolvedPath) {
332
+ const nfcStats = await statPath(nfcPath);
333
+ if (nfcStats.exists) {
334
+ resolvedPath = nfcPath;
335
+ stats = nfcStats;
351
336
  }
352
337
  }
353
338
  }
354
- // Re-check that the resolved path is still within the NotePlan data directory
339
+ if (!stats.exists) {
340
+ // Last resort: scan the parent directory for a Unicode-equivalent match
341
+ const dir = path.dirname(fullPath);
342
+ const targetBase = normalizeFilename(path.basename(fullPath));
343
+ const entries = await readDir(dir);
344
+ const match = entries.find((e) => e.name.normalize('NFC') === targetBase);
345
+ if (!match)
346
+ return null;
347
+ resolvedPath = path.join(dir, match.name);
348
+ stats = await statPath(resolvedPath);
349
+ if (!stats.exists)
350
+ return null;
351
+ }
352
+ if (stats.isDir)
353
+ return null;
355
354
  const resolvedFinal = path.resolve(resolvedPath);
356
355
  if (resolvedFinal !== resolvedRoot && !resolvedFinal.startsWith(`${resolvedRoot}${path.sep}`)) {
357
356
  return null;
358
357
  }
359
- const stats = fs.statSync(resolvedPath);
360
- if (stats.isDirectory()) {
361
- return null;
362
- }
363
- // Only read files with valid note extensions (.md, .txt)
364
358
  if (!isValidNoteExtension(path.basename(resolvedPath))) {
365
359
  return null;
366
360
  }
367
- const content = fs.readFileSync(resolvedPath, 'utf-8');
361
+ const content = await readFileUtf8(resolvedPath);
362
+ if (content === null)
363
+ return null;
368
364
  const relativePath = path.relative(getNotePlanPath(), resolvedPath);
369
365
  const filename = path.basename(resolvedPath);
370
- // Determine note type
371
366
  let type = 'note';
372
367
  let date;
373
368
  if (relativePath.startsWith('Calendar/') || relativePath.startsWith('Calendar\\')) {
@@ -378,7 +373,6 @@ export function readNoteFile(filePath) {
378
373
  relativePath.startsWith('@Trash/') || relativePath.startsWith('@Trash\\')) {
379
374
  type = 'trash';
380
375
  }
381
- // Extract folder from path
382
376
  const folder = path.dirname(relativePath);
383
377
  return {
384
378
  id: relativePath,
@@ -390,7 +384,7 @@ export function readNoteFile(filePath) {
390
384
  folder: folder !== '.' ? folder : undefined,
391
385
  date,
392
386
  modifiedAt: stats.mtime,
393
- createdAt: stats.birthtime,
387
+ createdAt: stats.ctime,
394
388
  };
395
389
  }
396
390
  catch (error) {
@@ -399,37 +393,31 @@ export function readNoteFile(filePath) {
399
393
  }
400
394
  }
401
395
  /**
402
- * List all notes in a directory (recursive)
396
+ * List all notes in a directory (recursive).
403
397
  */
404
- export function listNotesInDirectory(dirPath, type = 'note') {
398
+ export async function listNotesInDirectory(dirPath, type = 'note') {
405
399
  const notes = [];
406
400
  try {
407
401
  const fullPath = path.isAbsolute(dirPath) ? dirPath : path.join(getNotePlanPath(), dirPath);
408
- // Reject paths outside the NotePlan data directory
409
402
  const resolvedFull = path.resolve(fullPath);
410
403
  const resolvedRoot = path.resolve(getNotePlanPath());
411
404
  if (resolvedFull !== resolvedRoot && !resolvedFull.startsWith(`${resolvedRoot}${path.sep}`)) {
412
405
  return notes;
413
406
  }
414
- if (!fs.existsSync(fullPath)) {
415
- return notes;
416
- }
417
- const entries = fs.readdirSync(fullPath, { withFileTypes: true });
407
+ // readDir returns [] for missing dirs, so an explicit pathExists check
408
+ // would be a redundant stat round-trip per recursion level.
409
+ const entries = await readDir(fullPath);
418
410
  for (const entry of entries) {
419
411
  const entryPath = path.join(fullPath, entry.name);
420
- if (entry.isDirectory()) {
421
- // Skip hidden directories and Trash
422
- if (entry.name.startsWith('.') || entry.name === '@Trash' || entry.name === '@Archive') {
412
+ if (entry.isDir) {
413
+ if (isHiddenFolder(entry.name))
423
414
  continue;
424
- }
425
- // Recurse into subdirectories
426
- notes.push(...listNotesInDirectory(entryPath, type));
415
+ notes.push(...(await listNotesInDirectory(entryPath, type)));
427
416
  }
428
417
  else if (entry.name.endsWith('.md') || entry.name.endsWith('.txt')) {
429
- const note = readNoteFile(entryPath);
430
- if (note) {
418
+ const note = await readNoteFile(entryPath);
419
+ if (note)
431
420
  notes.push(note);
432
- }
433
421
  }
434
422
  }
435
423
  }
@@ -439,22 +427,20 @@ export function listNotesInDirectory(dirPath, type = 'note') {
439
427
  return notes;
440
428
  }
441
429
  /**
442
- * Count notes and subfolders in a directory (recursive, lightweight — no file reads)
430
+ * Count notes and subfolders in a directory (recursive, lightweight — no file reads).
443
431
  */
444
- export function countNotesInDirectory(dirPath) {
432
+ export async function countNotesInDirectory(dirPath) {
445
433
  let noteCount = 0;
446
434
  let folderCount = 0;
447
435
  try {
448
436
  const fullPath = path.isAbsolute(dirPath) ? dirPath : path.join(getNotePlanPath(), dirPath);
449
- if (!fs.existsSync(fullPath))
450
- return { noteCount, folderCount };
451
- const entries = fs.readdirSync(fullPath, { withFileTypes: true });
437
+ const entries = await readDir(fullPath);
452
438
  for (const entry of entries) {
453
- if (entry.isDirectory()) {
454
- if (entry.name.startsWith('.') || entry.name === '@Trash' || entry.name === '@Archive')
439
+ if (entry.isDir) {
440
+ if (isHiddenFolder(entry.name))
455
441
  continue;
456
442
  folderCount++;
457
- const sub = countNotesInDirectory(path.join(fullPath, entry.name));
443
+ const sub = await countNotesInDirectory(path.join(fullPath, entry.name));
458
444
  noteCount += sub.noteCount;
459
445
  folderCount += sub.folderCount;
460
446
  }
@@ -468,117 +454,131 @@ export function countNotesInDirectory(dirPath) {
468
454
  }
469
455
  return { noteCount, folderCount };
470
456
  }
471
- /**
472
- * List all project notes
473
- */
474
- export function listProjectNotes(folder) {
457
+ export async function listProjectNotes(folder) {
475
458
  const basePath = folder ? path.join(getNotesPath(), folder) : getNotesPath();
476
459
  return listNotesInDirectory(basePath, 'note');
477
460
  }
478
- /**
479
- * List all calendar notes
480
- */
481
- export function listCalendarNotes(year) {
461
+ export async function listCalendarNotes(year) {
482
462
  const basePath = year ? path.join(getCalendarPath(), year) : getCalendarPath();
483
463
  return listNotesInDirectory(basePath, 'calendar');
484
464
  }
485
465
  /**
486
- * Get a calendar note by date - tries multiple file paths
466
+ * Get a calendar note by date tries multiple file paths.
487
467
  */
488
- export function getCalendarNote(dateStr) {
468
+ export async function getCalendarNote(dateStr) {
489
469
  const config = detectConfig();
470
+ const preferredExt = await resolveNotePlanFileExtension();
490
471
  const year = dateStr.substring(0, 4);
491
- // Build list of paths to try (in order of preference)
492
472
  const pathsToTry = [];
493
- // First try the detected configuration
494
473
  if (config.hasYearSubfolders) {
495
- pathsToTry.push(`Calendar/${year}/${dateStr}${config.fileExtension}`);
496
- // Also try the other extension
497
- const otherExt = config.fileExtension === '.txt' ? '.md' : '.txt';
474
+ pathsToTry.push(`Calendar/${year}/${dateStr}${preferredExt}`);
475
+ const otherExt = preferredExt === '.txt' ? '.md' : '.txt';
498
476
  pathsToTry.push(`Calendar/${year}/${dateStr}${otherExt}`);
499
477
  }
500
478
  else {
501
- pathsToTry.push(`Calendar/${dateStr}${config.fileExtension}`);
502
- const otherExt = config.fileExtension === '.txt' ? '.md' : '.txt';
479
+ pathsToTry.push(`Calendar/${dateStr}${preferredExt}`);
480
+ const otherExt = preferredExt === '.txt' ? '.md' : '.txt';
503
481
  pathsToTry.push(`Calendar/${dateStr}${otherExt}`);
504
482
  }
505
- // Try flat structure with both extensions
506
483
  pathsToTry.push(`Calendar/${dateStr}.txt`);
507
484
  pathsToTry.push(`Calendar/${dateStr}.md`);
508
- // Try year subfolder with both extensions
509
485
  pathsToTry.push(`Calendar/${year}/${dateStr}.txt`);
510
486
  pathsToTry.push(`Calendar/${year}/${dateStr}.md`);
511
- // Deduplicate
512
487
  const uniquePaths = [...new Set(pathsToTry)];
513
488
  for (const filePath of uniquePaths) {
514
- const note = readNoteFile(filePath);
489
+ const note = await readNoteFile(filePath);
515
490
  if (note)
516
491
  return note;
517
492
  }
518
493
  return null;
519
494
  }
520
495
  /**
521
- * Get a note by title (searches project notes)
496
+ * Get a note by title (searches project notes).
522
497
  */
523
- export function getNoteByTitle(title) {
524
- const notes = listProjectNotes();
498
+ export async function getNoteByTitle(title) {
499
+ const notes = await listProjectNotes();
525
500
  const lowerTitle = title.toLowerCase();
526
- // Try exact match first
527
501
  const exactMatch = notes.find((n) => n.title.toLowerCase() === lowerTitle);
528
502
  if (exactMatch)
529
503
  return exactMatch;
530
- // Try filename match (without extension)
531
504
  const filenameMatch = notes.find((n) => {
532
505
  const basename = path.basename(n.filename, path.extname(n.filename));
533
506
  return basename.toLowerCase() === lowerTitle;
534
507
  });
535
508
  if (filenameMatch)
536
509
  return filenameMatch;
537
- // Try partial match
538
510
  const partialMatch = notes.find((n) => n.title.toLowerCase().includes(lowerTitle));
539
511
  return partialMatch || null;
540
512
  }
541
513
  /**
542
- * List all folders in the Notes directory
514
+ * List all folders in the Notes directory.
543
515
  */
544
- export function listFolders(maxDepth) {
516
+ export async function listFolders(maxDepth) {
545
517
  const folders = [];
546
- function scanDir(dirPath, relativePath = '', depth = 0) {
547
- try {
548
- const entries = fs.readdirSync(dirPath, { withFileTypes: true });
549
- for (const entry of entries) {
550
- if (entry.isDirectory() && !entry.name.startsWith('.') &&
551
- entry.name !== '@Trash' && entry.name !== '@Archive') {
552
- const nextDepth = depth + 1;
553
- if (typeof maxDepth === 'number' && nextDepth > maxDepth) {
554
- continue;
555
- }
556
- const folderRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
557
- folders.push({
558
- path: folderRelPath,
559
- name: entry.name,
560
- source: 'local',
561
- });
562
- // Recurse unless max depth reached
563
- if (typeof maxDepth !== 'number' || nextDepth < maxDepth) {
564
- scanDir(path.join(dirPath, entry.name), folderRelPath, nextDepth);
565
- }
518
+ async function scanDir(dirPath, relativePath = '', depth = 0) {
519
+ const entries = await readDir(dirPath);
520
+ for (const entry of entries) {
521
+ if (entry.isDir && !isHiddenFolder(entry.name)) {
522
+ const nextDepth = depth + 1;
523
+ if (typeof maxDepth === 'number' && nextDepth > maxDepth) {
524
+ continue;
525
+ }
526
+ const folderRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
527
+ folders.push({
528
+ path: folderRelPath,
529
+ name: entry.name,
530
+ source: 'local',
531
+ });
532
+ if (typeof maxDepth !== 'number' || nextDepth < maxDepth) {
533
+ await scanDir(path.join(dirPath, entry.name), folderRelPath, nextDepth);
566
534
  }
567
535
  }
568
536
  }
569
- catch (error) {
570
- console.error(`Error scanning folder ${dirPath}:`, error);
571
- }
572
537
  }
573
- scanDir(getNotesPath(), '', 0);
538
+ await scanDir(getNotesPath(), '', 0);
574
539
  return folders;
575
540
  }
576
541
  /**
577
- * Extract all unique tags from local notes
542
+ * Extract all unique tags from local notes.
543
+ *
544
+ * Preferred path: a single `/notes/tags` request to the bridge. NotePlan
545
+ * iterates its own files (no TCC, no per-file HTTP overhead) and returns
546
+ * the deduped, hierarchy-expanded tag list using its native parser.
547
+ *
548
+ * Second path: ripgrep `--only-matching` over Notes/ + Calendar/ — works
549
+ * when running outside the bridge but is unreliable when the calling
550
+ * process lacks Full Disk Access (ripgrep gets interrupted by TCC).
551
+ *
552
+ * Last-resort fallback: read every note via the bridge / fs and extract
553
+ * tags individually. This was the only path before Phase 2c; ~67s on a
554
+ * 5700-note vault.
578
555
  */
579
- export function extractAllTags() {
556
+ export async function extractAllTags() {
557
+ const bridge = await getBridgeClient();
558
+ if (bridge) {
559
+ try {
560
+ return (await bridge.tags()).sort();
561
+ }
562
+ catch {
563
+ // Older NotePlan build without the /notes/tags endpoint, or other
564
+ // transient failure — drop through to the slower paths.
565
+ }
566
+ }
580
567
  const tags = new Set();
581
- const notes = [...listProjectNotes(), ...listCalendarNotes()];
568
+ if (await isRipgrepAvailable()) {
569
+ const matches = await ripgrepOnlyMatching(String.raw `[@#][\w/-]+(\([^)]*\))?`, [getNotesPath(), getCalendarPath()]);
570
+ if (matches !== null) {
571
+ for (const tag of extractTagsFromContent(matches.join('\n'))) {
572
+ tags.add(tag);
573
+ }
574
+ return Array.from(tags).sort();
575
+ }
576
+ }
577
+ const [projectNotes, calendarNotes] = await Promise.all([
578
+ listProjectNotes(),
579
+ listCalendarNotes(),
580
+ ]);
581
+ const notes = [...projectNotes, ...calendarNotes];
582
582
  for (const note of notes) {
583
583
  for (const tag of extractTagsFromContent(note.content)) {
584
584
  tags.add(tag);
@@ -587,21 +587,19 @@ export function extractAllTags() {
587
587
  return Array.from(tags).sort();
588
588
  }
589
589
  /**
590
- * Search notes by content
590
+ * Search notes by content.
591
591
  */
592
- export function searchLocalNotes(query, options = {}) {
592
+ export async function searchLocalNotes(query, options = {}) {
593
593
  const { types, folder, limit = 50 } = options;
594
594
  const results = [];
595
595
  const lowerQuery = query.toLowerCase();
596
- // Get notes to search
597
596
  let notes = [];
598
597
  if (!types || types.includes('note')) {
599
- notes.push(...listProjectNotes(folder));
598
+ notes.push(...(await listProjectNotes(folder)));
600
599
  }
601
600
  if (!types || types.includes('calendar')) {
602
- notes.push(...listCalendarNotes());
601
+ notes.push(...(await listCalendarNotes()));
603
602
  }
604
- // Search
605
603
  for (const note of notes) {
606
604
  if (note.content.toLowerCase().includes(lowerQuery) ||
607
605
  note.title.toLowerCase().includes(lowerQuery)) {