apple-notes-mcp 2.5.7 → 2.5.8

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 +8 -5
  2. package/build/index.js +42669 -1077
  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,2634 +0,0 @@
1
- /**
2
- * Apple Notes Manager
3
- *
4
- * A comprehensive service for managing Apple Notes through AppleScript.
5
- * This module provides a clean TypeScript interface over the Notes.app
6
- * AppleScript dictionary, handling all the complexity of script generation,
7
- * text escaping, and result parsing.
8
- *
9
- * Architecture:
10
- * - Text escaping is handled by dedicated helper functions
11
- * - AppleScript generation uses template builders for consistency
12
- * - All public methods return typed results (no raw strings)
13
- * - Error handling is consistent across all operations
14
- *
15
- * @module services/appleNotesManager
16
- */
17
- import { executeAppleScript } from "../utils/applescript.js";
18
- import { getChecklistItems } from "../utils/checklistParser.js";
19
- import { assertSafeSavePath, readFileBase64Capped, fileSize, makeTempDir, cleanupTempDir, } from "../utils/attachmentFs.js";
20
- import { existsSync } from "fs";
21
- import TurndownService from "turndown";
22
- // =============================================================================
23
- // Result delimiters (#18)
24
- //
25
- // AppleScript output is delimited with ASCII control characters that cannot
26
- // appear in user-entered note titles, folder names, or body text — unlike the
27
- // old printable "|||" / "," / "ITEM" tokens, which collide with ordinary
28
- // content (a note titled "Groceries, etc." used to split into phantom notes).
29
- // FIELD_SEP (US, \x1f) separates fields within a record
30
- // RECORD_SEP (RS, \x1e) separates records within a list
31
- // In AppleScript these are emitted via `ASCII character 31 / 30`.
32
- // =============================================================================
33
- const FIELD_SEP = "\x1f";
34
- const RECORD_SEP = "\x1e";
35
- const AS_FIELD_SEP = "(ASCII character 31)";
36
- const AS_RECORD_SEP = "(ASCII character 30)";
37
- // =============================================================================
38
- // Text Processing Utilities
39
- // =============================================================================
40
- /**
41
- * Escapes text for safe embedding in AppleScript string literals.
42
- *
43
- * AppleScript strings use double quotes, so we need to escape:
44
- * 1. Double quotes (") - escaped as \"
45
- * 2. Backslashes (\) - already handled by shell escaping
46
- *
47
- * Additionally, since our AppleScript is passed through the shell via
48
- * `osascript -e '...'`, we need to handle single quotes in the content.
49
- *
50
- * Finally, Apple Notes uses HTML internally, so we convert control
51
- * characters to their HTML equivalents.
52
- *
53
- * @param text - Raw text to escape
54
- * @returns Text safe for AppleScript string embedding
55
- *
56
- * @example
57
- * escapeForAppleScript("Hello \"World\"")
58
- * // Returns: Hello \"World\"
59
- *
60
- * escapeForAppleScript("Line 1\nLine 2")
61
- * // Returns: Line 1<br>Line 2
62
- */
63
- export function escapeForAppleScript(text) {
64
- // Guard against null/undefined - return empty string
65
- if (!text) {
66
- return "";
67
- }
68
- // Content goes inside AppleScript double-quoted strings: body:"content here"
69
- // Within double-quoted AppleScript strings, we need to escape:
70
- // 1. Backslashes (\ → \\) - AppleScript escape character
71
- // 2. Double quotes (" → \") - String delimiter
72
- // Single quotes do NOT need escaping in double-quoted AppleScript strings.
73
- // Step 1: Encode HTML ampersands FIRST (before adding any HTML entities)
74
- let escaped = text.replace(/&/g, "&amp;");
75
- // Step 2: Encode backslashes as HTML entities
76
- // This avoids AppleScript escaping issues since Notes stores HTML
77
- // Must happen AFTER ampersand encoding (so &#92; doesn't become &amp;#92;)
78
- // and BEFORE double-quote escaping (so \" doesn't become &#92;")
79
- escaped = escaped.replace(/\\/g, "&#92;");
80
- // Step 3: Escape double quotes for AppleScript strings
81
- // The backslash in \" is for AppleScript, not content, so it's added AFTER
82
- // backslash encoding to avoid being HTML-encoded
83
- escaped = escaped.replace(/"/g, '\\"');
84
- // Step 4: Convert control characters to HTML for Notes.app
85
- // - Newlines (\n) to <br> tags
86
- // - Tabs (\t) to <br> tags (better than &nbsp; for readability)
87
- escaped = escaped.replace(/\n/g, "<br>");
88
- escaped = escaped.replace(/\t/g, "<br>");
89
- return escaped;
90
- }
91
- /**
92
- * Escapes already-HTML content for embedding in AppleScript string literals.
93
- *
94
- * Unlike escapeForAppleScript(), this function is designed for content that
95
- * is already HTML (e.g., from getNoteContent()). It only escapes the
96
- * AppleScript string delimiter (double quotes) and handles backslashes,
97
- * without re-encoding HTML entities.
98
- *
99
- * @param htmlContent - HTML content from Notes.app
100
- * @returns Content safe for AppleScript string embedding
101
- *
102
- * @example
103
- * escapeHtmlForAppleScript('<div>Hello "World"</div>')
104
- * // Returns: <div>Hello \"World\"</div>
105
- */
106
- export function escapeHtmlForAppleScript(htmlContent) {
107
- if (!htmlContent) {
108
- return "";
109
- }
110
- // For already-HTML content, we only need to:
111
- // 1. Escape backslashes for AppleScript (\ → \\)
112
- // 2. Escape double quotes for AppleScript (" → \")
113
- //
114
- // We do NOT re-encode HTML entities since content is already HTML from Notes.app
115
- return htmlContent.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
116
- }
117
- /**
118
- * Escapes a plain (non-HTML) string for safe embedding in an AppleScript string literal.
119
- *
120
- * Use this for folder names, account names, and other metadata that Apple Notes
121
- * stores as plain text — NOT for note body content (use escapeForAppleScript instead).
122
- * HTML-encoding ampersands here would produce `folder "R&amp;D"`, which Apple Notes
123
- * would fail to match against the real folder named "R&D".
124
- *
125
- * @param text - Plain string (folder name, account name, etc.)
126
- * @returns String safe for AppleScript string embedding
127
- */
128
- export function escapePlainStringForAppleScript(text) {
129
- if (!text)
130
- return "";
131
- return text.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
132
- }
133
- // =============================================================================
134
- // Input Validation & Sanitization
135
- // =============================================================================
136
- /** Maximum allowed length for note titles */
137
- const MAX_TITLE_LENGTH = 2000;
138
- /** Maximum allowed length for note content (5 MB of text) */
139
- const MAX_CONTENT_LENGTH = 5 * 1024 * 1024;
140
- /** Maximum allowed length for folder names/paths */
141
- const MAX_FOLDER_PATH_LENGTH = 1000;
142
- /** Maximum allowed length for account names */
143
- const MAX_ACCOUNT_LENGTH = 200;
144
- /** Maximum nesting depth for folder paths */
145
- const MAX_FOLDER_DEPTH = 20;
146
- /**
147
- * Validates and constrains string input length.
148
- *
149
- * @param value - The input string
150
- * @param maxLength - Maximum allowed length
151
- * @param label - Human-readable label for error messages
152
- * @returns The validated string
153
- * @throws Error if input exceeds maximum length
154
- */
155
- function validateLength(value, maxLength, label) {
156
- if (value.length > maxLength) {
157
- throw new Error(`${label} exceeds maximum length of ${maxLength} characters (got ${value.length})`);
158
- }
159
- return value;
160
- }
161
- /**
162
- * Sanitizes a CoreData ID for safe embedding in AppleScript.
163
- *
164
- * CoreData IDs follow the pattern: x-coredata://UUID/ICNote/pNNN
165
- * This function validates the format and escapes the value for AppleScript.
166
- *
167
- * @param id - CoreData URL identifier
168
- * @returns Escaped ID safe for AppleScript string embedding
169
- * @throws Error if ID format is invalid
170
- */
171
- export function sanitizeId(id) {
172
- // CoreData IDs should match: x-coredata://hex-hex-hex-hex-hex/ICEntity/pDigits
173
- // or temp-timestamp-counter format from generateFallbackId()
174
- const coreDataPattern = /^x-coredata:\/\/[0-9A-Fa-f-]+\/IC[A-Za-z]+\/p\d+$/;
175
- const tempIdPattern = /^temp-\d+-\d+$/;
176
- if (!coreDataPattern.test(id) && !tempIdPattern.test(id)) {
177
- throw new Error(`Invalid note ID format: "${id.substring(0, 80)}". Expected CoreData URL (x-coredata://...) or temp ID.`);
178
- }
179
- // Even with validation, escape for defense-in-depth
180
- return escapeForAppleScript(id);
181
- }
182
- /**
183
- * Sanitizes an account name for safe embedding in AppleScript.
184
- *
185
- * @param account - Account name string
186
- * @returns Escaped account name safe for AppleScript string embedding
187
- */
188
- function sanitizeAccountName(account) {
189
- validateLength(account, MAX_ACCOUNT_LENGTH, "Account name");
190
- return escapePlainStringForAppleScript(account);
191
- }
192
- /**
193
- * Counter for generating unique fallback IDs within the same millisecond.
194
- */
195
- let fallbackIdCounter = 0;
196
- /**
197
- * Generates a unique fallback ID when AppleScript doesn't return a valid ID.
198
- *
199
- * This creates a temporary ID that's unique within this session. Format:
200
- * "temp-{timestamp}-{counter}"
201
- *
202
- * @returns A unique temporary ID string
203
- *
204
- * @example
205
- * generateFallbackId() // Returns: "temp-1704067200000-0"
206
- * generateFallbackId() // Returns: "temp-1704067200000-1"
207
- */
208
- export function generateFallbackId() {
209
- return `temp-${Date.now()}-${fallbackIdCounter++}`;
210
- }
211
- /**
212
- * Converts AppleScript date representation to JavaScript Date.
213
- *
214
- * AppleScript returns dates in a verbose format like:
215
- * "date Saturday, December 27, 2025 at 3:44:02 PM"
216
- *
217
- * This function extracts the parseable portion and converts it
218
- * to a JavaScript Date object.
219
- *
220
- * @param appleScriptDate - Date string from AppleScript
221
- * @returns Parsed Date, or current date if parsing fails
222
- *
223
- * @example
224
- * parseAppleScriptDate("date Saturday, December 27, 2025 at 3:44:02 PM")
225
- * // Returns: Date object for Dec 27, 2025 3:44:02 PM
226
- */
227
- export function parseAppleScriptDate(appleScriptDate) {
228
- const s = appleScriptDate.trim();
229
- // Locale-independent numeric form emitted by our producers (#25): "Y-M-D-H-m-s"
230
- // built from AppleScript date components, so it never depends on the system's
231
- // date-format locale (the old `date as text` form did, silently falling back
232
- // to "now" on non-US Macs).
233
- const numeric = s.match(/^(\d{1,5})-(\d{1,2})-(\d{1,2})-(\d{1,2})-(\d{1,2})-(\d{1,2})$/);
234
- if (numeric) {
235
- const [, y, mo, d, h, mi, se] = numeric;
236
- const dt = new Date(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(se));
237
- return isNaN(dt.getTime()) ? new Date() : dt;
238
- }
239
- // Legacy en-US verbose form: "date Saturday, December 27, 2025 at 3:44:02 PM".
240
- // Remove the "date " prefix if present
241
- const withoutPrefix = s.replace(/^date\s+/, "");
242
- // Replace " at " with a space for standard date parsing
243
- // "Saturday, December 27, 2025 at 3:44:02 PM" ->
244
- // "Saturday, December 27, 2025 3:44:02 PM"
245
- const normalized = withoutPrefix.replace(" at ", " ");
246
- // Attempt to parse - JavaScript's Date constructor handles this format
247
- const parsed = new Date(normalized);
248
- // Return parsed date if valid, otherwise current date as fallback
249
- return isNaN(parsed.getTime()) ? new Date() : parsed;
250
- }
251
- /**
252
- * Generates AppleScript code that creates a date variable with the given values.
253
- *
254
- * This approach is locale-independent, unlike `date "M/D/YYYY"` coercion which
255
- * depends on the system's date format settings and would fail on non-US locales.
256
- *
257
- * @param date - JavaScript Date object
258
- * @param varName - AppleScript variable name to assign (default: "thresholdDate")
259
- * @returns AppleScript code that sets up the date variable
260
- *
261
- * @example
262
- * buildAppleScriptDateVar(new Date("2025-06-15T00:00:00"))
263
- * // Returns multi-line AppleScript that sets thresholdDate to June 15, 2025 midnight
264
- */
265
- export function buildAppleScriptDateVar(date, varName = "thresholdDate") {
266
- const year = date.getFullYear();
267
- const month = date.getMonth() + 1;
268
- const day = date.getDate();
269
- const timeInSeconds = date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds();
270
- return [
271
- `set ${varName} to current date`,
272
- `set year of ${varName} to ${year}`,
273
- `set month of ${varName} to ${month}`,
274
- `set day of ${varName} to ${day}`,
275
- `set time of ${varName} to ${timeInSeconds}`,
276
- ].join("\n");
277
- }
278
- /**
279
- * Builds a locale-independent AppleScript expression that renders a date variable
280
- * as "Y-M-D-H-m-s" from its numeric components (#25), parsed by
281
- * {@link parseAppleScriptDate}. Avoids `(someDate as text)`, whose format depends
282
- * on the system locale.
283
- *
284
- * @param v - name of an AppleScript variable already holding a date
285
- */
286
- export function asDatePartsExpr(v) {
287
- return (`((year of ${v}) as text) & "-" & ((month of ${v}) as integer as text) & "-" & ` +
288
- `((day of ${v}) as text) & "-" & ((hours of ${v}) as text) & "-" & ` +
289
- `((minutes of ${v}) as text) & "-" & ((seconds of ${v}) as text)`);
290
- }
291
- /**
292
- * Parses AppleScript note properties output into structured data.
293
- *
294
- * AppleScript returns note properties in a format like:
295
- * "title, id, date DayName, Month Day, Year at Time, date..., bool, bool"
296
- *
297
- * Dates contain commas, so we use regex to extract them safely.
298
- *
299
- * @param output - Raw AppleScript output string
300
- * @returns Parsed properties, or null if format is invalid
301
- */
302
- export function parseNotePropertiesOutput(output) {
303
- // Fields are control-char delimited (#18): title, id, created, modified,
304
- // shared, passwordProtected — robust against commas in titles, unlike the
305
- // old comma/regex parsing.
306
- const parts = output.split(FIELD_SEP);
307
- if (parts.length < 6) {
308
- console.error("Unexpected response format: expected 6 delimited note properties");
309
- return null;
310
- }
311
- const [title, id, createdStr, modifiedStr, sharedStr, ppStr] = parts;
312
- return {
313
- title: title.trim(),
314
- id: id.trim(),
315
- created: createdStr?.trim() ? parseAppleScriptDate(createdStr.trim()) : new Date(),
316
- modified: modifiedStr?.trim() ? parseAppleScriptDate(modifiedStr.trim()) : new Date(),
317
- shared: sharedStr?.trim() === "true",
318
- passwordProtected: ppStr?.trim() === "true",
319
- };
320
- }
321
- /**
322
- * Splits a folder path on unescaped `/` separators.
323
- *
324
- * Folder names may contain literal slashes (e.g., "Spain/Portugal 2023").
325
- * In path strings these are escaped as `\/`. This function splits only on
326
- * unescaped `/` and restores the literal slashes in each segment.
327
- *
328
- * @param folderPath - Folder path with `/` as hierarchy separator and `\/` for literal slashes
329
- * @returns Array of folder name segments
330
- */
331
- export function splitFolderPath(folderPath) {
332
- // Split on `/` that is NOT preceded by `\`
333
- // We use a negative lookbehind to avoid splitting on escaped slashes
334
- const parts = folderPath.split(/(?<!\\)\//);
335
- // Unescape `\/` → `/` in each segment
336
- return parts.map((p) => p.replace(/\\\//g, "/")).filter((p) => p.length > 0);
337
- }
338
- /**
339
- * Escapes literal slashes in a folder name for use in path strings.
340
- *
341
- * @param name - Raw folder name (may contain `/`)
342
- * @returns Folder name with `/` escaped as `\/`
343
- */
344
- function escapeFolderName(name) {
345
- return name.replace(/\//g, "\\/");
346
- }
347
- /**
348
- * Builds an AppleScript folder reference from a path string.
349
- *
350
- * Converts a folder path like "Work/Clients/Omnia" into the nested
351
- * AppleScript syntax: `folder "Omnia" of folder "Clients" of folder "Work"`.
352
- *
353
- * A simple folder name like "Work" returns `folder "Work"`.
354
- * Literal slashes in folder names must be escaped as `\/` (e.g., "Travel/Spain\/Portugal").
355
- *
356
- * @param folderPath - Folder name or slash-separated path (e.g., "Work/Clients")
357
- * @returns AppleScript folder reference string
358
- */
359
- export function buildFolderReference(folderPath) {
360
- validateLength(folderPath, MAX_FOLDER_PATH_LENGTH, "Folder path");
361
- const parts = splitFolderPath(folderPath);
362
- if (parts.length > MAX_FOLDER_DEPTH) {
363
- throw new Error(`Folder path exceeds maximum nesting depth of ${MAX_FOLDER_DEPTH} (got ${parts.length})`);
364
- }
365
- if (parts.length === 0) {
366
- throw new Error("Folder path is empty");
367
- }
368
- // Build inside-out: last part is innermost, first part is outermost
369
- return parts
370
- .reverse()
371
- .map((part) => `folder "${escapePlainStringForAppleScript(part)}"`)
372
- .join(" of ");
373
- }
374
- /**
375
- * Builds an AppleScript command wrapped in account context.
376
- *
377
- * Most Notes.app operations need to be scoped to an account:
378
- * ```applescript
379
- * tell application "Notes"
380
- * tell account "iCloud"
381
- * -- command here
382
- * end tell
383
- * end tell
384
- * ```
385
- *
386
- * This builder generates that wrapper structure.
387
- *
388
- * @param scope - Account to target
389
- * @param command - The AppleScript command to execute
390
- * @returns Complete AppleScript ready for execution
391
- */
392
- function buildAccountScopedScript(scope, command) {
393
- const safeAccount = sanitizeAccountName(scope.account);
394
- return `
395
- tell application "Notes"
396
- tell account "${safeAccount}"
397
- ${command}
398
- end tell
399
- end tell
400
- `;
401
- }
402
- /**
403
- * Builds an AppleScript command at the application level.
404
- *
405
- * Some operations (like listing accounts) don't need account scoping:
406
- * ```applescript
407
- * tell application "Notes"
408
- * -- command here
409
- * end tell
410
- * ```
411
- *
412
- * @param command - The AppleScript command to execute
413
- * @returns Complete AppleScript ready for execution
414
- */
415
- function buildAppLevelScript(command) {
416
- return `
417
- tell application "Notes"
418
- ${command}
419
- end tell
420
- `;
421
- }
422
- // =============================================================================
423
- // Result Parsing Utilities
424
- // =============================================================================
425
- /**
426
- * Extracts a CoreData ID from AppleScript output.
427
- *
428
- * Notes.app uses CoreData URLs as unique identifiers:
429
- * "note id x-coredata://ABC123-DEF456/ICNote/p789"
430
- *
431
- * This function extracts the ID portion.
432
- *
433
- * @param output - AppleScript output containing an ID reference
434
- * @param prefix - The object type prefix (e.g., "note", "folder")
435
- * @returns Extracted ID or empty string
436
- */
437
- function extractCoreDataId(output, prefix) {
438
- const pattern = new RegExp(`${prefix} id ([^\\s]+)`);
439
- const match = output.match(pattern);
440
- return match ? match[1] : "";
441
- }
442
- // =============================================================================
443
- // Apple Notes Manager Class
444
- // =============================================================================
445
- /**
446
- * Manages interactions with Apple Notes via AppleScript.
447
- *
448
- * This class provides a high-level TypeScript interface for all
449
- * Notes.app operations. It handles:
450
- *
451
- * - Note CRUD operations (create, read, update, delete)
452
- * - Note organization (folders, moving between folders)
453
- * - Multi-account support (iCloud, Gmail, Exchange, etc.)
454
- * - Search functionality (by title or content)
455
- *
456
- * All operations are synchronous since they rely on AppleScript
457
- * execution via osascript. Error handling is consistent: methods
458
- * return null/false/empty-array on failure rather than throwing.
459
- *
460
- * @example
461
- * ```typescript
462
- * const notes = new AppleNotesManager();
463
- *
464
- * // Create a note in the default (iCloud) account
465
- * const note = notes.createNote("Shopping List", "Eggs, Milk, Bread");
466
- *
467
- * // Search across all notes
468
- * const results = notes.searchNotes("shopping", true); // searches content
469
- *
470
- * // Work with a different account
471
- * const gmailNotes = notes.listNotes("Gmail");
472
- * ```
473
- */
474
- export class AppleNotesManager {
475
- /**
476
- * Default account used when no account is specified.
477
- * iCloud is the primary account for most Apple Notes users.
478
- */
479
- defaultAccount = "iCloud";
480
- /**
481
- * Resolves the account to use for an operation.
482
- * Falls back to default if not specified.
483
- */
484
- resolveAccount(account) {
485
- return account || this.defaultAccount;
486
- }
487
- /**
488
- * Checks if a note is password-protected by its ID.
489
- *
490
- * Password-protected notes cannot have their content read or modified
491
- * via AppleScript when locked. This method allows checking before
492
- * attempting operations that would fail.
493
- *
494
- * @param id - CoreData URL identifier for the note
495
- * @returns true if the note is password-protected, false otherwise
496
- */
497
- isNotePasswordProtectedById(id) {
498
- const note = this.getNoteById(id);
499
- return note?.passwordProtected === true;
500
- }
501
- /**
502
- * Checks if a note is password-protected by its title.
503
- *
504
- * @param title - Exact title of the note
505
- * @param account - Account to search in (defaults to iCloud)
506
- * @returns true if the note is password-protected, false otherwise
507
- */
508
- isNotePasswordProtected(title, account) {
509
- const note = this.getNoteDetails(title, account);
510
- return note?.passwordProtected === true;
511
- }
512
- // ===========================================================================
513
- // Note Operations
514
- // ===========================================================================
515
- /**
516
- * Creates a new note in Apple Notes.
517
- *
518
- * The note is created with the specified title and content. If a folder
519
- * is specified, the note is created in that folder; otherwise it goes
520
- * to the account's default location.
521
- *
522
- * @param title - Display title for the note
523
- * @param content - Body content (plain text that will be HTML-escaped, or raw HTML when format is "html")
524
- * @param tags - Optional tags (stored in returned object, not used by Notes.app)
525
- * @param folder - Optional folder name to create the note in
526
- * @param account - Account to use (defaults to iCloud)
527
- * @param format - Content format: "plaintext" escapes and wraps in div tags (default), "html" uses content as-is
528
- * @returns Created Note object with metadata, or null on failure
529
- *
530
- * @example
531
- * ```typescript
532
- * // Simple note creation
533
- * const note = manager.createNote("Meeting Notes", "Discussed Q4 plans");
534
- *
535
- * // Create in a specific folder
536
- * const work = manager.createNote("Task List", "1. Review PR", [], "Work");
537
- *
538
- * // Create in a different account
539
- * const gmail = manager.createNote("Draft", "...", [], undefined, "Gmail");
540
- *
541
- * // Create with HTML formatting (no need for <h1> — title is auto-prepended)
542
- * const html = manager.createNote("Report", "<p>Details here</p>",
543
- * [], undefined, undefined, "html");
544
- * ```
545
- */
546
- createNote(title, content, tags = [], folder, account, format = "plaintext") {
547
- validateLength(title, MAX_TITLE_LENGTH, "Note title");
548
- validateLength(content, MAX_CONTENT_LENGTH, "Note content");
549
- const targetAccount = this.resolveAccount(account);
550
- // Build body HTML: title as <h1>, content follows.
551
- // We set only 'body' (not 'name') to avoid title duplication —
552
- // Notes.app auto-uses the first line of body as the note's display title.
553
- const htmlTitle = title.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
554
- const bodyContent = format === "html"
555
- ? content
556
- : content
557
- .replace(/&/g, "&amp;")
558
- .replace(/\\/g, "&#92;")
559
- .replace(/</g, "&lt;")
560
- .replace(/>/g, "&gt;")
561
- .replace(/\n/g, "<br>")
562
- .replace(/\t/g, "<br>");
563
- const safeBody = escapeHtmlForAppleScript(`<h1>${htmlTitle}</h1>${bodyContent}`);
564
- // Build the AppleScript command
565
- let createCommand;
566
- if (folder) {
567
- // Create note in specific folder (supports nested paths like "Work/Clients")
568
- // Note: We avoid `set newNote` + `return id of newNote` because AppleScript
569
- // fails to resolve the note reference in deeply nested folder contexts (-1728).
570
- // The implicit return from `make new note` includes the ID which we parse.
571
- const folderRef = buildFolderReference(folder);
572
- createCommand = `make new note at ${folderRef} with properties {body:"${safeBody}"}`;
573
- }
574
- else {
575
- // Create note in default location
576
- createCommand = `
577
- set newNote to make new note with properties {body:"${safeBody}"}
578
- return id of newNote
579
- `;
580
- }
581
- // Execute the script
582
- const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
583
- const result = executeAppleScript(script);
584
- if (!result.success) {
585
- console.error(`Failed to create note "${title}":`, result.error);
586
- return null;
587
- }
588
- // Extract the CoreData ID from the response.
589
- // AppleScript's `id of newNote` yields an object specifier of the form
590
- // "note id x-coredata://<uuid>/ICNote/pN" — with a literal "note id " prefix.
591
- // Strip that prefix so we return the bare x-coredata:// URL that the id
592
- // validator and downstream tools (get-note-content, update-note) accept.
593
- const rawOutput = result.output.trim();
594
- const noteId = extractCoreDataId(rawOutput, "note") || rawOutput;
595
- // Return a Note object representing the created note with real ID
596
- const now = new Date();
597
- return {
598
- id: noteId || generateFallbackId(), // Use real ID, fallback to unique temp ID
599
- title,
600
- content,
601
- tags,
602
- created: now,
603
- modified: now,
604
- folder,
605
- account: targetAccount,
606
- };
607
- }
608
- /**
609
- * Searches for notes matching a query.
610
- *
611
- * By default, searches note titles. Set searchContent=true to search
612
- * the body text instead. Optionally filter to a specific folder.
613
- *
614
- * @param query - Text to search for
615
- * @param searchContent - If true, search note bodies; if false, search titles
616
- * @param account - Account to search in (defaults to iCloud)
617
- * @param folder - Optional folder to limit search to
618
- * @param modifiedSince - Optional ISO 8601 date string to filter notes modified on or after this date
619
- * @param limit - Optional maximum number of results to return (default: no limit)
620
- * @returns Array of matching notes (with minimal metadata)
621
- *
622
- * @example
623
- * ```typescript
624
- * // Search by title
625
- * const meetingNotes = manager.searchNotes("meeting");
626
- *
627
- * // Search in note content
628
- * const projectRefs = manager.searchNotes("Project Alpha", true);
629
- *
630
- * // Search within a specific folder
631
- * const workNotes = manager.searchNotes("deadline", false, "iCloud", "Work");
632
- *
633
- * // Search only recently modified notes
634
- * const recentNotes = manager.searchNotes("todo", true, undefined, undefined, "2025-01-01");
635
- *
636
- * // Search with a result limit
637
- * const topResults = manager.searchNotes("project", false, undefined, undefined, undefined, 10);
638
- * ```
639
- */
640
- searchNotes(query, searchContent = false, account, folder, modifiedSince, limit) {
641
- const targetAccount = this.resolveAccount(account);
642
- const safeQuery = escapePlainStringForAppleScript(query);
643
- const safeLimit = limit !== undefined && limit > 0 ? Math.floor(limit) : undefined;
644
- // Build the where clause based on search type
645
- // AppleScript uses 'name' for title and 'body' for content
646
- const whereParts = [];
647
- if (searchContent) {
648
- whereParts.push(`body contains "${safeQuery}"`);
649
- }
650
- else {
651
- whereParts.push(`name contains "${safeQuery}"`);
652
- }
653
- // Add date filter if specified (uses locale-safe date variable)
654
- let dateSetup = "";
655
- if (modifiedSince) {
656
- const date = new Date(modifiedSince);
657
- if (!isNaN(date.getTime())) {
658
- dateSetup = buildAppleScriptDateVar(date) + "\n";
659
- whereParts.push(`modification date >= thresholdDate`);
660
- }
661
- }
662
- const whereClause = whereParts.join(" and ");
663
- // Build the notes source - either all notes or notes in a specific folder
664
- const notesSource = folder ? `notes of ${buildFolderReference(folder)}` : "notes";
665
- // Build the limit logic for the repeat loop
666
- // Note: The limit only reduces iteration over already-matched results from the whose clause,
667
- // not the query itself. It controls output size, not AppleScript query performance.
668
- const limitCheck = safeLimit !== undefined
669
- ? `
670
- if (count of resultList) >= ${safeLimit} then exit repeat`
671
- : "";
672
- // Get names, IDs, and folder for each matching note.
673
- // Notes.app can return the same CoreData note more than once when asking
674
- // an account for all notes, so dedupe on note ID before adding results.
675
- const searchCommand = `
676
- ${dateSetup}set matchingNotes to ${notesSource} where ${whereClause}
677
- set resultList to {}
678
- set seenIds to {}
679
- repeat with n in matchingNotes
680
- try
681
- set noteName to name of n
682
- set noteId to id of n
683
- if seenIds does not contain noteId then
684
- set end of seenIds to noteId
685
- try
686
- set noteFolder to name of container of n
687
- on error
688
- set noteFolder to "Notes"
689
- end try
690
- set end of resultList to noteName & ${AS_FIELD_SEP} & noteId & ${AS_FIELD_SEP} & noteFolder${limitCheck}
691
- end if
692
- end try
693
- end repeat
694
- set AppleScript's text item delimiters to ${AS_RECORD_SEP}
695
- return resultList as text
696
- `;
697
- const script = buildAccountScopedScript({ account: targetAccount }, searchCommand);
698
- const result = executeAppleScript(script);
699
- if (!result.success) {
700
- // Surface the failure (#19) — an empty array would look like "no matches".
701
- throw new Error(`Failed to search notes for "${query}": ${result.error ?? "unknown error"}`);
702
- }
703
- // Handle empty results
704
- if (!result.output.trim()) {
705
- return [];
706
- }
707
- // Parse the control-char-delimited output (#18): fields by FIELD_SEP, records by RECORD_SEP.
708
- const items = result.output.split(RECORD_SEP);
709
- const notes = [];
710
- const seenIds = new Set();
711
- for (const item of items) {
712
- const [title, id, folder] = item.split(FIELD_SEP);
713
- if (!title?.trim())
714
- continue;
715
- const noteId = id?.trim() || generateFallbackId();
716
- if (seenIds.has(noteId))
717
- continue;
718
- seenIds.add(noteId);
719
- notes.push({
720
- id: noteId,
721
- title: title.trim(),
722
- content: "", // Not fetched in search
723
- tags: [],
724
- created: new Date(),
725
- modified: new Date(),
726
- folder: folder?.trim(),
727
- account: targetAccount,
728
- });
729
- }
730
- return notes;
731
- }
732
- /**
733
- * Retrieves the HTML content of a note by its title.
734
- *
735
- * Note: Password-protected notes will fail with an AppleScript error.
736
- * Callers should check for password protection beforehand using
737
- * getNoteDetails() or isNotePasswordProtected().
738
- *
739
- * @param title - Exact title of the note
740
- * @param account - Account to search in (defaults to iCloud)
741
- * @returns HTML content of the note, or empty string if not found
742
- *
743
- * @example
744
- * ```typescript
745
- * const content = manager.getNoteContent("Shopping List");
746
- * if (content) {
747
- * console.log("Note found:", content);
748
- * }
749
- * ```
750
- */
751
- getNoteContent(title, account) {
752
- const targetAccount = this.resolveAccount(account);
753
- const safeTitle = escapePlainStringForAppleScript(title);
754
- // Retrieve the body property of the note
755
- const getCommand = `get body of note "${safeTitle}"`;
756
- const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
757
- const result = executeAppleScript(script);
758
- if (!result.success) {
759
- console.error(`Failed to get content of note "${title}":`, result.error);
760
- return "";
761
- }
762
- return result.output;
763
- }
764
- /**
765
- * Retrieves the HTML content of a note by its CoreData ID.
766
- *
767
- * This is more reliable than getNoteContent() because IDs are unique
768
- * across all accounts, while titles can be duplicated.
769
- *
770
- * Note: Password-protected notes will fail with an AppleScript error.
771
- * Callers should check for password protection beforehand using
772
- * getNoteById() or isNotePasswordProtectedById().
773
- *
774
- * @param id - CoreData URL identifier for the note
775
- * @returns HTML content of the note, or empty string if not found
776
- */
777
- getNoteContentById(id) {
778
- const safeId = sanitizeId(id);
779
- // Note IDs work at the application level, not scoped to account
780
- const getCommand = `get body of note id "${safeId}"`;
781
- const script = buildAppLevelScript(getCommand);
782
- const result = executeAppleScript(script);
783
- if (!result.success) {
784
- console.error(`Failed to get content of note with ID "${id}":`, result.error);
785
- return "";
786
- }
787
- return result.output;
788
- }
789
- /**
790
- * Retrieves the plain-text content of a note by its exact title.
791
- *
792
- * Reads the note's `plaintext` property, which Notes derives from the body
793
- * with all HTML markup removed. This is the text Notes itself exposes, so it
794
- * is more faithful than converting the HTML body and skips the markup
795
- * round-trip entirely.
796
- *
797
- * @param title - Exact title of the note
798
- * @param account - Account to search in (defaults to iCloud)
799
- * @returns Plain-text content of the note, or empty string if not found
800
- */
801
- getNotePlaintext(title, account) {
802
- const targetAccount = this.resolveAccount(account);
803
- const safeTitle = escapePlainStringForAppleScript(title);
804
- const getCommand = `get plaintext of note "${safeTitle}"`;
805
- const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
806
- const result = executeAppleScript(script);
807
- if (!result.success) {
808
- console.error(`Failed to get plaintext of note "${title}":`, result.error);
809
- return "";
810
- }
811
- return result.output;
812
- }
813
- /**
814
- * Retrieves the plain-text content of a note by its CoreData ID.
815
- *
816
- * Reads the read-only `plaintext` property (the body with HTML removed). More
817
- * reliable than getNotePlaintext() because IDs are unique across accounts.
818
- *
819
- * Note: Password-protected notes will fail with an AppleScript error. Callers
820
- * should check for password protection beforehand using getNoteById().
821
- *
822
- * @param id - CoreData URL identifier for the note
823
- * @returns Plain-text content of the note, or empty string if not found
824
- */
825
- getNotePlaintextById(id) {
826
- const safeId = sanitizeId(id);
827
- const getCommand = `get plaintext of note id "${safeId}"`;
828
- const script = buildAppLevelScript(getCommand);
829
- const result = executeAppleScript(script);
830
- if (!result.success) {
831
- console.error(`Failed to get plaintext of note with ID "${id}":`, result.error);
832
- return "";
833
- }
834
- return result.output;
835
- }
836
- /**
837
- * Retrieves a note by its unique CoreData ID.
838
- *
839
- * Each note has a unique ID in the format:
840
- * "x-coredata://DEVICE-UUID/ICNote/pXXXX"
841
- *
842
- * This method fetches the note and its metadata using this ID.
843
- *
844
- * @param id - CoreData URL identifier for the note
845
- * @returns Note object with metadata, or null if not found
846
- */
847
- getNoteById(id) {
848
- const safeId = sanitizeId(id);
849
- // Note IDs work at the application level, not scoped to account
850
- const getCommand = `
851
- set n to note id "${safeId}"
852
- set cd to creation date of n
853
- set md to modification date of n
854
- set noteProps to {name of n, id of n, ${asDatePartsExpr("cd")}, ${asDatePartsExpr("md")}, (shared of n as text), (password protected of n as text)}
855
- set AppleScript's text item delimiters to ${AS_FIELD_SEP}
856
- return noteProps as text
857
- `;
858
- const script = buildAppLevelScript(getCommand);
859
- const result = executeAppleScript(script);
860
- if (!result.success) {
861
- console.error(`Failed to get note with ID "${id}":`, result.error);
862
- return null;
863
- }
864
- // Parse the AppleScript output using the shared helper
865
- const parsed = parseNotePropertiesOutput(result.output);
866
- if (!parsed) {
867
- return null;
868
- }
869
- return {
870
- id: parsed.id,
871
- title: parsed.title,
872
- content: "", // Not fetched to keep response small
873
- tags: [],
874
- created: parsed.created,
875
- modified: parsed.modified,
876
- shared: parsed.shared,
877
- passwordProtected: parsed.passwordProtected,
878
- };
879
- }
880
- /**
881
- * Retrieves detailed metadata for a note by title.
882
- *
883
- * Similar to getNoteContent but returns structured metadata
884
- * including creation date, modification date, and sharing status.
885
- *
886
- * @param title - Exact title of the note
887
- * @param account - Account to search in (defaults to iCloud)
888
- * @returns Note object with full metadata, or null if not found
889
- */
890
- getNoteDetails(title, account) {
891
- const targetAccount = this.resolveAccount(account);
892
- const safeTitle = escapePlainStringForAppleScript(title);
893
- // Fetch multiple properties at once
894
- const getCommand = `
895
- set n to note "${safeTitle}"
896
- set cd to creation date of n
897
- set md to modification date of n
898
- set noteProps to {name of n, id of n, ${asDatePartsExpr("cd")}, ${asDatePartsExpr("md")}, (shared of n as text), (password protected of n as text)}
899
- set AppleScript's text item delimiters to ${AS_FIELD_SEP}
900
- return noteProps as text
901
- `;
902
- const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
903
- const result = executeAppleScript(script);
904
- if (!result.success) {
905
- console.error(`Failed to get details for note "${title}":`, result.error);
906
- return null;
907
- }
908
- // Parse the AppleScript output using the shared helper
909
- const parsed = parseNotePropertiesOutput(result.output);
910
- if (!parsed) {
911
- return null;
912
- }
913
- return {
914
- id: parsed.id,
915
- title: parsed.title,
916
- content: "", // Not fetched
917
- tags: [],
918
- created: parsed.created,
919
- modified: parsed.modified,
920
- shared: parsed.shared,
921
- passwordProtected: parsed.passwordProtected,
922
- account: targetAccount,
923
- };
924
- }
925
- /**
926
- * Deletes a note by its title.
927
- *
928
- * Note: This permanently deletes the note. It may be recoverable
929
- * from the "Recently Deleted" folder in Notes.app.
930
- *
931
- * @param title - Exact title of the note to delete
932
- * @param account - Account containing the note (defaults to iCloud)
933
- * @returns true if deletion succeeded, false otherwise
934
- */
935
- deleteNote(title, account) {
936
- const targetAccount = this.resolveAccount(account);
937
- const safeTitle = escapePlainStringForAppleScript(title);
938
- const deleteCommand = `delete note "${safeTitle}"`;
939
- const script = buildAccountScopedScript({ account: targetAccount }, deleteCommand);
940
- const result = executeAppleScript(script);
941
- if (!result.success) {
942
- console.error(`Failed to delete note "${title}":`, result.error);
943
- return false;
944
- }
945
- return true;
946
- }
947
- /**
948
- * Deletes a note by its CoreData ID.
949
- *
950
- * This is more reliable than deleteNote() because IDs are unique
951
- * across all accounts, while titles can be duplicated.
952
- *
953
- * @param id - CoreData URL identifier for the note
954
- * @returns true if deletion succeeded, false otherwise
955
- */
956
- deleteNoteById(id) {
957
- const safeId = sanitizeId(id);
958
- const deleteCommand = `delete note id "${safeId}"`;
959
- const script = buildAppLevelScript(deleteCommand);
960
- const result = executeAppleScript(script);
961
- if (!result.success) {
962
- console.error(`Failed to delete note with ID "${id}":`, result.error);
963
- return false;
964
- }
965
- return true;
966
- }
967
- /**
968
- * Updates an existing note's content and optionally its title.
969
- *
970
- * Apple Notes derives the title from the first line of the body,
971
- * so updating content also allows title changes. If newTitle is
972
- * not provided, the original title is preserved.
973
- *
974
- * When format is 'html', newTitle is ignored — the caller must include
975
- * the title in the HTML content.
976
- *
977
- * Note: Password-protected notes will fail with an AppleScript error.
978
- * Callers should check for password protection beforehand using
979
- * getNoteDetails() or isNotePasswordProtected().
980
- *
981
- * @param title - Current title of the note to update
982
- * @param newTitle - New title (optional, keeps existing if not provided; ignored in html format)
983
- * @param newContent - New content for the note body
984
- * @param account - Account containing the note (defaults to iCloud)
985
- * @param format - Content format: "plaintext" wraps in div tags (default), "html" uses content as-is
986
- * @returns true if update succeeded, false otherwise
987
- */
988
- updateNote(title, newTitle, newContent, account, format = "plaintext") {
989
- if (newTitle)
990
- validateLength(newTitle, MAX_TITLE_LENGTH, "Note title");
991
- validateLength(newContent, MAX_CONTENT_LENGTH, "Note content");
992
- const targetAccount = this.resolveAccount(account);
993
- const safeCurrentTitle = escapePlainStringForAppleScript(title);
994
- let fullBody;
995
- if (format === "html") {
996
- // HTML mode: content is the complete body, escaped only for AppleScript string
997
- fullBody = escapeHtmlForAppleScript(newContent);
998
- }
999
- else {
1000
- // Plaintext mode: wrap title + content in <div> tags (existing behavior)
1001
- const effectiveTitle = newTitle || title;
1002
- const safeEffectiveTitle = escapeForAppleScript(effectiveTitle);
1003
- const safeContent = escapeForAppleScript(newContent);
1004
- fullBody = `<div>${safeEffectiveTitle}</div><div>${safeContent}</div>`;
1005
- }
1006
- const updateCommand = `set body of note "${safeCurrentTitle}" to "${fullBody}"`;
1007
- const script = buildAccountScopedScript({ account: targetAccount }, updateCommand);
1008
- const result = executeAppleScript(script);
1009
- if (!result.success) {
1010
- console.error(`Failed to update note "${title}":`, result.error);
1011
- return false;
1012
- }
1013
- return true;
1014
- }
1015
- /**
1016
- * Updates an existing note by its CoreData ID.
1017
- *
1018
- * This is more reliable than updateNote() because IDs are unique,
1019
- * while titles can be duplicated.
1020
- *
1021
- * When format is 'html', newTitle is ignored — the caller must include
1022
- * the title in the HTML content.
1023
- *
1024
- * Note: Password-protected notes will fail with an AppleScript error.
1025
- * Callers should check for password protection beforehand using
1026
- * getNoteById() or isNotePasswordProtectedById().
1027
- *
1028
- * @param id - CoreData URL identifier for the note
1029
- * @param newTitle - New title (optional, keeps existing if not provided; ignored in html format)
1030
- * @param newContent - New content for the note body
1031
- * @param format - Content format: "plaintext" wraps in div tags (default), "html" uses content as-is
1032
- * @returns true if update succeeded, false otherwise
1033
- */
1034
- updateNoteById(id, newTitle, newContent, format = "plaintext") {
1035
- if (newTitle)
1036
- validateLength(newTitle, MAX_TITLE_LENGTH, "Note title");
1037
- validateLength(newContent, MAX_CONTENT_LENGTH, "Note content");
1038
- let fullBody;
1039
- if (format === "html") {
1040
- // HTML mode: content is the complete body, escaped only for AppleScript string
1041
- fullBody = escapeHtmlForAppleScript(newContent);
1042
- }
1043
- else {
1044
- // Plaintext mode: wrap title + content in <div> tags (existing behavior)
1045
- // Get the note to retrieve current title if newTitle not provided
1046
- let effectiveTitle = newTitle;
1047
- if (!effectiveTitle) {
1048
- const note = this.getNoteById(id);
1049
- if (!note) {
1050
- console.error(`Cannot update note: note with ID "${id}" not found`);
1051
- return false;
1052
- }
1053
- effectiveTitle = note.title;
1054
- }
1055
- const safeEffectiveTitle = escapeForAppleScript(effectiveTitle);
1056
- const safeContent = escapeForAppleScript(newContent);
1057
- fullBody = `<div>${safeEffectiveTitle}</div><div>${safeContent}</div>`;
1058
- }
1059
- const safeId = sanitizeId(id);
1060
- const updateCommand = `set body of note id "${safeId}" to "${fullBody}"`;
1061
- const script = buildAppLevelScript(updateCommand);
1062
- const result = executeAppleScript(script);
1063
- if (!result.success) {
1064
- console.error(`Failed to update note with ID "${id}":`, result.error);
1065
- return false;
1066
- }
1067
- return true;
1068
- }
1069
- /**
1070
- * Lists all notes in an account, optionally filtered by folder, date, and limit.
1071
- *
1072
- * @param account - Account to list notes from (defaults to iCloud)
1073
- * @param folder - Optional folder to filter by
1074
- * @param modifiedSince - Optional ISO 8601 date string to filter notes modified on or after this date
1075
- * @param limit - Optional maximum number of results to return (default: no limit)
1076
- * @returns Array of note titles
1077
- */
1078
- listNotes(account, folder, modifiedSince, limit) {
1079
- const targetAccount = this.resolveAccount(account);
1080
- const safeLimit = limit !== undefined && limit > 0 ? Math.floor(limit) : undefined;
1081
- // When date or limit filters are needed, use a repeat loop for fine-grained control
1082
- if (modifiedSince || safeLimit !== undefined) {
1083
- const baseNotesSource = folder ? `notes of ${buildFolderReference(folder)}` : "notes";
1084
- // Use whose clause for date filtering (locale-safe, no sort order assumption)
1085
- let dateSetup = "";
1086
- let notesSource = baseNotesSource;
1087
- if (modifiedSince) {
1088
- const date = new Date(modifiedSince);
1089
- if (!isNaN(date.getTime())) {
1090
- dateSetup = buildAppleScriptDateVar(date) + "\n";
1091
- notesSource = `(${baseNotesSource} whose modification date >= thresholdDate)`;
1092
- }
1093
- }
1094
- // Build the limit check. Check after appending so deduped results,
1095
- // rather than duplicate AppleScript references, determine the limit.
1096
- const limitCheck = safeLimit !== undefined
1097
- ? `
1098
- if (count of resultList) >= ${safeLimit} then exit repeat`
1099
- : "";
1100
- const listCommand = `
1101
- ${dateSetup}set resultList to {}
1102
- set seenIds to {}
1103
- repeat with n in ${notesSource}
1104
- try
1105
- set noteName to name of n
1106
- set noteId to id of n
1107
- if seenIds does not contain noteId then
1108
- set end of seenIds to noteId
1109
- set end of resultList to noteName & ${AS_FIELD_SEP} & noteId${limitCheck}
1110
- end if
1111
- end try
1112
- end repeat
1113
- set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1114
- return resultList as text
1115
- `;
1116
- const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
1117
- const result = executeAppleScript(script);
1118
- if (!result.success) {
1119
- throw new Error(`Failed to list notes: ${result.error ?? "unknown error"}`);
1120
- }
1121
- if (!result.output.trim()) {
1122
- return [];
1123
- }
1124
- const seenIds = new Set();
1125
- const titles = [];
1126
- for (const item of result.output.split(RECORD_SEP)) {
1127
- const [title, id] = item.split(FIELD_SEP);
1128
- if (!title?.trim())
1129
- continue;
1130
- const noteId = id?.trim() || generateFallbackId();
1131
- if (seenIds.has(noteId))
1132
- continue;
1133
- seenIds.add(noteId);
1134
- titles.push(title.trim());
1135
- }
1136
- return titles;
1137
- }
1138
- // Simple path: no date or limit filters. Use a repeat loop so duplicate
1139
- // CoreData note references can be deduped by ID before returning titles.
1140
- const notesRef = folder ? `notes of ${buildFolderReference(folder)}` : `notes`;
1141
- const listCommand = `
1142
- set resultList to {}
1143
- set seenIds to {}
1144
- repeat with n in ${notesRef}
1145
- try
1146
- set noteName to name of n
1147
- set noteId to id of n
1148
- if seenIds does not contain noteId then
1149
- set end of seenIds to noteId
1150
- set end of resultList to noteName & ${AS_FIELD_SEP} & noteId
1151
- end if
1152
- end try
1153
- end repeat
1154
- set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1155
- return resultList as text
1156
- `;
1157
- const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
1158
- const result = executeAppleScript(script);
1159
- if (!result.success) {
1160
- throw new Error(`Failed to list notes: ${result.error ?? "unknown error"}`);
1161
- }
1162
- if (!result.output.trim())
1163
- return [];
1164
- const seenIds = new Set();
1165
- const titles = [];
1166
- for (const item of result.output.split(RECORD_SEP)) {
1167
- const [title, id] = item.split(FIELD_SEP);
1168
- if (!title?.trim())
1169
- continue;
1170
- const noteId = id?.trim() || generateFallbackId();
1171
- if (seenIds.has(noteId))
1172
- continue;
1173
- seenIds.add(noteId);
1174
- titles.push(title.trim());
1175
- }
1176
- return titles;
1177
- }
1178
- /**
1179
- * Lists all shared (collaborative) notes across all accounts.
1180
- *
1181
- * Returns notes that are shared with other users. These notes require
1182
- * extra caution when modifying or deleting as changes affect collaborators.
1183
- *
1184
- * @returns Array of Note objects for all shared notes
1185
- *
1186
- * @example
1187
- * ```typescript
1188
- * const shared = manager.listSharedNotes();
1189
- * console.log(`You have ${shared.length} shared notes`);
1190
- * ```
1191
- */
1192
- listSharedNotes() {
1193
- const sharedNotes = [];
1194
- // Query each account for shared notes
1195
- const accounts = this.listAccounts();
1196
- for (const account of accounts) {
1197
- // Use delimited output to avoid fragile comma-based parsing.
1198
- // Format: name|||id|||createdDate|||modifiedDate|||shared|||passwordProtected
1199
- const script = buildAccountScopedScript({ account: account.name }, `
1200
- set resultList to {}
1201
- repeat with n in notes
1202
- if shared of n is true then
1203
- set cd to creation date of n
1204
- set md to modification date of n
1205
- set end of resultList to (name of n) & ${AS_FIELD_SEP} & (id of n) & ${AS_FIELD_SEP} & ${asDatePartsExpr("cd")} & ${AS_FIELD_SEP} & ${asDatePartsExpr("md")} & ${AS_FIELD_SEP} & (shared of n as text) & ${AS_FIELD_SEP} & (password protected of n as text)
1206
- end if
1207
- end repeat
1208
- set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1209
- return resultList as text
1210
- `);
1211
- const result = executeAppleScript(script);
1212
- if (!result.success) {
1213
- console.error(`Failed to list shared notes for ${account.name}:`, result.error);
1214
- continue;
1215
- }
1216
- const output = result.output.trim();
1217
- if (!output) {
1218
- continue;
1219
- }
1220
- // Parse control-char-delimited output (#18): fields by FIELD_SEP, records by RECORD_SEP.
1221
- const items = output.split(RECORD_SEP);
1222
- for (const item of items) {
1223
- const parts = item.split(FIELD_SEP);
1224
- if (parts.length >= 6) {
1225
- const title = parts[0].trim();
1226
- const id = parts[1].trim();
1227
- const createdStr = parts[2].trim();
1228
- const modifiedStr = parts[3].trim();
1229
- const shared = parts[4].trim() === "true";
1230
- const passwordProtected = parts[5].trim() === "true";
1231
- sharedNotes.push({
1232
- id,
1233
- title,
1234
- content: "",
1235
- tags: [],
1236
- created: parseAppleScriptDate(createdStr),
1237
- modified: parseAppleScriptDate(modifiedStr),
1238
- account: account.name,
1239
- shared,
1240
- passwordProtected,
1241
- });
1242
- }
1243
- }
1244
- }
1245
- return sharedNotes;
1246
- }
1247
- // ===========================================================================
1248
- // Folder Operations
1249
- // ===========================================================================
1250
- /**
1251
- * Lists all folders in an account with full hierarchical paths.
1252
- *
1253
- * Each folder's `name` field contains the full path (e.g., "Work/Clients/Omnia")
1254
- * so that duplicate folder names (e.g., multiple "Archive" folders) are
1255
- * distinguishable and can be used directly in other operations.
1256
- *
1257
- * @param account - Account to list folders from (defaults to iCloud)
1258
- * @returns Array of Folder objects with path-based names
1259
- */
1260
- listFolders(account) {
1261
- const targetAccount = this.resolveAccount(account);
1262
- // Get each folder's ID, name, parent ID, and shared state in a single AppleScript call.
1263
- // Using IDs enables correct tree building even with duplicate folder names.
1264
- const listCommand = `
1265
- set folderList to {}
1266
- set allFolders to every folder
1267
- repeat with f in allFolders
1268
- set fRef to contents of f
1269
- set cRef to container of fRef
1270
- set parentId to ""
1271
- if class of cRef is folder then
1272
- set parentId to id of cRef
1273
- end if
1274
- set sharedFlag to shared of fRef as text
1275
- set end of folderList to (id of fRef) & ${AS_FIELD_SEP} & (name of fRef) & ${AS_FIELD_SEP} & parentId & ${AS_FIELD_SEP} & sharedFlag
1276
- end repeat
1277
- set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1278
- return folderList as text
1279
- `;
1280
- const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
1281
- const result = executeAppleScript(script);
1282
- if (!result.success) {
1283
- throw new Error(`Failed to list folders: ${result.error ?? "unknown error"}`);
1284
- }
1285
- if (!result.output.trim()) {
1286
- return [];
1287
- }
1288
- const recordSeparator = result.output.includes(RECORD_SEP) ? RECORD_SEP : "\n";
1289
- const entries = result.output.split(recordSeparator).map((line) => {
1290
- const parts = line.includes(FIELD_SEP) ? line.split(FIELD_SEP) : line.split("\t");
1291
- return {
1292
- id: (parts[0] || "").trim(),
1293
- name: (parts[1] || "").trim(),
1294
- parentId: (parts[2] || "").trim(),
1295
- shared: (parts[3] || "").trim().toLowerCase() === "true",
1296
- };
1297
- });
1298
- // Build an ID-to-entry map for efficient parent lookups
1299
- const byId = new Map(entries.map((e) => [e.id, e]));
1300
- // Build full path by walking up the parent chain using unique IDs
1301
- // Build full path by walking up the parent chain using unique IDs.
1302
- // Literal slashes in folder names are escaped as `\/` so they don't
1303
- // collide with the `/` path separator.
1304
- const buildPath = (entry) => {
1305
- const safeName = escapeFolderName(entry.name);
1306
- if (!entry.parentId)
1307
- return safeName;
1308
- const parent = byId.get(entry.parentId);
1309
- if (parent) {
1310
- return buildPath(parent) + "/" + safeName;
1311
- }
1312
- return safeName;
1313
- };
1314
- return entries.map((entry) => ({
1315
- id: entry.id,
1316
- name: buildPath(entry),
1317
- account: targetAccount,
1318
- shared: entry.shared,
1319
- }));
1320
- }
1321
- /**
1322
- * Creates a new folder in an account.
1323
- *
1324
- * @param name - Name for the new folder
1325
- * @param account - Account to create folder in (defaults to iCloud)
1326
- * @returns Created Folder object, or null on failure
1327
- */
1328
- createFolder(name, account) {
1329
- const targetAccount = this.resolveAccount(account);
1330
- const parts = splitFolderPath(name);
1331
- if (parts.length === 0) {
1332
- console.error(`Invalid folder name: "${name}"`);
1333
- return null;
1334
- }
1335
- // Create each segment of the path, checking existence first to avoid duplicates.
1336
- // For "A/B/C": ensure "A" exists, then "A/B", then "A/B/C".
1337
- for (let i = 0; i < parts.length; i++) {
1338
- const currentPath = parts
1339
- .slice(0, i + 1)
1340
- .map((p) => escapeFolderName(p))
1341
- .join("/");
1342
- const currentRef = buildFolderReference(currentPath);
1343
- // Check if this folder already exists
1344
- const checkScript = buildAccountScopedScript({ account: targetAccount }, `return id of ${currentRef}`);
1345
- const checkResult = executeAppleScript(checkScript);
1346
- if (checkResult.success) {
1347
- // Folder exists, move to next segment
1348
- continue;
1349
- }
1350
- // Folder doesn't exist — create it
1351
- const segmentName = escapePlainStringForAppleScript(parts[i]);
1352
- let createCommand;
1353
- if (i === 0) {
1354
- createCommand = `make new folder with properties {name:"${segmentName}"}`;
1355
- }
1356
- else {
1357
- const parentPath = parts
1358
- .slice(0, i)
1359
- .map((p) => escapeFolderName(p))
1360
- .join("/");
1361
- const parentRef = buildFolderReference(parentPath);
1362
- createCommand = `make new folder at ${parentRef} with properties {name:"${segmentName}"}`;
1363
- }
1364
- const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
1365
- const result = executeAppleScript(script);
1366
- if (!result.success) {
1367
- console.error(`Failed to create folder "${name}":`, result.error);
1368
- return null;
1369
- }
1370
- }
1371
- // Get the ID of the final (deepest) folder
1372
- const fullRef = buildFolderReference(name);
1373
- const idScript = buildAccountScopedScript({ account: targetAccount }, `return id of ${fullRef}`);
1374
- const idResult = executeAppleScript(idScript);
1375
- const folderId = idResult.success ? extractCoreDataId(idResult.output, "folder") : "";
1376
- return {
1377
- id: folderId,
1378
- name,
1379
- account: targetAccount,
1380
- };
1381
- }
1382
- /**
1383
- * Deletes a folder from an account.
1384
- *
1385
- * Note: This may fail if the folder contains notes.
1386
- *
1387
- * @param name - Name of the folder to delete
1388
- * @param account - Account containing the folder (defaults to iCloud)
1389
- * @returns true if deletion succeeded, false otherwise
1390
- */
1391
- deleteFolder(name, account) {
1392
- const targetAccount = this.resolveAccount(account);
1393
- const deleteCommand = `delete ${buildFolderReference(name)}`;
1394
- const script = buildAccountScopedScript({ account: targetAccount }, deleteCommand);
1395
- const result = executeAppleScript(script);
1396
- if (!result.success) {
1397
- console.error(`Failed to delete folder "${name}":`, result.error);
1398
- return false;
1399
- }
1400
- return true;
1401
- }
1402
- /**
1403
- * Moves a note to a different folder, looked up by title.
1404
- *
1405
- * Uses Notes.app's native `move` command (the same one `batchMoveNotes`
1406
- * uses), which relocates the note in place — preserving its identity, id,
1407
- * creation date, AND all embedded attachments (files/images/PDFs/scans/audio).
1408
- * The previous copy-then-delete implementation rebuilt the note from its body
1409
- * HTML, which silently dropped attachments and reset the note's identity.
1410
- *
1411
- * The note is resolved to its id first (titles can be duplicated), then moved
1412
- * by id so the title-based and id-based paths share the same native move.
1413
- *
1414
- * @param title - Title of the note to move
1415
- * @param destinationFolder - Name of the folder to move to (must already exist)
1416
- * @param account - Account containing the note (defaults to iCloud)
1417
- * @returns true if the move succeeded, false otherwise
1418
- */
1419
- moveNote(title, destinationFolder, account) {
1420
- const targetAccount = this.resolveAccount(account);
1421
- // Resolve the note's id first (titles can be duplicated), then delegate to
1422
- // the id-based native move so both paths preserve attachments + identity.
1423
- const originalNote = this.getNoteDetails(title, targetAccount);
1424
- if (!originalNote) {
1425
- console.error(`Cannot move note "${title}": note not found`);
1426
- return false;
1427
- }
1428
- return this.moveNoteById(originalNote.id, destinationFolder, targetAccount);
1429
- }
1430
- /**
1431
- * Moves a note to a different folder by its CoreData ID.
1432
- *
1433
- * Uses Notes.app's native `move <noteRef> to <destFolder>` command — the same
1434
- * one `batchMoveNotes` uses — which relocates the note in place, preserving its
1435
- * id, creation date, and all embedded attachments. (The old copy-then-delete
1436
- * approach rebuilt the note from body HTML and silently lost attachments.)
1437
- *
1438
- * @param id - CoreData URL identifier for the note
1439
- * @param destinationFolder - Name of the folder to move to (must already exist)
1440
- * @param account - Account containing the destination folder (defaults to iCloud)
1441
- * @returns true if the move succeeded, false otherwise
1442
- */
1443
- moveNoteById(id, destinationFolder, account) {
1444
- const targetAccount = this.resolveAccount(account);
1445
- const safeId = sanitizeId(id);
1446
- const safeAccount = sanitizeAccountName(targetAccount);
1447
- // buildFolderReference validates the destination path; a malformed folder is
1448
- // a precondition error, so let it throw. The destination folder must already
1449
- // exist — Notes.app's `move` does not create it.
1450
- const destFolderRef = `${buildFolderReference(destinationFolder)} of account "${safeAccount}"`;
1451
- const moveCommand = `
1452
- set destFolder to ${destFolderRef}
1453
- set noteRef to note id "${safeId}"
1454
- move noteRef to destFolder
1455
- `;
1456
- const script = buildAppLevelScript(moveCommand);
1457
- const result = executeAppleScript(script);
1458
- if (!result.success) {
1459
- console.error(`Cannot move note to "${destinationFolder}" (folder may not exist):`, result.error);
1460
- return false;
1461
- }
1462
- return true;
1463
- }
1464
- // ===========================================================================
1465
- // Account Operations
1466
- // ===========================================================================
1467
- /**
1468
- * Lists all available Notes accounts.
1469
- *
1470
- * Common accounts include iCloud, Gmail, Exchange, and other
1471
- * email providers configured on the Mac.
1472
- *
1473
- * @returns Array of Account objects
1474
- */
1475
- listAccounts() {
1476
- // Coerce account records to text with control-char delimiters so names
1477
- // containing commas or tabs can't split into phantom accounts (#18).
1478
- const listCommand = `
1479
- set resultList to {}
1480
- repeat with a in accounts
1481
- set aRef to contents of a
1482
- set defaultFolderId to ""
1483
- set defaultFolderName to ""
1484
- try
1485
- set fRef to default folder of aRef
1486
- set defaultFolderId to id of fRef
1487
- set defaultFolderName to name of fRef
1488
- end try
1489
- set upgradedFlag to upgraded of aRef as text
1490
- set end of resultList to (id of aRef) & ${AS_FIELD_SEP} & (name of aRef) & ${AS_FIELD_SEP} & upgradedFlag & ${AS_FIELD_SEP} & defaultFolderId & ${AS_FIELD_SEP} & defaultFolderName
1491
- end repeat
1492
- set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1493
- return resultList as text
1494
- `;
1495
- const script = buildAppLevelScript(listCommand);
1496
- const result = executeAppleScript(script);
1497
- if (!result.success) {
1498
- throw new Error(`Failed to list accounts: ${result.error ?? "unknown error"}`);
1499
- }
1500
- return result.output
1501
- .split(RECORD_SEP)
1502
- .map((s) => s.trim())
1503
- .filter((s) => s.length > 0)
1504
- .map((item) => {
1505
- const parts = item.split(FIELD_SEP);
1506
- if (parts.length === 1) {
1507
- return { name: parts[0].trim() };
1508
- }
1509
- return {
1510
- id: (parts[0] || "").trim(),
1511
- name: (parts[1] || "").trim(),
1512
- upgraded: (parts[2] || "").trim().toLowerCase() === "true",
1513
- defaultFolderId: (parts[3] || "").trim() || undefined,
1514
- defaultFolder: (parts[4] || "").trim() || undefined,
1515
- };
1516
- });
1517
- }
1518
- /**
1519
- * Gets the default account and folder used by Notes.app for new notes.
1520
- *
1521
- * @returns Default account and folder metadata
1522
- */
1523
- getDefaultLocation() {
1524
- const command = `
1525
- set aRef to default account
1526
- set fRef to default folder of aRef
1527
- return (id of aRef) & ${AS_FIELD_SEP} & (name of aRef) & ${AS_FIELD_SEP} & (upgraded of aRef as text) & ${AS_FIELD_SEP} & (id of fRef) & ${AS_FIELD_SEP} & (name of fRef) & ${AS_FIELD_SEP} & (shared of fRef as text)
1528
- `;
1529
- const result = executeAppleScript(buildAppLevelScript(command));
1530
- if (!result.success) {
1531
- throw new Error(`Failed to get default Notes location: ${result.error ?? "unknown error"}`);
1532
- }
1533
- const parts = result.output.split(FIELD_SEP);
1534
- if (parts.length < 6) {
1535
- throw new Error(`Failed to parse default Notes location: ${result.output}`);
1536
- }
1537
- const accountName = (parts[1] || "").trim();
1538
- return {
1539
- account: {
1540
- id: (parts[0] || "").trim(),
1541
- name: accountName,
1542
- upgraded: (parts[2] || "").trim().toLowerCase() === "true",
1543
- defaultFolderId: (parts[3] || "").trim(),
1544
- defaultFolder: (parts[4] || "").trim(),
1545
- },
1546
- folder: {
1547
- id: (parts[3] || "").trim(),
1548
- name: (parts[4] || "").trim(),
1549
- account: accountName,
1550
- shared: (parts[5] || "").trim().toLowerCase() === "true",
1551
- },
1552
- };
1553
- }
1554
- /**
1555
- * Lists the currently selected Notes in the Notes.app UI.
1556
- *
1557
- * @returns Array of selected notes, or an empty array when nothing is selected
1558
- */
1559
- getSelectedNotes() {
1560
- const command = `
1561
- set selectedNotes to selection
1562
- set noteList to {}
1563
- repeat with n in selectedNotes
1564
- set nRef to contents of n
1565
- set createdDate to creation date of nRef
1566
- set modifiedDate to modification date of nRef
1567
- set createdParts to ${asDatePartsExpr("createdDate")}
1568
- set modifiedParts to ${asDatePartsExpr("modifiedDate")}
1569
- set folderName to ""
1570
- set accountName to ""
1571
- try
1572
- set fRef to container of nRef
1573
- set folderName to name of fRef
1574
- set aRef to container of fRef
1575
- set accountName to name of aRef
1576
- end try
1577
- set end of noteList to (id of nRef) & ${AS_FIELD_SEP} & (name of nRef) & ${AS_FIELD_SEP} & createdParts & ${AS_FIELD_SEP} & modifiedParts & ${AS_FIELD_SEP} & (shared of nRef as text) & ${AS_FIELD_SEP} & (password protected of nRef as text) & ${AS_FIELD_SEP} & folderName & ${AS_FIELD_SEP} & accountName
1578
- end repeat
1579
- set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1580
- return noteList as text
1581
- `;
1582
- const result = executeAppleScript(buildAppLevelScript(command));
1583
- if (!result.success) {
1584
- throw new Error(`Failed to get selected notes: ${result.error ?? "unknown error"}`);
1585
- }
1586
- if (!result.output.trim()) {
1587
- return [];
1588
- }
1589
- return result.output
1590
- .split(RECORD_SEP)
1591
- .filter((s) => s.trim())
1592
- .map((item) => {
1593
- const parts = item.split(FIELD_SEP);
1594
- return {
1595
- id: (parts[0] || "").trim(),
1596
- title: (parts[1] || "").trim(),
1597
- content: "",
1598
- tags: [],
1599
- created: parseAppleScriptDate((parts[2] || "").trim()),
1600
- modified: parseAppleScriptDate((parts[3] || "").trim()),
1601
- shared: (parts[4] || "").trim().toLowerCase() === "true",
1602
- passwordProtected: (parts[5] || "").trim().toLowerCase() === "true",
1603
- folder: (parts[6] || "").trim() || undefined,
1604
- account: (parts[7] || "").trim() || undefined,
1605
- };
1606
- });
1607
- }
1608
- /**
1609
- * Reveals a note in the Notes.app UI by ID.
1610
- *
1611
- * @param id - CoreData URL identifier for the note
1612
- * @param separately - Whether to open the note in a separate window
1613
- * @returns true if Notes.app accepted the show command
1614
- */
1615
- showNoteById(id, separately = false) {
1616
- const safeId = sanitizeId(id);
1617
- const separatelyClause = separately ? " separately true" : "";
1618
- const result = executeAppleScript(buildAppLevelScript(`show note id "${safeId}"${separatelyClause}`));
1619
- if (!result.success) {
1620
- console.error(`Failed to show note with ID "${id}":`, result.error);
1621
- return false;
1622
- }
1623
- return true;
1624
- }
1625
- /**
1626
- * Reveals a folder in the Notes.app UI by its id.
1627
- *
1628
- * Wraps the Notes `show` command, which the scripting dictionary exposes for
1629
- * folders as well as notes. This opens or focuses the Notes UI on the folder.
1630
- *
1631
- * @param id - CoreData identifier for the folder (from list-folders)
1632
- * @param separately - Open in a separate window when supported by Notes.app
1633
- * @returns true if Notes.app accepted the show command, false otherwise
1634
- */
1635
- showFolderById(id, separately = false) {
1636
- const safeId = sanitizeId(id);
1637
- const separatelyClause = separately ? " separately true" : "";
1638
- const result = executeAppleScript(buildAppLevelScript(`show folder id "${safeId}"${separatelyClause}`));
1639
- if (!result.success) {
1640
- console.error(`Failed to show folder with ID "${id}":`, result.error);
1641
- return false;
1642
- }
1643
- return true;
1644
- }
1645
- /**
1646
- * Reveals an account in the Notes.app UI by its id.
1647
- *
1648
- * Wraps the Notes `show` command, which the scripting dictionary exposes for
1649
- * accounts as well as notes. This opens or focuses the Notes UI on the account.
1650
- *
1651
- * @param id - CoreData identifier for the account (from list-accounts)
1652
- * @param separately - Open in a separate window when supported by Notes.app
1653
- * @returns true if Notes.app accepted the show command, false otherwise
1654
- */
1655
- showAccountById(id, separately = false) {
1656
- const safeId = sanitizeId(id);
1657
- const separatelyClause = separately ? " separately true" : "";
1658
- const result = executeAppleScript(buildAppLevelScript(`show account id "${safeId}"${separatelyClause}`));
1659
- if (!result.success) {
1660
- console.error(`Failed to show account with ID "${id}":`, result.error);
1661
- return false;
1662
- }
1663
- return true;
1664
- }
1665
- /**
1666
- * Reveals an attachment in the Notes.app UI.
1667
- *
1668
- * Attachments are elements of a note, so they cannot be referenced at the
1669
- * application level by id alone. This resolves the attachment within its note
1670
- * (the same lookup used by save-attachment) and then runs the Notes `show`
1671
- * command on it, opening or focusing the Notes UI on the attachment.
1672
- *
1673
- * @param noteId - CoreData identifier for the note containing the attachment
1674
- * @param attachmentId - id of the attachment (from list-attachments)
1675
- * @param separately - Open in a separate window when supported by Notes.app
1676
- * @returns true if Notes.app revealed the attachment, false otherwise
1677
- */
1678
- showAttachmentById(noteId, attachmentId, separately = false) {
1679
- const safeNoteId = sanitizeId(noteId);
1680
- const safeAttId = escapePlainStringForAppleScript(attachmentId);
1681
- const separatelyClause = separately ? " separately true" : "";
1682
- const script = `
1683
- tell application "Notes"
1684
- set theNote to note id "${safeNoteId}"
1685
- set theAttachment to missing value
1686
- repeat with a in attachments of theNote
1687
- if (id of a as text) is "${safeAttId}" then
1688
- set theAttachment to a
1689
- exit repeat
1690
- end if
1691
- end repeat
1692
- if theAttachment is missing value then
1693
- return "ERR${AS_FIELD_SEP}attachment not found"
1694
- end if
1695
- show theAttachment${separatelyClause}
1696
- return "OK"
1697
- end tell
1698
- `;
1699
- const result = executeAppleScript(script);
1700
- if (!result.success) {
1701
- console.error(`Failed to show attachment "${attachmentId}" on note "${noteId}":`, result.error);
1702
- return false;
1703
- }
1704
- if ((result.output ?? "").trim().startsWith("ERR")) {
1705
- console.error(`Attachment "${attachmentId}" not found on note "${noteId}"`);
1706
- return false;
1707
- }
1708
- return true;
1709
- }
1710
- // ===========================================================================
1711
- // Health Check
1712
- // ===========================================================================
1713
- /**
1714
- * Performs a health check on Notes.app accessibility and functionality.
1715
- *
1716
- * This method verifies:
1717
- * - Notes.app is installed and accessible
1718
- * - AppleScript automation permissions are granted
1719
- * - At least one account is available
1720
- * - Basic list operations work
1721
- *
1722
- * Use this to diagnose connection issues or verify setup.
1723
- *
1724
- * @returns HealthCheckResult with overall status and individual check details
1725
- *
1726
- * @example
1727
- * ```typescript
1728
- * const health = manager.healthCheck();
1729
- * if (!health.healthy) {
1730
- * console.log("Issues found:");
1731
- * health.checks.filter(c => !c.passed).forEach(c => console.log(`- ${c.message}`));
1732
- * }
1733
- * ```
1734
- */
1735
- healthCheck() {
1736
- const checks = [];
1737
- // Check 1: Notes.app is accessible
1738
- const appCheck = executeAppleScript('tell application "Notes" to return "ok"');
1739
- if (appCheck.success && appCheck.output === "ok") {
1740
- checks.push({
1741
- name: "notes_app",
1742
- passed: true,
1743
- message: "Notes.app is accessible",
1744
- });
1745
- }
1746
- else {
1747
- const errorHint = appCheck.error?.includes("not authorized")
1748
- ? " (check Automation permissions in System Preferences)"
1749
- : "";
1750
- checks.push({
1751
- name: "notes_app",
1752
- passed: false,
1753
- message: `Notes.app is not accessible${errorHint}`,
1754
- });
1755
- // If Notes.app isn't accessible, skip other checks
1756
- return { healthy: false, checks };
1757
- }
1758
- // Check 2: AppleScript permissions (can we execute commands?)
1759
- const permCheck = executeAppleScript('tell application "Notes" to get name of account 1');
1760
- if (permCheck.success) {
1761
- checks.push({
1762
- name: "permissions",
1763
- passed: true,
1764
- message: "AppleScript automation permissions granted",
1765
- });
1766
- }
1767
- else {
1768
- const isPermError = permCheck.error?.includes("not authorized") || permCheck.error?.includes("not permitted");
1769
- checks.push({
1770
- name: "permissions",
1771
- passed: !isPermError,
1772
- message: isPermError
1773
- ? "AppleScript permissions denied. Grant access in System Preferences > Privacy & Security > Automation"
1774
- : `Permission check returned: ${permCheck.error}`,
1775
- });
1776
- if (isPermError) {
1777
- return { healthy: false, checks };
1778
- }
1779
- }
1780
- // Check 3: At least one account accessible
1781
- const accounts = this.listAccounts();
1782
- if (accounts.length > 0) {
1783
- const accountNames = accounts.map((a) => a.name).join(", ");
1784
- checks.push({
1785
- name: "accounts",
1786
- passed: true,
1787
- message: `Found ${accounts.length} account(s): ${accountNames}`,
1788
- });
1789
- }
1790
- else {
1791
- checks.push({
1792
- name: "accounts",
1793
- passed: false,
1794
- message: "No Notes accounts found. Set up an account in Notes.app first.",
1795
- });
1796
- return { healthy: false, checks };
1797
- }
1798
- // Check 4: Basic operations work (list notes in default account)
1799
- const defaultAccount = accounts[0]?.name || "iCloud";
1800
- const notes = this.listNotes(defaultAccount);
1801
- // Even 0 notes is fine - we just want to verify the operation works
1802
- checks.push({
1803
- name: "operations",
1804
- passed: true,
1805
- message: `Basic operations working (${notes.length} note(s) in ${defaultAccount})`,
1806
- });
1807
- const allPassed = checks.every((c) => c.passed);
1808
- return { healthy: allPassed, checks };
1809
- }
1810
- // ===========================================================================
1811
- // Statistics
1812
- // ===========================================================================
1813
- /**
1814
- * Gets comprehensive statistics about notes across all accounts.
1815
- *
1816
- * Returns total note counts, per-account breakdowns, folder statistics,
1817
- * and counts of recently modified notes.
1818
- *
1819
- * @returns NotesStats object with comprehensive statistics
1820
- *
1821
- * @example
1822
- * ```typescript
1823
- * const stats = manager.getNotesStats();
1824
- * console.log(`Total notes: ${stats.totalNotes}`);
1825
- * console.log(`Modified today: ${stats.recentlyModified.last24h}`);
1826
- * ```
1827
- */
1828
- getNotesStats() {
1829
- const accounts = this.listAccounts();
1830
- const accountStats = [];
1831
- const warnings = [];
1832
- let totalNotes = 0;
1833
- // Collect stats per account with ONE bounded script per account (#20/#26):
1834
- // count notes server-side per folder instead of fetching every note's name
1835
- // (unbounded) via a listNotes call per folder (N+1 osascript spawns).
1836
- //
1837
- // Per-account failures degrade gracefully (#19): a single unreachable or
1838
- // locked account is recorded as a coverage warning and skipped, rather than
1839
- // discarding the stats for every healthy account. Only a total wipeout
1840
- // (no account readable) is escalated to a thrown error below.
1841
- for (const account of accounts) {
1842
- const countScript = buildAccountScopedScript({ account: account.name }, `
1843
- set out to ""
1844
- repeat with fldr in folders
1845
- set out to out & (name of fldr) & ${AS_FIELD_SEP} & (count of notes of fldr) & ${AS_RECORD_SEP}
1846
- end repeat
1847
- return out
1848
- `);
1849
- const res = executeAppleScript(countScript);
1850
- if (!res.success) {
1851
- warnings.push({ scope: account.name, reason: res.error ?? "unknown error" });
1852
- continue;
1853
- }
1854
- const folderStats = [];
1855
- let accountTotal = 0;
1856
- for (const rec of res.output.split(RECORD_SEP)) {
1857
- if (!rec.trim())
1858
- continue;
1859
- const [fname, cnt] = rec.split(FIELD_SEP);
1860
- const noteCount = parseInt((cnt ?? "").trim(), 10) || 0;
1861
- accountTotal += noteCount;
1862
- folderStats.push({ name: (fname ?? "").trim(), noteCount });
1863
- }
1864
- totalNotes += accountTotal;
1865
- accountStats.push({
1866
- name: account.name,
1867
- totalNotes: accountTotal,
1868
- folderCount: folderStats.length,
1869
- folders: folderStats,
1870
- });
1871
- }
1872
- // If every account failed, there is no data to report — surface the error
1873
- // (#19) rather than returning a deceptively empty stats object.
1874
- if (accounts.length > 0 && accountStats.length === 0) {
1875
- throw new Error(`Failed to read folder stats for any of ${accounts.length} account(s): ${warnings
1876
- .map((w) => `${w.scope} (${w.reason})`)
1877
- .join("; ")}`);
1878
- }
1879
- // Get recently modified notes counts. A failure here is non-fatal — record a
1880
- // coverage warning and report zeros, flagged as not-covered (#19), instead of
1881
- // passing off fake zero activity as real.
1882
- const recent = this.getRecentlyModifiedCounts();
1883
- if (recent.error) {
1884
- warnings.push({ scope: "recent-activity", reason: recent.error });
1885
- }
1886
- // scopes = each account + the recent-activity scan
1887
- const scanned = accounts.length + 1;
1888
- const covered = scanned - warnings.length;
1889
- return {
1890
- totalNotes,
1891
- accounts: accountStats,
1892
- recentlyModified: recent.counts,
1893
- coverage: {
1894
- complete: warnings.length === 0,
1895
- scanned,
1896
- covered,
1897
- warnings,
1898
- },
1899
- };
1900
- }
1901
- /**
1902
- * Helper to get counts of recently modified notes.
1903
- */
1904
- getRecentlyModifiedCounts() {
1905
- // Count server-side with locale-safe date variables (#20/#25): instead of
1906
- // streaming every note's modification date to JS (unbounded, ENOBUFS-prone,
1907
- // locale-fragile), let AppleScript count matches via a `whose` filter — three
1908
- // counts per account, regardless of library size.
1909
- const now = new Date();
1910
- const d1 = new Date(now.getTime() - 24 * 60 * 60 * 1000);
1911
- const d7 = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
1912
- const d30 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
1913
- const script = `
1914
- tell application "Notes"
1915
- ${buildAppleScriptDateVar(d1, "d1")}
1916
- ${buildAppleScriptDateVar(d7, "d7")}
1917
- ${buildAppleScriptDateVar(d30, "d30")}
1918
- set c1 to 0
1919
- set c7 to 0
1920
- set c30 to 0
1921
- repeat with acct in accounts
1922
- set c1 to c1 + (count of (notes of acct whose modification date >= d1))
1923
- set c7 to c7 + (count of (notes of acct whose modification date >= d7))
1924
- set c30 to c30 + (count of (notes of acct whose modification date >= d30))
1925
- end repeat
1926
- return (c1 as text) & ${AS_FIELD_SEP} & (c7 as text) & ${AS_FIELD_SEP} & (c30 as text)
1927
- end tell
1928
- `;
1929
- const result = executeAppleScript(script);
1930
- if (!result.success) {
1931
- // Non-fatal (#19): report the error to the caller so it becomes a coverage
1932
- // warning, with zeroed counts, instead of throwing away the whole stats
1933
- // result or passing off fake zero activity as real.
1934
- return {
1935
- counts: { last24h: 0, last7d: 0, last30d: 0 },
1936
- error: result.error ?? "unknown error",
1937
- };
1938
- }
1939
- const parts = result.output.trim().split(FIELD_SEP);
1940
- const toInt = (s) => {
1941
- const n = parseInt((s ?? "").trim(), 10);
1942
- return Number.isFinite(n) ? n : 0;
1943
- };
1944
- return {
1945
- counts: { last24h: toInt(parts[0]), last7d: toInt(parts[1]), last30d: toInt(parts[2]) },
1946
- };
1947
- }
1948
- // ===========================================================================
1949
- // Attachments
1950
- // ===========================================================================
1951
- /**
1952
- * Lists attachments for a note by its ID.
1953
- *
1954
- * Returns metadata about each attachment including name and content type.
1955
- * Note: The position within the note cannot be determined via AppleScript.
1956
- *
1957
- * @param id - CoreData URL identifier for the note
1958
- * @returns Array of Attachment objects, or empty array if none found
1959
- *
1960
- * @example
1961
- * ```typescript
1962
- * const attachments = manager.listAttachmentsById("x-coredata://ABC/ICNote/p123");
1963
- * attachments.forEach(a => console.log(`${a.name}: ${a.contentType}`));
1964
- * ```
1965
- */
1966
- listAttachmentsById(id) {
1967
- const safeId = sanitizeId(id);
1968
- const script = `
1969
- tell application "Notes"
1970
- set theNote to note id "${safeId}"
1971
- set attachmentList to {}
1972
- repeat with a in attachments of theNote
1973
- set attachId to id of a
1974
- set attachName to name of a
1975
- set attachContentId to content identifier of a
1976
- set attachUrl to ""
1977
- try
1978
- set attachUrl to URL of a as text
1979
- end try
1980
- set createdDate to creation date of a
1981
- set modifiedDate to modification date of a
1982
- set createdParts to ${asDatePartsExpr("createdDate")}
1983
- set modifiedParts to ${asDatePartsExpr("modifiedDate")}
1984
- set sharedFlag to shared of a as text
1985
- set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachContentId & ${AS_FIELD_SEP} & attachUrl & ${AS_FIELD_SEP} & createdParts & ${AS_FIELD_SEP} & modifiedParts & ${AS_FIELD_SEP} & sharedFlag
1986
- end repeat
1987
- set output to ""
1988
- repeat with item in attachmentList
1989
- set output to output & item & ${AS_RECORD_SEP}
1990
- end repeat
1991
- return output
1992
- end tell
1993
- `;
1994
- const result = executeAppleScript(script);
1995
- if (!result.success || !result.output) {
1996
- if (result.error) {
1997
- console.error(`Failed to list attachments for note ID "${id}":`, result.error);
1998
- }
1999
- return [];
2000
- }
2001
- // Parse the results
2002
- const attachments = [];
2003
- const items = result.output.split(RECORD_SEP).filter((s) => s.trim());
2004
- for (const item of items) {
2005
- const parts = item.split(FIELD_SEP);
2006
- if (parts.length >= 3) {
2007
- attachments.push({
2008
- id: parts[0].trim(),
2009
- name: parts[1].trim(),
2010
- contentType: parts[2].trim(),
2011
- contentId: parts[2].trim() || undefined,
2012
- url: parts[3]?.trim() || undefined,
2013
- created: parts[4] ? parseAppleScriptDate(parts[4].trim()) : undefined,
2014
- modified: parts[5] ? parseAppleScriptDate(parts[5].trim()) : undefined,
2015
- shared: parts[6] ? parts[6].trim().toLowerCase() === "true" : undefined,
2016
- });
2017
- }
2018
- }
2019
- return attachments;
2020
- }
2021
- /**
2022
- * Lists attachments for a note by its title.
2023
- *
2024
- * @param title - Title of the note
2025
- * @param account - Account containing the note (defaults to iCloud)
2026
- * @returns Array of Attachment objects, or empty array if none found
2027
- */
2028
- listAttachments(title, account) {
2029
- const targetAccount = this.resolveAccount(account);
2030
- const safeAccount = escapePlainStringForAppleScript(targetAccount);
2031
- const safeTitle = escapePlainStringForAppleScript(title);
2032
- const script = `
2033
- tell application "Notes"
2034
- tell account "${safeAccount}"
2035
- set theNote to note "${safeTitle}"
2036
- set attachmentList to {}
2037
- repeat with a in attachments of theNote
2038
- set attachId to id of a
2039
- set attachName to name of a
2040
- set attachContentId to content identifier of a
2041
- set attachUrl to ""
2042
- try
2043
- set attachUrl to URL of a as text
2044
- end try
2045
- set createdDate to creation date of a
2046
- set modifiedDate to modification date of a
2047
- set createdParts to ${asDatePartsExpr("createdDate")}
2048
- set modifiedParts to ${asDatePartsExpr("modifiedDate")}
2049
- set sharedFlag to shared of a as text
2050
- set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachContentId & ${AS_FIELD_SEP} & attachUrl & ${AS_FIELD_SEP} & createdParts & ${AS_FIELD_SEP} & modifiedParts & ${AS_FIELD_SEP} & sharedFlag
2051
- end repeat
2052
- set output to ""
2053
- repeat with item in attachmentList
2054
- set output to output & item & ${AS_RECORD_SEP}
2055
- end repeat
2056
- return output
2057
- end tell
2058
- end tell
2059
- `;
2060
- const result = executeAppleScript(script);
2061
- if (!result.success || !result.output) {
2062
- if (result.error) {
2063
- console.error(`Failed to list attachments for note "${title}":`, result.error);
2064
- }
2065
- return [];
2066
- }
2067
- // Parse the results
2068
- const attachments = [];
2069
- const items = result.output.split(RECORD_SEP).filter((s) => s.trim());
2070
- for (const item of items) {
2071
- const parts = item.split(FIELD_SEP);
2072
- if (parts.length >= 3) {
2073
- attachments.push({
2074
- id: parts[0].trim(),
2075
- name: parts[1].trim(),
2076
- contentType: parts[2].trim(),
2077
- contentId: parts[2].trim() || undefined,
2078
- url: parts[3]?.trim() || undefined,
2079
- created: parts[4] ? parseAppleScriptDate(parts[4].trim()) : undefined,
2080
- modified: parts[5] ? parseAppleScriptDate(parts[5].trim()) : undefined,
2081
- shared: parts[6] ? parts[6].trim().toLowerCase() === "true" : undefined,
2082
- });
2083
- }
2084
- }
2085
- return attachments;
2086
- }
2087
- /**
2088
- * Saves a single attachment of a note (identified by attachment id) to a file
2089
- * on disk via Notes.app's AppleScript `save` (#27).
2090
- *
2091
- * @param noteId - CoreData URL identifier for the note
2092
- * @param attachmentId - id of the attachment (from list-attachments)
2093
- * @param savePath - absolute destination file path (within home / temp / /Volumes)
2094
- * @returns { success, savedPath?, name?, contentType?, error? }
2095
- */
2096
- saveAttachmentById(noteId, attachmentId, savePath) {
2097
- let abs;
2098
- try {
2099
- abs = assertSafeSavePath(savePath);
2100
- }
2101
- catch (e) {
2102
- return { success: false, error: e instanceof Error ? e.message : String(e) };
2103
- }
2104
- const safeNoteId = sanitizeId(noteId);
2105
- const safeAttId = escapePlainStringForAppleScript(attachmentId);
2106
- const safePath = escapePlainStringForAppleScript(abs);
2107
- const script = `
2108
- tell application "Notes"
2109
- set theNote to note id "${safeNoteId}"
2110
- set theAttachment to missing value
2111
- repeat with a in attachments of theNote
2112
- if (id of a as text) is "${safeAttId}" then
2113
- set theAttachment to a
2114
- exit repeat
2115
- end if
2116
- end repeat
2117
- if theAttachment is missing value then
2118
- return "ERR${AS_FIELD_SEP}attachment not found"
2119
- end if
2120
- save theAttachment in (POSIX file "${safePath}")
2121
- return "OK${AS_FIELD_SEP}" & (name of theAttachment) & "${AS_FIELD_SEP}" & (content identifier of theAttachment)
2122
- end tell
2123
- `;
2124
- const result = executeAppleScript(script);
2125
- if (!result.success) {
2126
- return { success: false, error: result.error ?? "unknown error" };
2127
- }
2128
- const parts = (result.output ?? "").trim().split(FIELD_SEP);
2129
- if (parts[0] !== "OK") {
2130
- return { success: false, error: parts[1]?.trim() || "attachment not found" };
2131
- }
2132
- if (!existsSync(abs) || fileSize(abs) === 0) {
2133
- return { success: false, error: `Notes reported success but no file was written to ${abs}` };
2134
- }
2135
- return {
2136
- success: true,
2137
- savedPath: abs,
2138
- name: parts[1]?.trim(),
2139
- contentType: parts[2]?.trim(),
2140
- };
2141
- }
2142
- /**
2143
- * Fetches a note attachment as base64 (#27). Exports to a private temp file,
2144
- * reads it, then deletes the temp copy.
2145
- *
2146
- * @param noteId - CoreData URL identifier for the note
2147
- * @param attachmentId - id of the attachment
2148
- * @returns { success, name?, contentType?, base64?, bytes?, error? }
2149
- */
2150
- getAttachmentBase64ById(noteId, attachmentId) {
2151
- const dir = makeTempDir();
2152
- try {
2153
- const dest = `${dir}/attachment.bin`;
2154
- const saved = this.saveAttachmentById(noteId, attachmentId, dest);
2155
- if (!saved.success || !saved.savedPath) {
2156
- return { success: false, error: saved.error };
2157
- }
2158
- // readFileBase64Capped checks the file size BEFORE reading and throws if it
2159
- // exceeds APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES — the throw is caught below
2160
- // and the temp dir is still cleaned up in `finally`.
2161
- const base64 = readFileBase64Capped(saved.savedPath);
2162
- return {
2163
- success: true,
2164
- name: saved.name,
2165
- contentType: saved.contentType,
2166
- base64,
2167
- bytes: fileSize(saved.savedPath),
2168
- };
2169
- }
2170
- catch (e) {
2171
- return { success: false, error: e instanceof Error ? e.message : String(e) };
2172
- }
2173
- finally {
2174
- cleanupTempDir(dir);
2175
- }
2176
- }
2177
- // ===========================================================================
2178
- // Batch Operations
2179
- // ===========================================================================
2180
- /**
2181
- * Result of a batch operation on a single item.
2182
- */
2183
- createBatchResult(id, success, error) {
2184
- return error ? { id, success, error } : { id, success };
2185
- }
2186
- /**
2187
- * Deletes multiple notes by their IDs.
2188
- *
2189
- * Each deletion is attempted independently; failures don't stop other deletions.
2190
- * Returns results for each note indicating success or failure.
2191
- *
2192
- * @param ids - Array of CoreData URL identifiers for notes to delete
2193
- * @returns Array of results with id, success status, and optional error message
2194
- *
2195
- * @example
2196
- * ```typescript
2197
- * const results = manager.batchDeleteNotes([
2198
- * "x-coredata://ABC/ICNote/p1",
2199
- * "x-coredata://ABC/ICNote/p2"
2200
- * ]);
2201
- * results.forEach(r => {
2202
- * if (r.success) console.log(`Deleted ${r.id}`);
2203
- * else console.log(`Failed to delete ${r.id}: ${r.error}`);
2204
- * });
2205
- * ```
2206
- */
2207
- batchDeleteNotes(ids) {
2208
- if (ids.length === 0)
2209
- return [];
2210
- // Collapse the whole batch into ONE osascript spawn (#26): a single
2211
- // app-level script loops over every id, with a per-id `try` so one bad note
2212
- // can't abort the rest. The old path spawned 3 processes per note
2213
- // (getNoteById + isNotePasswordProtectedById + deleteNoteById) — i.e. 3N
2214
- // spawns for N notes. This is one spawn total, with the same per-item
2215
- // isolation and result semantics.
2216
- const results = new Array(ids.length);
2217
- const runnable = [];
2218
- ids.forEach((id, i) => {
2219
- try {
2220
- runnable.push({ index: i, safe: sanitizeId(id) });
2221
- }
2222
- catch (e) {
2223
- results[i] = this.createBatchResult(id, false, e instanceof Error ? e.message : "Invalid note ID");
2224
- }
2225
- });
2226
- if (runnable.length > 0) {
2227
- const idList = runnable.map((r) => `"${r.safe}"`).join(", ");
2228
- const script = buildAppLevelScript(`
2229
- set out to ""
2230
- repeat with rawId in {${idList}}
2231
- set theId to (rawId as text)
2232
- set noteRef to missing value
2233
- try
2234
- set noteRef to note id theId
2235
- end try
2236
- if noteRef is missing value then
2237
- set out to out & "missing" & ${AS_RECORD_SEP}
2238
- else
2239
- set isPw to false
2240
- try
2241
- set isPw to (password protected of noteRef)
2242
- end try
2243
- if isPw then
2244
- set out to out & "pw" & ${AS_RECORD_SEP}
2245
- else
2246
- try
2247
- delete noteRef
2248
- set out to out & "ok" & ${AS_RECORD_SEP}
2249
- on error
2250
- set out to out & "fail" & ${AS_RECORD_SEP}
2251
- end try
2252
- end if
2253
- end if
2254
- end repeat
2255
- return out
2256
- `);
2257
- const res = executeAppleScript(script);
2258
- if (!res.success) {
2259
- // Whole-batch failure (e.g. Notes.app not responding): can't isolate,
2260
- // so mark every runnable note as failed with the underlying error.
2261
- for (const r of runnable) {
2262
- results[r.index] = this.createBatchResult(ids[r.index], false, res.error ?? "Batch delete failed");
2263
- }
2264
- }
2265
- else {
2266
- const statuses = res.output
2267
- .split(RECORD_SEP)
2268
- .map((s) => s.trim())
2269
- .filter((s) => s.length > 0);
2270
- runnable.forEach((r, k) => {
2271
- results[r.index] = this.mapBatchStatus(ids[r.index], statuses[k], "delete");
2272
- });
2273
- }
2274
- }
2275
- return results;
2276
- }
2277
- /**
2278
- * Maps a per-item status token emitted by a batch AppleScript loop to a
2279
- * BatchResult, preserving the human-readable error messages of the original
2280
- * per-note implementation. See {@link batchDeleteNotes} / {@link batchMoveNotes}.
2281
- */
2282
- mapBatchStatus(id, status, op) {
2283
- switch (status) {
2284
- case "ok":
2285
- return this.createBatchResult(id, true);
2286
- case "pw":
2287
- return this.createBatchResult(id, false, "Note is password-protected");
2288
- case "missing":
2289
- return this.createBatchResult(id, false, "Note not found");
2290
- case "fail":
2291
- return this.createBatchResult(id, false, op === "delete" ? "Deletion failed" : "Move failed");
2292
- default:
2293
- return this.createBatchResult(id, false, "Unknown error");
2294
- }
2295
- }
2296
- /**
2297
- * Moves multiple notes to a folder by their IDs.
2298
- *
2299
- * Each move is attempted independently; failures don't stop other moves.
2300
- * Returns results for each note indicating success or failure.
2301
- *
2302
- * @param ids - Array of CoreData URL identifiers for notes to move
2303
- * @param folder - Destination folder name
2304
- * @param account - Account containing the folder (defaults to iCloud)
2305
- * @returns Array of results with id, success status, and optional error message
2306
- *
2307
- * @example
2308
- * ```typescript
2309
- * const results = manager.batchMoveNotes(
2310
- * ["x-coredata://ABC/ICNote/p1", "x-coredata://ABC/ICNote/p2"],
2311
- * "Archive"
2312
- * );
2313
- * ```
2314
- */
2315
- batchMoveNotes(ids, folder, account) {
2316
- if (ids.length === 0)
2317
- return [];
2318
- // Collapse the whole batch into ONE osascript spawn (#26). The old path
2319
- // spawned 5+ processes per note (getNoteById + isNotePasswordProtectedById +
2320
- // moveNoteById's copy-then-delete fan-out). This uses the native `move`
2321
- // command — which preserves the note's identity and metadata rather than
2322
- // copy+delete — inside a single app-level loop with per-id `try` isolation.
2323
- const targetAccount = this.resolveAccount(account);
2324
- const safeAccount = sanitizeAccountName(targetAccount);
2325
- // buildFolderReference validates the (single, shared) destination path; a
2326
- // malformed folder is a precondition error for the whole call, so let it throw.
2327
- const destFolderRef = `${buildFolderReference(folder)} of account "${safeAccount}"`;
2328
- const results = new Array(ids.length);
2329
- const runnable = [];
2330
- ids.forEach((id, i) => {
2331
- try {
2332
- runnable.push({ index: i, safe: sanitizeId(id) });
2333
- }
2334
- catch (e) {
2335
- results[i] = this.createBatchResult(id, false, e instanceof Error ? e.message : "Invalid note ID");
2336
- }
2337
- });
2338
- if (runnable.length > 0) {
2339
- const idList = runnable.map((r) => `"${r.safe}"`).join(", ");
2340
- const script = buildAppLevelScript(`
2341
- set destFolder to ${destFolderRef}
2342
- set out to ""
2343
- repeat with rawId in {${idList}}
2344
- set theId to (rawId as text)
2345
- set noteRef to missing value
2346
- try
2347
- set noteRef to note id theId
2348
- end try
2349
- if noteRef is missing value then
2350
- set out to out & "missing" & ${AS_RECORD_SEP}
2351
- else
2352
- set isPw to false
2353
- try
2354
- set isPw to (password protected of noteRef)
2355
- end try
2356
- if isPw then
2357
- set out to out & "pw" & ${AS_RECORD_SEP}
2358
- else
2359
- try
2360
- move noteRef to destFolder
2361
- set out to out & "ok" & ${AS_RECORD_SEP}
2362
- on error
2363
- set out to out & "fail" & ${AS_RECORD_SEP}
2364
- end try
2365
- end if
2366
- end if
2367
- end repeat
2368
- return out
2369
- `);
2370
- const res = executeAppleScript(script);
2371
- if (!res.success) {
2372
- // Whole-batch failure (e.g. destination folder unresolved, Notes not
2373
- // responding): can't isolate, so fail every runnable note.
2374
- for (const r of runnable) {
2375
- results[r.index] = this.createBatchResult(ids[r.index], false, res.error ?? "Batch move failed");
2376
- }
2377
- }
2378
- else {
2379
- const statuses = res.output
2380
- .split(RECORD_SEP)
2381
- .map((s) => s.trim())
2382
- .filter((s) => s.length > 0);
2383
- runnable.forEach((r, k) => {
2384
- results[r.index] = this.mapBatchStatus(ids[r.index], statuses[k], "move");
2385
- });
2386
- }
2387
- }
2388
- return results;
2389
- }
2390
- // ===========================================================================
2391
- // Export Operations
2392
- // ===========================================================================
2393
- /**
2394
- * Export structure for a single note.
2395
- */
2396
- exportNote(note, content) {
2397
- return {
2398
- id: note.id,
2399
- title: note.title,
2400
- content: content,
2401
- plaintext: this.htmlToPlaintext(content),
2402
- folder: note.folder || "Notes",
2403
- account: note.account || "iCloud",
2404
- created: note.created.toISOString(),
2405
- modified: note.modified.toISOString(),
2406
- shared: note.shared || false,
2407
- passwordProtected: note.passwordProtected || false,
2408
- };
2409
- }
2410
- /**
2411
- * Simple HTML to plaintext conversion for export.
2412
- */
2413
- htmlToPlaintext(html) {
2414
- // Convert block/line breaks to newlines first.
2415
- let text = html
2416
- .replace(/<br\s*\/?>/gi, "\n")
2417
- .replace(/<\/div>/gi, "\n")
2418
- .replace(/<\/p>/gi, "\n");
2419
- // Strip any remaining tags, looping until the string stabilizes. A single
2420
- // pass can leave residue when removing one tag re-forms another (e.g.
2421
- // "<<i>>"), so we repeat until there are no more matches — the recognized
2422
- // fix for CodeQL js/incomplete-multi-character-sanitization.
2423
- let prev;
2424
- do {
2425
- prev = text;
2426
- text = text.replace(/<[^>]*>/g, "");
2427
- } while (text !== prev);
2428
- return (text
2429
- .replace(/&nbsp;/g, " ")
2430
- .replace(/&lt;/g, "<")
2431
- .replace(/&gt;/g, ">")
2432
- .replace(/&quot;/g, '"')
2433
- .replace(/&#92;/g, "\\")
2434
- // Decode &amp; LAST so an encoded entity like "&amp;lt;" round-trips to the
2435
- // literal "&lt;" instead of being double-unescaped to "<".
2436
- .replace(/&amp;/g, "&")
2437
- .replace(/\n{3,}/g, "\n\n")
2438
- .trim());
2439
- }
2440
- /**
2441
- * Exports all notes as a JSON structure for backup/migration.
2442
- *
2443
- * Exports complete note data including:
2444
- * - Metadata (id, title, dates, flags)
2445
- * - Content (HTML and plaintext)
2446
- * - Organization (folder, account)
2447
- *
2448
- * Note: Password-protected notes are included with metadata only (no content).
2449
- *
2450
- * @returns JSON-serializable export object
2451
- *
2452
- * @example
2453
- * ```typescript
2454
- * const snapshot = manager.exportNotesAsJson();
2455
- * fs.writeFileSync('notes-backup.json', JSON.stringify(snapshot, null, 2));
2456
- * ```
2457
- */
2458
- exportNotesAsJson() {
2459
- const accounts = this.listAccounts();
2460
- const exportData = {
2461
- exportDate: new Date().toISOString(),
2462
- version: "1.0",
2463
- accounts: [],
2464
- summary: { totalNotes: 0, totalFolders: 0, totalAccounts: accounts.length },
2465
- };
2466
- for (const account of accounts) {
2467
- const folders = this.listFolders(account.name);
2468
- const accountData = {
2469
- name: account.name,
2470
- folders: [],
2471
- };
2472
- for (const folder of folders) {
2473
- const folderData = {
2474
- name: folder.name,
2475
- notes: [],
2476
- };
2477
- // Get all note titles in this folder
2478
- const noteTitles = this.listNotes(account.name, folder.name);
2479
- for (const title of noteTitles) {
2480
- // Get note details
2481
- const note = this.getNoteDetails(title, account.name);
2482
- if (!note)
2483
- continue;
2484
- // Skip password-protected notes' content but include metadata
2485
- let content = "";
2486
- if (!note.passwordProtected) {
2487
- content = this.getNoteContent(title, account.name);
2488
- }
2489
- folderData.notes.push(this.exportNote(note, content));
2490
- exportData.summary.totalNotes++;
2491
- }
2492
- accountData.folders.push(folderData);
2493
- exportData.summary.totalFolders++;
2494
- }
2495
- exportData.accounts.push(accountData);
2496
- }
2497
- return exportData;
2498
- }
2499
- // ===========================================================================
2500
- // Markdown Conversion
2501
- // ===========================================================================
2502
- /**
2503
- * Turndown service instance for HTML to Markdown conversion.
2504
- * Configured for Apple Notes HTML quirks.
2505
- * Initialized lazily on first use.
2506
- */
2507
- turndownService;
2508
- /**
2509
- * Initialize the Turndown service with Apple Notes-specific rules.
2510
- */
2511
- initTurndownService() {
2512
- if (this.turndownService)
2513
- return;
2514
- this.turndownService = new TurndownService({
2515
- headingStyle: "atx",
2516
- codeBlockStyle: "fenced",
2517
- bulletListMarker: "-",
2518
- });
2519
- // Handle Apple Notes-specific HTML patterns
2520
- // Notes.app uses <div> instead of <p> for paragraphs
2521
- this.turndownService.addRule("notesDivs", {
2522
- filter: "div",
2523
- replacement: (content) => {
2524
- return content + "\n";
2525
- },
2526
- });
2527
- }
2528
- /**
2529
- * Converts HTML content to Markdown.
2530
- *
2531
- * @param html - HTML content from Notes.app
2532
- * @returns Markdown formatted content
2533
- */
2534
- htmlToMarkdown(html) {
2535
- this.initTurndownService();
2536
- return this.turndownService.turndown(html).trim();
2537
- }
2538
- /**
2539
- * Enriches markdown with checklist state from the NoteStore database.
2540
- *
2541
- * Apple Notes checklists appear as plain list items in the AppleScript HTML
2542
- * output. This method reads the protobuf data to get done/undone state and
2543
- * annotates matching list items with [x] or [ ] prefixes.
2544
- *
2545
- * Fails silently (returns original markdown) if the database is inaccessible
2546
- * or the note has no checklists.
2547
- *
2548
- * @param markdown - The base markdown content
2549
- * @param checklistItems - Checklist items with done state
2550
- * @returns Markdown with checklist annotations
2551
- */
2552
- enrichMarkdownWithChecklists(markdown, checklistItems) {
2553
- if (checklistItems.length === 0)
2554
- return markdown;
2555
- // Build a map of checklist text → done state
2556
- const checklistMap = new Map();
2557
- for (const item of checklistItems) {
2558
- checklistMap.set(item.text.trim(), item.done);
2559
- }
2560
- // Replace matching list items with checkbox syntax
2561
- const lines = markdown.split("\n");
2562
- const enriched = lines.map((line) => {
2563
- // Match markdown list items: "- text" or "* text"
2564
- const listMatch = line.match(/^(\s*[-*])\s+(.+)$/);
2565
- if (!listMatch)
2566
- return line;
2567
- const [, prefix, text] = listMatch;
2568
- const done = checklistMap.get(text.trim());
2569
- if (done === undefined)
2570
- return line;
2571
- // Remove from map so duplicate text lines aren't all converted
2572
- checklistMap.delete(text.trim());
2573
- return `${prefix} ${done ? "[x]" : "[ ]"} ${text}`;
2574
- });
2575
- return enriched.join("\n");
2576
- }
2577
- /**
2578
- * Gets note content as Markdown by title.
2579
- *
2580
- * If the note contains checklists and the NoteStore database is accessible
2581
- * (Full Disk Access required), checklist items will be annotated with
2582
- * [x] (done) or [ ] (undone) prefixes.
2583
- *
2584
- * @param title - Exact title of the note
2585
- * @param account - Account containing the note (defaults to iCloud)
2586
- * @returns Markdown content, or empty string if not found
2587
- *
2588
- * @example
2589
- * ```typescript
2590
- * const md = manager.getNoteMarkdown("Shopping List");
2591
- * console.log(md); // "# Shopping List\n\n- [x] Eggs\n- [ ] Milk"
2592
- * ```
2593
- */
2594
- getNoteMarkdown(title, account) {
2595
- const html = this.getNoteContent(title, account);
2596
- if (!html)
2597
- return "";
2598
- let markdown = this.htmlToMarkdown(html);
2599
- // Try to enrich with checklist state (requires note ID)
2600
- const note = this.getNoteDetails(title, account);
2601
- if (note?.id) {
2602
- const result = getChecklistItems(note.id);
2603
- if (result.items) {
2604
- markdown = this.enrichMarkdownWithChecklists(markdown, result.items);
2605
- }
2606
- }
2607
- return markdown;
2608
- }
2609
- /**
2610
- * Gets note content as Markdown by ID.
2611
- *
2612
- * This is more reliable than getNoteMarkdown() because IDs are unique
2613
- * across all accounts, while titles can be duplicated.
2614
- *
2615
- * If the note contains checklists and the NoteStore database is accessible
2616
- * (Full Disk Access required), checklist items will be annotated with
2617
- * [x] (done) or [ ] (undone) prefixes.
2618
- *
2619
- * @param id - CoreData URL identifier for the note
2620
- * @returns Markdown content, or empty string if not found
2621
- */
2622
- getNoteMarkdownById(id) {
2623
- const html = this.getNoteContentById(id);
2624
- if (!html)
2625
- return "";
2626
- let markdown = this.htmlToMarkdown(html);
2627
- // Try to enrich with checklist state
2628
- const result = getChecklistItems(id);
2629
- if (result.items) {
2630
- markdown = this.enrichMarkdownWithChecklists(markdown, result.items);
2631
- }
2632
- return markdown;
2633
- }
2634
- }