apple-notes-mcp 2.5.7 → 2.5.9

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 (34) hide show
  1. package/README.md +9 -5
  2. package/build/index.js +42755 -1080
  3. package/package.json +3 -3
  4. package/build/index.test.js +0 -446
  5. package/build/services/__fixtures__/notesNormalizedHtml.js +0 -32
  6. package/build/services/appleNotesManager.js +0 -2634
  7. package/build/services/appleNotesManager.test.js +0 -2416
  8. package/build/services/attachmentSave.test.js +0 -85
  9. package/build/services/fileConfig.js +0 -51
  10. package/build/services/fileConfig.test.js +0 -48
  11. package/build/services/notesHtmlMarkdown.test.js +0 -55
  12. package/build/tools/doctor.js +0 -50
  13. package/build/tools/doctor.test.js +0 -42
  14. package/build/tools/resourcesAndPrompts.js +0 -70
  15. package/build/tools/resourcesAndPrompts.test.js +0 -63
  16. package/build/types.js +0 -13
  17. package/build/utils/applescript.js +0 -421
  18. package/build/utils/applescript.test.js +0 -342
  19. package/build/utils/attachmentFs.js +0 -97
  20. package/build/utils/attachmentFs.test.js +0 -69
  21. package/build/utils/checklistParser.js +0 -259
  22. package/build/utils/checklistParser.test.js +0 -230
  23. package/build/utils/contentWarnings.js +0 -44
  24. package/build/utils/contentWarnings.test.js +0 -52
  25. package/build/utils/hashtags.js +0 -56
  26. package/build/utils/hashtags.test.js +0 -45
  27. package/build/utils/jxa.js +0 -139
  28. package/build/utils/jxa.test.js +0 -134
  29. package/build/utils/noteMetadata.js +0 -135
  30. package/build/utils/noteMetadata.test.js +0 -106
  31. package/build/utils/protobuf.js +0 -151
  32. package/build/utils/protobuf.test.js +0 -138
  33. package/build/utils/syncDetection.js +0 -242
  34. package/build/utils/syncDetection.test.js +0 -228
@@ -1,421 +0,0 @@
1
- /**
2
- * AppleScript Execution Utilities
3
- *
4
- * This module provides a safe interface for executing AppleScript commands
5
- * on macOS. It handles script execution, error capture, and result parsing.
6
- *
7
- * @module utils/applescript
8
- */
9
- import { execSync, spawnSync } from "child_process";
10
- /**
11
- * Default execution timeout for AppleScript commands in milliseconds.
12
- * 30 seconds is sufficient for most operations, including complex
13
- * searches on large note collections. Can be overridden per-call.
14
- */
15
- const DEFAULT_TIMEOUT_MS = 30000;
16
- /**
17
- * Output cap for osascript. Node's execSync defaults to 1 MB, which a large
18
- * Notes library (export-notes-json, full-library stat scans, long-note content)
19
- * can blow past — execSync then throws ENOBUFS and the failure surfaces as an
20
- * empty result. 64 MB headroom, overridable via APPLE_NOTES_MCP_MAX_BUFFER. (#16)
21
- */
22
- const DEFAULT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
23
- function getMaxBuffer() {
24
- const raw = process.env.APPLE_NOTES_MCP_MAX_BUFFER;
25
- if (raw !== undefined) {
26
- const n = Number(raw);
27
- if (Number.isFinite(n) && n > 0)
28
- return n;
29
- }
30
- return DEFAULT_MAX_BUFFER_BYTES;
31
- }
32
- /**
33
- * Headroom (ms) between the in-AppleScript `with timeout` and the outer
34
- * osascript process timeout. The script-level timeout must fire first so
35
- * Notes.app aborts from inside its own AppleScript dispatch — releasing the
36
- * event queue — before Node SIGKILLs osascript. Killing osascript alone does
37
- * not stop work already dispatched into Notes.app, which is what wedges it for
38
- * subsequent calls. (#17)
39
- */
40
- const SCRIPT_TIMEOUT_HEADROOM_MS = 5000;
41
- /**
42
- * Wrap a script body in an AppleScript `with timeout` block so an Apple Event
43
- * that honors timeouts aborts cleanly rather than holding Notes.app's
44
- * single-threaded dispatch open. Set below the process timeout so the in-app
45
- * abort wins the race against the outer SIGKILL. (#17)
46
- */
47
- function wrapWithTimeout(script, processTimeoutMs) {
48
- const seconds = Math.max(1, Math.ceil((processTimeoutMs - SCRIPT_TIMEOUT_HEADROOM_MS) / 1000));
49
- return `with timeout of ${seconds} seconds\n${script}\nend timeout`;
50
- }
51
- /**
52
- * Default retry configuration.
53
- * - 1 attempt means no retries (default behavior)
54
- * - Use maxRetries: 3 for exponential backoff with 1s/2s delays
55
- */
56
- const DEFAULT_MAX_RETRIES = 1;
57
- const DEFAULT_RETRY_DELAY_MS = 1000;
58
- /**
59
- * Check if debug/verbose logging is enabled.
60
- * Set DEBUG=1 or DEBUG=true or VERBOSE=1 to enable.
61
- */
62
- const isDebugEnabled = () => {
63
- const debug = process.env.DEBUG;
64
- const verbose = process.env.VERBOSE;
65
- return debug === "1" || debug === "true" || verbose === "1" || verbose === "true";
66
- };
67
- /**
68
- * Log a debug message if debug mode is enabled.
69
- *
70
- * @param message - The message to log
71
- * @param data - Optional additional data to log
72
- */
73
- function debugLog(message, data) {
74
- if (!isDebugEnabled())
75
- return;
76
- const timestamp = new Date().toISOString();
77
- if (data !== undefined) {
78
- console.error(`[DEBUG ${timestamp}] ${message}`, data);
79
- }
80
- else {
81
- console.error(`[DEBUG ${timestamp}] ${message}`);
82
- }
83
- }
84
- /**
85
- * Escapes a string for safe inclusion in a shell command.
86
- *
87
- * When passing AppleScript to osascript via shell, we need to handle
88
- * the interaction between shell quoting and AppleScript string literals.
89
- * This function escapes single quotes since we wrap the script in single quotes.
90
- *
91
- * @param script - The raw AppleScript code
92
- * @returns Shell-safe version of the script
93
- *
94
- * @example
95
- * // Input: tell app "Notes" to get note "Rob's Note"
96
- * // Output: tell app "Notes" to get note "Rob'\''s Note"
97
- */
98
- function escapeForShell(script) {
99
- // Replace single quotes with: end quote, escaped quote, start quote
100
- // This is the standard shell escaping pattern for single-quoted strings
101
- return script.replace(/'/g, "'\\''");
102
- }
103
- /**
104
- * Checks if an error is a timeout error from execSync.
105
- *
106
- * Node.js throws errors with specific properties when a child process
107
- * is killed due to timeout.
108
- *
109
- * @param error - The caught error object
110
- * @returns True if this was a timeout error
111
- */
112
- function isTimeoutError(error) {
113
- if (error instanceof Error) {
114
- const execError = error;
115
- // execSync kills the process with SIGTERM on timeout
116
- return execError.killed === true || execError.signal === "SIGTERM";
117
- }
118
- return false;
119
- }
120
- /**
121
- * Error patterns that indicate transient failures worth retrying.
122
- * These typically occur when Notes.app is syncing or temporarily busy.
123
- */
124
- const RETRYABLE_ERROR_PATTERNS = [
125
- /timed? out/i,
126
- /not responding/i,
127
- /connection.*invalid/i,
128
- /lost connection/i,
129
- /busy/i,
130
- ];
131
- /**
132
- * Checks if an error message indicates a transient failure that should be retried.
133
- *
134
- * @param errorMessage - The error message to check
135
- * @returns True if this error is worth retrying
136
- */
137
- function isRetryableError(errorMessage) {
138
- return RETRYABLE_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage));
139
- }
140
- /**
141
- * Synchronous sleep using the system's sleep command.
142
- * Used between retry attempts for exponential backoff.
143
- *
144
- * This is more efficient than a busy-wait loop as it doesn't
145
- * consume CPU cycles during the delay.
146
- *
147
- * Uses spawnSync instead of execSync to avoid interference with
148
- * execSync mocks in tests.
149
- *
150
- * @param ms - Milliseconds to sleep
151
- */
152
- function sleep(ms) {
153
- // Use system sleep command with fractional seconds support
154
- // This avoids CPU-spinning busy wait while keeping the code synchronous
155
- const seconds = ms / 1000;
156
- const result = spawnSync("sleep", [seconds.toString()], { stdio: "ignore" });
157
- if (result.error) {
158
- // Fallback to busy-wait if sleep command fails (shouldn't happen on macOS)
159
- const end = Date.now() + ms;
160
- while (Date.now() < end) {
161
- // Busy wait fallback
162
- }
163
- }
164
- }
165
- /**
166
- * User-friendly error messages mapped from common AppleScript errors.
167
- * Each entry maps a pattern (regex or string) to a user-friendly message.
168
- */
169
- const ERROR_MAPPINGS = [
170
- // Permission errors
171
- {
172
- pattern: /not authorized|not permitted|access.*denied/i,
173
- message: "Permission denied. Grant automation access in System Preferences > Privacy & Security > Automation.",
174
- },
175
- // Application not running
176
- {
177
- pattern: /application isn't running|not running/i,
178
- message: "Notes.app is not responding. Try opening Notes.app manually.",
179
- },
180
- // Connection errors
181
- {
182
- pattern: /connection is invalid|lost connection/i,
183
- message: "Lost connection to Notes.app. The app may have crashed or been restarted.",
184
- },
185
- // Note not found (general)
186
- {
187
- pattern: /can't get note "([^"]+)"/i,
188
- message: 'Note "$1" not found. Verify the title is exact (case-sensitive).',
189
- },
190
- // Note not found by ID
191
- {
192
- pattern: /can't get note id/i,
193
- message: "Note not found. The note may have been deleted or the ID is invalid.",
194
- },
195
- // Folder not found
196
- {
197
- pattern: /can't get folder "([^"]+)"/i,
198
- message: 'Folder "$1" not found. Use list-folders to see available folders.',
199
- },
200
- // Account not found
201
- {
202
- pattern: /can't get account "([^"]+)"/i,
203
- message: 'Account "$1" not found. Use list-accounts to see available accounts.',
204
- },
205
- // Folder already exists
206
- {
207
- pattern: /folder.*already exists/i,
208
- message: "A folder with that name already exists.",
209
- },
210
- // Cannot delete (various reasons)
211
- {
212
- pattern: /can't delete|cannot delete/i,
213
- message: "Cannot delete. The item may be locked or in use.",
214
- },
215
- // Password protected notes
216
- {
217
- pattern: /password protected|locked note/i,
218
- message: "Note is password-protected. Unlock it in Notes.app first.",
219
- },
220
- // Syntax/script errors (usually programming bugs)
221
- {
222
- pattern: /syntax error|expected/i,
223
- message: "Internal error. Please report this issue.",
224
- },
225
- ];
226
- /**
227
- * Parses error output from osascript to extract meaningful error messages.
228
- *
229
- * osascript errors typically include execution error numbers and descriptions.
230
- * This function attempts to extract the human-readable portion and map it
231
- * to a user-friendly message with helpful suggestions.
232
- *
233
- * @param errorOutput - Raw error string from execSync
234
- * @returns User-friendly error message with suggested action
235
- */
236
- function parseErrorMessage(errorOutput) {
237
- // First, extract the core error message from AppleScript format
238
- let coreError = errorOutput;
239
- // Check for execution error format: "execution error: Message (-1234)"
240
- const executionError = errorOutput.match(/execution error: (.+?)(?:\s*\(-?\d+\))?$/m);
241
- if (executionError) {
242
- coreError = executionError[1].trim();
243
- }
244
- // Try to match against known error patterns for user-friendly messages
245
- for (const { pattern, message } of ERROR_MAPPINGS) {
246
- const match = coreError.match(pattern);
247
- if (match) {
248
- // Replace $1, $2, etc. with captured groups
249
- let result = message;
250
- for (let i = 1; i < match.length; i++) {
251
- result = result.replace(`$${i}`, match[i] || "");
252
- }
253
- return result;
254
- }
255
- }
256
- // Fall back to basic "Can't get X" parsing
257
- const notFoundError = coreError.match(/Can't get (.+?)\./);
258
- if (notFoundError) {
259
- return `Not found: ${notFoundError[1]}`;
260
- }
261
- // Return cleaned version of original error
262
- return coreError.trim() || "Unknown AppleScript error";
263
- }
264
- /**
265
- * Executes an AppleScript command and returns a structured result.
266
- *
267
- * This function serves as the bridge between TypeScript and macOS AppleScript.
268
- * It handles the complexity of shell escaping, execution, and error handling
269
- * so that calling code can work with clean TypeScript interfaces.
270
- *
271
- * The script is executed synchronously via the `osascript` command-line tool.
272
- * Multi-line scripts are supported and preserved (important for AppleScript
273
- * tell blocks and repeat loops).
274
- *
275
- * @param script - The AppleScript code to execute
276
- * @param options - Optional execution settings (timeout, etc.)
277
- * @returns A result object with success status and output or error message
278
- *
279
- * @example
280
- * ```typescript
281
- * // Basic usage with default timeout (30 seconds)
282
- * const result = executeAppleScript(`
283
- * tell application "Notes"
284
- * get name of every note
285
- * end tell
286
- * `);
287
- *
288
- * // With custom timeout for complex operations
289
- * const result = executeAppleScript(complexScript, { timeoutMs: 60000 });
290
- *
291
- * if (result.success) {
292
- * console.log("Notes:", result.output);
293
- * } else {
294
- * console.error("Failed:", result.error);
295
- * }
296
- * ```
297
- */
298
- export function executeAppleScript(script, options = {}) {
299
- const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
300
- const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
301
- const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
302
- // Validate input - empty scripts are likely programmer errors
303
- if (!script || !script.trim()) {
304
- return {
305
- success: false,
306
- output: "",
307
- error: "Cannot execute empty AppleScript",
308
- };
309
- }
310
- // Prepare the script:
311
- // 1. Trim leading/trailing whitespace (cosmetic)
312
- // 2. Wrap in `with timeout` so Notes.app aborts cleanly from inside its own
313
- // dispatch before the outer process SIGKILL (#17)
314
- // 3. Preserve internal newlines (required for AppleScript syntax)
315
- // 4. Escape for shell execution
316
- const preparedScript = escapeForShell(wrapWithTimeout(script.trim(), timeoutMs));
317
- // Build the osascript command
318
- // We use single quotes to wrap the script, which is why we escape
319
- // single quotes within the script itself
320
- const command = `osascript -e '${preparedScript}'`;
321
- // Debug: Log the script being executed
322
- debugLog("Executing AppleScript", {
323
- scriptPreview: script.trim().substring(0, 200) + (script.length > 200 ? "..." : ""),
324
- timeout: timeoutMs,
325
- maxRetries,
326
- });
327
- let lastError = null;
328
- const startTime = Date.now();
329
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
330
- const attemptStart = Date.now();
331
- try {
332
- // Execute synchronously - MCP tools are inherently synchronous
333
- // and Apple Notes operations are fast enough that async isn't needed
334
- const output = execSync(command, {
335
- encoding: "utf8",
336
- timeout: timeoutMs,
337
- // SIGKILL (not the default SIGTERM): a wedged osascript blocked on an
338
- // unresponsive Notes.app can ignore SIGTERM and leak, piling up and
339
- // worsening contention. SIGKILL guarantees reaping on timeout. (#17)
340
- killSignal: "SIGKILL",
341
- // Raise the output cap above Node's 1 MB default so large exports /
342
- // long notes aren't truncated into an ENOBUFS failure. (#16)
343
- maxBuffer: getMaxBuffer(),
344
- // Capture stderr separately to get error details
345
- stdio: ["pipe", "pipe", "pipe"],
346
- });
347
- const duration = Date.now() - attemptStart;
348
- debugLog("AppleScript succeeded", {
349
- attempt,
350
- duration: `${duration}ms`,
351
- outputLength: output.length,
352
- outputPreview: output.substring(0, 100) + (output.length > 100 ? "..." : ""),
353
- });
354
- return {
355
- success: true,
356
- output: output.trim(),
357
- };
358
- }
359
- catch (error) {
360
- // execSync throws on non-zero exit codes
361
- // The error object contains stderr output with AppleScript error details
362
- const attemptDuration = Date.now() - attemptStart;
363
- let errorMessage;
364
- let isTimeout = false;
365
- let rawError;
366
- // Check for timeout first - provide specific message
367
- if (isTimeoutError(error)) {
368
- isTimeout = true;
369
- const timeoutSecs = Math.round(timeoutMs / 1000);
370
- errorMessage = `Operation timed out after ${timeoutSecs} seconds. Notes.app may be unresponsive or the operation involves too many notes.`;
371
- }
372
- else if (error instanceof Error) {
373
- rawError = error.message;
374
- // Node's ExecException includes stderr in the message
375
- errorMessage = parseErrorMessage(error.message);
376
- }
377
- else if (typeof error === "string") {
378
- rawError = error;
379
- errorMessage = parseErrorMessage(error);
380
- }
381
- else {
382
- errorMessage = "AppleScript execution failed with unknown error";
383
- }
384
- // Debug: Log error details
385
- debugLog("AppleScript failed", {
386
- attempt,
387
- duration: `${attemptDuration}ms`,
388
- totalElapsed: `${Date.now() - startTime}ms`,
389
- isTimeout,
390
- errorMessage,
391
- rawError: rawError?.substring(0, 500),
392
- });
393
- lastError = {
394
- success: false,
395
- output: "",
396
- error: errorMessage,
397
- };
398
- // Check if we should retry
399
- const canRetry = isTimeout || isRetryableError(errorMessage);
400
- const hasAttemptsLeft = attempt < maxRetries;
401
- if (canRetry && hasAttemptsLeft) {
402
- const delayMs = retryDelayMs * Math.pow(2, attempt - 1);
403
- console.error(`AppleScript retry: Attempt ${attempt}/${maxRetries} failed with "${errorMessage}". Retrying in ${delayMs}ms...`);
404
- sleep(delayMs);
405
- // Continue to next attempt
406
- }
407
- else {
408
- // Log final error and return
409
- if (isTimeout) {
410
- console.error(`AppleScript timeout: ${errorMessage}`);
411
- }
412
- else {
413
- console.error(`AppleScript error: ${errorMessage}`);
414
- }
415
- return lastError;
416
- }
417
- }
418
- }
419
- // Return the last error (all retries exhausted - shouldn't reach here normally)
420
- return lastError;
421
- }