apple-notes-mcp 1.1.0

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.
@@ -0,0 +1,720 @@
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
+ // =============================================================================
19
+ // Text Processing Utilities
20
+ // =============================================================================
21
+ /**
22
+ * Escapes text for safe embedding in AppleScript string literals.
23
+ *
24
+ * AppleScript strings use double quotes, so we need to escape:
25
+ * 1. Double quotes (") - escaped as \"
26
+ * 2. Backslashes (\) - already handled by shell escaping
27
+ *
28
+ * Additionally, since our AppleScript is passed through the shell via
29
+ * `osascript -e '...'`, we need to handle single quotes in the content.
30
+ *
31
+ * Finally, Apple Notes uses HTML internally, so we convert control
32
+ * characters to their HTML equivalents.
33
+ *
34
+ * @param text - Raw text to escape
35
+ * @returns Text safe for AppleScript string embedding
36
+ *
37
+ * @example
38
+ * escapeForAppleScript("Hello \"World\"")
39
+ * // Returns: Hello \"World\"
40
+ *
41
+ * escapeForAppleScript("Line 1\nLine 2")
42
+ * // Returns: Line 1<br>Line 2
43
+ */
44
+ export function escapeForAppleScript(text) {
45
+ // Guard against null/undefined - return empty string
46
+ if (!text) {
47
+ return "";
48
+ }
49
+ // Step 1: Escape single quotes for shell embedding
50
+ // When we run: osascript -e 'tell app...'
51
+ // Any single quotes in the script need special handling
52
+ // Pattern: ' becomes '\'' (end quote, escaped quote, begin quote)
53
+ let escaped = text.replace(/'/g, "'\\''");
54
+ // Step 2: Escape double quotes for AppleScript strings
55
+ // AppleScript uses: "hello \"quoted\" world"
56
+ escaped = escaped.replace(/"/g, '\\"');
57
+ // Step 3: Convert control characters to HTML for Notes.app
58
+ // Apple Notes stores content as HTML, so we convert:
59
+ // - Newlines (\n) to <br> tags
60
+ // - Tabs (\t) to <br> tags (better than &nbsp; for readability)
61
+ escaped = escaped.replace(/\n/g, "<br>");
62
+ escaped = escaped.replace(/\t/g, "<br>");
63
+ return escaped;
64
+ }
65
+ /**
66
+ * Converts AppleScript date representation to JavaScript Date.
67
+ *
68
+ * AppleScript returns dates in a verbose format like:
69
+ * "date Saturday, December 27, 2025 at 3:44:02 PM"
70
+ *
71
+ * This function extracts the parseable portion and converts it
72
+ * to a JavaScript Date object.
73
+ *
74
+ * @param appleScriptDate - Date string from AppleScript
75
+ * @returns Parsed Date, or current date if parsing fails
76
+ *
77
+ * @example
78
+ * parseAppleScriptDate("date Saturday, December 27, 2025 at 3:44:02 PM")
79
+ * // Returns: Date object for Dec 27, 2025 3:44:02 PM
80
+ */
81
+ export function parseAppleScriptDate(appleScriptDate) {
82
+ // Remove the "date " prefix if present
83
+ const withoutPrefix = appleScriptDate.replace(/^date\s+/, "");
84
+ // Replace " at " with a space for standard date parsing
85
+ // "Saturday, December 27, 2025 at 3:44:02 PM" ->
86
+ // "Saturday, December 27, 2025 3:44:02 PM"
87
+ const normalized = withoutPrefix.replace(" at ", " ");
88
+ // Attempt to parse - JavaScript's Date constructor handles this format
89
+ const parsed = new Date(normalized);
90
+ // Return parsed date if valid, otherwise current date as fallback
91
+ return isNaN(parsed.getTime()) ? new Date() : parsed;
92
+ }
93
+ /**
94
+ * Builds an AppleScript command wrapped in account context.
95
+ *
96
+ * Most Notes.app operations need to be scoped to an account:
97
+ * ```applescript
98
+ * tell application "Notes"
99
+ * tell account "iCloud"
100
+ * -- command here
101
+ * end tell
102
+ * end tell
103
+ * ```
104
+ *
105
+ * This builder generates that wrapper structure.
106
+ *
107
+ * @param scope - Account to target
108
+ * @param command - The AppleScript command to execute
109
+ * @returns Complete AppleScript ready for execution
110
+ */
111
+ function buildAccountScopedScript(scope, command) {
112
+ return `
113
+ tell application "Notes"
114
+ tell account "${scope.account}"
115
+ ${command}
116
+ end tell
117
+ end tell
118
+ `;
119
+ }
120
+ /**
121
+ * Builds an AppleScript command at the application level.
122
+ *
123
+ * Some operations (like listing accounts) don't need account scoping:
124
+ * ```applescript
125
+ * tell application "Notes"
126
+ * -- command here
127
+ * end tell
128
+ * ```
129
+ *
130
+ * @param command - The AppleScript command to execute
131
+ * @returns Complete AppleScript ready for execution
132
+ */
133
+ function buildAppLevelScript(command) {
134
+ return `
135
+ tell application "Notes"
136
+ ${command}
137
+ end tell
138
+ `;
139
+ }
140
+ // =============================================================================
141
+ // Result Parsing Utilities
142
+ // =============================================================================
143
+ /**
144
+ * Parses a comma-separated list from AppleScript output.
145
+ *
146
+ * AppleScript often returns lists as comma-separated strings:
147
+ * "Note 1, Note 2, Note 3"
148
+ *
149
+ * This function splits and cleans the output.
150
+ *
151
+ * @param output - Raw AppleScript output
152
+ * @returns Array of trimmed, non-empty strings
153
+ */
154
+ function parseCommaSeparatedList(output) {
155
+ return output
156
+ .split(",")
157
+ .map((item) => item.trim())
158
+ .filter((item) => item.length > 0);
159
+ }
160
+ /**
161
+ * Extracts a CoreData ID from AppleScript output.
162
+ *
163
+ * Notes.app uses CoreData URLs as unique identifiers:
164
+ * "note id x-coredata://ABC123-DEF456/ICNote/p789"
165
+ *
166
+ * This function extracts the ID portion.
167
+ *
168
+ * @param output - AppleScript output containing an ID reference
169
+ * @param prefix - The object type prefix (e.g., "note", "folder")
170
+ * @returns Extracted ID or empty string
171
+ */
172
+ function extractCoreDataId(output, prefix) {
173
+ const pattern = new RegExp(`${prefix} id ([^\\s]+)`);
174
+ const match = output.match(pattern);
175
+ return match ? match[1] : "";
176
+ }
177
+ // =============================================================================
178
+ // Apple Notes Manager Class
179
+ // =============================================================================
180
+ /**
181
+ * Manages interactions with Apple Notes via AppleScript.
182
+ *
183
+ * This class provides a high-level TypeScript interface for all
184
+ * Notes.app operations. It handles:
185
+ *
186
+ * - Note CRUD operations (create, read, update, delete)
187
+ * - Note organization (folders, moving between folders)
188
+ * - Multi-account support (iCloud, Gmail, Exchange, etc.)
189
+ * - Search functionality (by title or content)
190
+ *
191
+ * All operations are synchronous since they rely on AppleScript
192
+ * execution via osascript. Error handling is consistent: methods
193
+ * return null/false/empty-array on failure rather than throwing.
194
+ *
195
+ * @example
196
+ * ```typescript
197
+ * const notes = new AppleNotesManager();
198
+ *
199
+ * // Create a note in the default (iCloud) account
200
+ * const note = notes.createNote("Shopping List", "Eggs, Milk, Bread");
201
+ *
202
+ * // Search across all notes
203
+ * const results = notes.searchNotes("shopping", true); // searches content
204
+ *
205
+ * // Work with a different account
206
+ * const gmailNotes = notes.listNotes("Gmail");
207
+ * ```
208
+ */
209
+ export class AppleNotesManager {
210
+ /**
211
+ * Default account used when no account is specified.
212
+ * iCloud is the primary account for most Apple Notes users.
213
+ */
214
+ defaultAccount = "iCloud";
215
+ /**
216
+ * Resolves the account to use for an operation.
217
+ * Falls back to default if not specified.
218
+ */
219
+ resolveAccount(account) {
220
+ return account || this.defaultAccount;
221
+ }
222
+ // ===========================================================================
223
+ // Note Operations
224
+ // ===========================================================================
225
+ /**
226
+ * Creates a new note in Apple Notes.
227
+ *
228
+ * The note is created with the specified title and content. If a folder
229
+ * is specified, the note is created in that folder; otherwise it goes
230
+ * to the account's default location.
231
+ *
232
+ * @param title - Display title for the note
233
+ * @param content - Body content (plain text, will be HTML-escaped)
234
+ * @param tags - Optional tags (stored in returned object, not used by Notes.app)
235
+ * @param folder - Optional folder name to create the note in
236
+ * @param account - Account to use (defaults to iCloud)
237
+ * @returns Created Note object with metadata, or null on failure
238
+ *
239
+ * @example
240
+ * ```typescript
241
+ * // Simple note creation
242
+ * const note = manager.createNote("Meeting Notes", "Discussed Q4 plans");
243
+ *
244
+ * // Create in a specific folder
245
+ * const work = manager.createNote("Task List", "1. Review PR", [], "Work");
246
+ *
247
+ * // Create in a different account
248
+ * const gmail = manager.createNote("Draft", "...", [], undefined, "Gmail");
249
+ * ```
250
+ */
251
+ createNote(title, content, tags = [], folder, account) {
252
+ const targetAccount = this.resolveAccount(account);
253
+ // Escape content for AppleScript embedding
254
+ const safeTitle = escapeForAppleScript(title);
255
+ const safeContent = escapeForAppleScript(content);
256
+ // Build the AppleScript command
257
+ // Notes.app uses 'name' for the title and 'body' for content
258
+ let createCommand;
259
+ if (folder) {
260
+ // Create note in specific folder
261
+ const safeFolder = escapeForAppleScript(folder);
262
+ createCommand = `make new note at folder "${safeFolder}" with properties {name:"${safeTitle}", body:"${safeContent}"}`;
263
+ }
264
+ else {
265
+ // Create note in default location
266
+ createCommand = `make new note with properties {name:"${safeTitle}", body:"${safeContent}"}`;
267
+ }
268
+ // Execute the script
269
+ const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
270
+ const result = executeAppleScript(script);
271
+ if (!result.success) {
272
+ console.error(`Failed to create note "${title}":`, result.error);
273
+ return null;
274
+ }
275
+ // Return a Note object representing the created note
276
+ // Note: We use a timestamp as ID since we can't easily get the real ID
277
+ const now = new Date();
278
+ return {
279
+ id: Date.now().toString(),
280
+ title,
281
+ content,
282
+ tags,
283
+ created: now,
284
+ modified: now,
285
+ folder,
286
+ account: targetAccount,
287
+ };
288
+ }
289
+ /**
290
+ * Searches for notes matching a query.
291
+ *
292
+ * By default, searches note titles. Set searchContent=true to search
293
+ * the body text instead.
294
+ *
295
+ * @param query - Text to search for
296
+ * @param searchContent - If true, search note bodies; if false, search titles
297
+ * @param account - Account to search in (defaults to iCloud)
298
+ * @returns Array of matching notes (with minimal metadata)
299
+ *
300
+ * @example
301
+ * ```typescript
302
+ * // Search by title
303
+ * const meetingNotes = manager.searchNotes("meeting");
304
+ *
305
+ * // Search in note content
306
+ * const projectRefs = manager.searchNotes("Project Alpha", true);
307
+ * ```
308
+ */
309
+ searchNotes(query, searchContent = false, account) {
310
+ const targetAccount = this.resolveAccount(account);
311
+ const safeQuery = escapeForAppleScript(query);
312
+ // Build the where clause based on search type
313
+ // AppleScript uses 'name' for title and 'body' for content
314
+ const whereClause = searchContent
315
+ ? `body contains "${safeQuery}"`
316
+ : `name contains "${safeQuery}"`;
317
+ // Get names and folder for each matching note
318
+ // We use a repeat loop to get both properties, separated by a delimiter
319
+ const searchCommand = `
320
+ set matchingNotes to notes where ${whereClause}
321
+ set resultList to {}
322
+ repeat with n in matchingNotes
323
+ set noteName to name of n
324
+ set noteFolder to name of container of n
325
+ set end of resultList to noteName & "|||" & noteFolder
326
+ end repeat
327
+ set AppleScript's text item delimiters to "|||ITEM|||"
328
+ return resultList as text
329
+ `;
330
+ const script = buildAccountScopedScript({ account: targetAccount }, searchCommand);
331
+ const result = executeAppleScript(script);
332
+ if (!result.success) {
333
+ console.error(`Failed to search notes for "${query}":`, result.error);
334
+ return [];
335
+ }
336
+ // Handle empty results
337
+ if (!result.output.trim()) {
338
+ return [];
339
+ }
340
+ // Parse the delimited output: "name|||folder|||ITEM|||name|||folder..."
341
+ const items = result.output.split("|||ITEM|||");
342
+ const notes = [];
343
+ for (const item of items) {
344
+ const [title, folder] = item.split("|||");
345
+ if (!title?.trim())
346
+ continue;
347
+ notes.push({
348
+ id: Date.now().toString(),
349
+ title: title.trim(),
350
+ content: "", // Not fetched in search
351
+ tags: [],
352
+ created: new Date(),
353
+ modified: new Date(),
354
+ folder: folder?.trim(),
355
+ account: targetAccount,
356
+ });
357
+ }
358
+ return notes;
359
+ }
360
+ /**
361
+ * Retrieves the HTML content of a note by its title.
362
+ *
363
+ * @param title - Exact title of the note
364
+ * @param account - Account to search in (defaults to iCloud)
365
+ * @returns HTML content of the note, or empty string if not found
366
+ *
367
+ * @example
368
+ * ```typescript
369
+ * const content = manager.getNoteContent("Shopping List");
370
+ * if (content) {
371
+ * console.log("Note found:", content);
372
+ * }
373
+ * ```
374
+ */
375
+ getNoteContent(title, account) {
376
+ const targetAccount = this.resolveAccount(account);
377
+ const safeTitle = escapeForAppleScript(title);
378
+ // Retrieve the body property of the note
379
+ const getCommand = `get body of note "${safeTitle}"`;
380
+ const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
381
+ const result = executeAppleScript(script);
382
+ if (!result.success) {
383
+ console.error(`Failed to get content of note "${title}":`, result.error);
384
+ return "";
385
+ }
386
+ return result.output;
387
+ }
388
+ /**
389
+ * Retrieves a note by its unique CoreData ID.
390
+ *
391
+ * Each note has a unique ID in the format:
392
+ * "x-coredata://DEVICE-UUID/ICNote/pXXXX"
393
+ *
394
+ * This method fetches the note and its metadata using this ID.
395
+ *
396
+ * @param id - CoreData URL identifier for the note
397
+ * @returns Note object with metadata, or null if not found
398
+ */
399
+ getNoteById(id) {
400
+ // Note IDs work at the application level, not scoped to account
401
+ const getCommand = `
402
+ set n to note id "${id}"
403
+ set noteProps to {name of n, id of n, creation date of n, modification date of n, shared of n, password protected of n}
404
+ return noteProps
405
+ `;
406
+ const script = buildAppLevelScript(getCommand);
407
+ const result = executeAppleScript(script);
408
+ if (!result.success) {
409
+ console.error(`Failed to get note with ID "${id}":`, result.error);
410
+ return null;
411
+ }
412
+ // Parse the complex output
413
+ // AppleScript returns: "title, id, date DayName, Month Day, Year at Time, date..., bool, bool"
414
+ // Dates contain commas, so we can't use simple CSV parsing
415
+ const output = result.output;
416
+ // Extract dates using regex - they start with "date " and end before the next comma-space-lowercase
417
+ // Pattern matches: "date Saturday, December 27, 2025 at 3:44:02 PM"
418
+ const dateMatches = output.match(/date [A-Z][^,]*(?:, [A-Z][^,]*)* at \d+:\d+:\d+ [AP]M/g) || [];
419
+ // Extract title (everything before the first comma)
420
+ const firstComma = output.indexOf(",");
421
+ if (firstComma === -1) {
422
+ console.error("Unexpected response format when getting note by ID");
423
+ return null;
424
+ }
425
+ const extractedTitle = output.substring(0, firstComma).trim();
426
+ // Extract ID (between first and second comma)
427
+ const afterTitle = output.substring(firstComma + 1);
428
+ const secondComma = afterTitle.indexOf(",");
429
+ if (secondComma === -1) {
430
+ console.error("Unexpected response format when getting note by ID");
431
+ return null;
432
+ }
433
+ const extractedId = afterTitle.substring(0, secondComma).trim();
434
+ // Extract boolean values from the end (shared, passwordProtected)
435
+ // They appear as ", true" or ", false" at the end
436
+ const boolPattern = /, (true|false), (true|false)$/;
437
+ const boolMatch = output.match(boolPattern);
438
+ const shared = boolMatch ? boolMatch[1] === "true" : false;
439
+ const passwordProtected = boolMatch ? boolMatch[2] === "true" : false;
440
+ return {
441
+ id: extractedId,
442
+ title: extractedTitle,
443
+ content: "", // Not fetched to keep response small
444
+ tags: [],
445
+ created: dateMatches[0] ? parseAppleScriptDate(dateMatches[0]) : new Date(),
446
+ modified: dateMatches[1] ? parseAppleScriptDate(dateMatches[1]) : new Date(),
447
+ shared,
448
+ passwordProtected,
449
+ };
450
+ }
451
+ /**
452
+ * Retrieves detailed metadata for a note by title.
453
+ *
454
+ * Similar to getNoteContent but returns structured metadata
455
+ * including creation date, modification date, and sharing status.
456
+ *
457
+ * @param title - Exact title of the note
458
+ * @param account - Account to search in (defaults to iCloud)
459
+ * @returns Note object with full metadata, or null if not found
460
+ */
461
+ getNoteDetails(title, account) {
462
+ const targetAccount = this.resolveAccount(account);
463
+ const safeTitle = escapeForAppleScript(title);
464
+ // Fetch multiple properties at once
465
+ const getCommand = `
466
+ set n to note "${safeTitle}"
467
+ set noteProps to {name of n, id of n, creation date of n, modification date of n, shared of n, password protected of n}
468
+ return noteProps
469
+ `;
470
+ const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
471
+ const result = executeAppleScript(script);
472
+ if (!result.success) {
473
+ console.error(`Failed to get details for note "${title}":`, result.error);
474
+ return null;
475
+ }
476
+ // Parse the complex output
477
+ // The output contains embedded date objects that complicate simple CSV parsing
478
+ const output = result.output;
479
+ // Extract dates using regex (they have a recognizable format)
480
+ const dateMatches = output.match(/date [^,]+/g) || [];
481
+ // Extract title and ID from the beginning
482
+ const firstComma = output.indexOf(",");
483
+ const extractedTitle = output.substring(0, firstComma).trim();
484
+ const afterTitle = output.substring(firstComma + 1);
485
+ const secondComma = afterTitle.indexOf(",");
486
+ const extractedId = afterTitle.substring(0, secondComma).trim();
487
+ return {
488
+ id: extractedId,
489
+ title: extractedTitle,
490
+ content: "", // Not fetched
491
+ tags: [],
492
+ created: dateMatches[0] ? parseAppleScriptDate(dateMatches[0]) : new Date(),
493
+ modified: dateMatches[1] ? parseAppleScriptDate(dateMatches[1]) : new Date(),
494
+ shared: output.includes("true"),
495
+ passwordProtected: false, // Difficult to parse reliably
496
+ account: targetAccount,
497
+ };
498
+ }
499
+ /**
500
+ * Deletes a note by its title.
501
+ *
502
+ * Note: This permanently deletes the note. It may be recoverable
503
+ * from the "Recently Deleted" folder in Notes.app.
504
+ *
505
+ * @param title - Exact title of the note to delete
506
+ * @param account - Account containing the note (defaults to iCloud)
507
+ * @returns true if deletion succeeded, false otherwise
508
+ */
509
+ deleteNote(title, account) {
510
+ const targetAccount = this.resolveAccount(account);
511
+ const safeTitle = escapeForAppleScript(title);
512
+ const deleteCommand = `delete note "${safeTitle}"`;
513
+ const script = buildAccountScopedScript({ account: targetAccount }, deleteCommand);
514
+ const result = executeAppleScript(script);
515
+ if (!result.success) {
516
+ console.error(`Failed to delete note "${title}":`, result.error);
517
+ return false;
518
+ }
519
+ return true;
520
+ }
521
+ /**
522
+ * Updates an existing note's content and optionally its title.
523
+ *
524
+ * Apple Notes derives the title from the first line of the body,
525
+ * so updating content also allows title changes. If newTitle is
526
+ * not provided, the original title is preserved.
527
+ *
528
+ * @param title - Current title of the note to update
529
+ * @param newTitle - New title (optional, keeps existing if not provided)
530
+ * @param newContent - New content for the note body
531
+ * @param account - Account containing the note (defaults to iCloud)
532
+ * @returns true if update succeeded, false otherwise
533
+ */
534
+ updateNote(title, newTitle, newContent, account) {
535
+ const targetAccount = this.resolveAccount(account);
536
+ const safeCurrentTitle = escapeForAppleScript(title);
537
+ // Determine the effective title (new or keep existing)
538
+ const effectiveTitle = newTitle || title;
539
+ const safeEffectiveTitle = escapeForAppleScript(effectiveTitle);
540
+ const safeContent = escapeForAppleScript(newContent);
541
+ // Apple Notes uses HTML body; first <div> becomes the title
542
+ const fullBody = `<div>${safeEffectiveTitle}</div><div>${safeContent}</div>`;
543
+ const updateCommand = `set body of note "${safeCurrentTitle}" to "${fullBody}"`;
544
+ const script = buildAccountScopedScript({ account: targetAccount }, updateCommand);
545
+ const result = executeAppleScript(script);
546
+ if (!result.success) {
547
+ console.error(`Failed to update note "${title}":`, result.error);
548
+ return false;
549
+ }
550
+ return true;
551
+ }
552
+ /**
553
+ * Lists all notes in an account, optionally filtered by folder.
554
+ *
555
+ * @param account - Account to list notes from (defaults to iCloud)
556
+ * @param folder - Optional folder to filter by
557
+ * @returns Array of note titles
558
+ */
559
+ listNotes(account, folder) {
560
+ const targetAccount = this.resolveAccount(account);
561
+ // Build command based on whether folder filter is specified
562
+ let listCommand;
563
+ if (folder) {
564
+ const safeFolder = escapeForAppleScript(folder);
565
+ listCommand = `get name of notes of folder "${safeFolder}"`;
566
+ }
567
+ else {
568
+ listCommand = `get name of notes`;
569
+ }
570
+ const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
571
+ const result = executeAppleScript(script);
572
+ if (!result.success) {
573
+ console.error("Failed to list notes:", result.error);
574
+ return [];
575
+ }
576
+ return parseCommaSeparatedList(result.output);
577
+ }
578
+ // ===========================================================================
579
+ // Folder Operations
580
+ // ===========================================================================
581
+ /**
582
+ * Lists all folders in an account.
583
+ *
584
+ * @param account - Account to list folders from (defaults to iCloud)
585
+ * @returns Array of Folder objects
586
+ */
587
+ listFolders(account) {
588
+ const targetAccount = this.resolveAccount(account);
589
+ // Get folder names (simpler than getting full objects)
590
+ const listCommand = `get name of folders`;
591
+ const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
592
+ const result = executeAppleScript(script);
593
+ if (!result.success) {
594
+ console.error("Failed to list folders:", result.error);
595
+ return [];
596
+ }
597
+ // Convert names to Folder objects
598
+ const names = parseCommaSeparatedList(result.output);
599
+ return names.map((name) => ({
600
+ id: "", // Would require additional query to get
601
+ name,
602
+ account: targetAccount,
603
+ }));
604
+ }
605
+ /**
606
+ * Creates a new folder in an account.
607
+ *
608
+ * @param name - Name for the new folder
609
+ * @param account - Account to create folder in (defaults to iCloud)
610
+ * @returns Created Folder object, or null on failure
611
+ */
612
+ createFolder(name, account) {
613
+ const targetAccount = this.resolveAccount(account);
614
+ const safeName = escapeForAppleScript(name);
615
+ const createCommand = `make new folder with properties {name:"${safeName}"}`;
616
+ const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
617
+ const result = executeAppleScript(script);
618
+ if (!result.success) {
619
+ console.error(`Failed to create folder "${name}":`, result.error);
620
+ return null;
621
+ }
622
+ // Extract the folder ID from the response
623
+ const folderId = extractCoreDataId(result.output, "folder");
624
+ return {
625
+ id: folderId,
626
+ name,
627
+ account: targetAccount,
628
+ };
629
+ }
630
+ /**
631
+ * Deletes a folder from an account.
632
+ *
633
+ * Note: This may fail if the folder contains notes.
634
+ *
635
+ * @param name - Name of the folder to delete
636
+ * @param account - Account containing the folder (defaults to iCloud)
637
+ * @returns true if deletion succeeded, false otherwise
638
+ */
639
+ deleteFolder(name, account) {
640
+ const targetAccount = this.resolveAccount(account);
641
+ const safeName = escapeForAppleScript(name);
642
+ const deleteCommand = `delete folder "${safeName}"`;
643
+ const script = buildAccountScopedScript({ account: targetAccount }, deleteCommand);
644
+ const result = executeAppleScript(script);
645
+ if (!result.success) {
646
+ console.error(`Failed to delete folder "${name}":`, result.error);
647
+ return false;
648
+ }
649
+ return true;
650
+ }
651
+ /**
652
+ * Moves a note to a different folder.
653
+ *
654
+ * Since AppleScript doesn't support direct note moves, this operation:
655
+ * 1. Retrieves the source note's content
656
+ * 2. Creates a new note with that content in the destination folder
657
+ * 3. Deletes the original note (only if copy succeeded)
658
+ *
659
+ * This ensures the note is never lost - if the copy fails, the
660
+ * original remains untouched. If only the delete fails, the note
661
+ * exists in the new location (success is still returned).
662
+ *
663
+ * @param title - Title of the note to move
664
+ * @param destinationFolder - Name of the folder to move to
665
+ * @param account - Account containing the note (defaults to iCloud)
666
+ * @returns true if move succeeded (or copy succeeded but delete failed)
667
+ */
668
+ moveNote(title, destinationFolder, account) {
669
+ const targetAccount = this.resolveAccount(account);
670
+ // Step 1: Retrieve the original note's content
671
+ const originalContent = this.getNoteContent(title, targetAccount);
672
+ if (!originalContent) {
673
+ console.error(`Cannot move note "${title}": failed to retrieve content`);
674
+ return false;
675
+ }
676
+ // Step 2: Create a copy in the destination folder
677
+ // We need to escape the HTML content for AppleScript embedding
678
+ const safeFolder = escapeForAppleScript(destinationFolder);
679
+ const safeContent = originalContent.replace(/"/g, '\\"').replace(/'/g, "'\\''");
680
+ const createCommand = `make new note at folder "${safeFolder}" with properties {body:"${safeContent}"}`;
681
+ const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
682
+ const copyResult = executeAppleScript(script);
683
+ if (!copyResult.success) {
684
+ console.error(`Cannot move note "${title}": failed to create in destination folder:`, copyResult.error);
685
+ return false;
686
+ }
687
+ // Step 3: Delete the original (only after successful copy)
688
+ const deleteSuccess = this.deleteNote(title, targetAccount);
689
+ if (!deleteSuccess) {
690
+ // The note was copied successfully but we couldn't delete the original.
691
+ // This is still a partial success - the note exists in the new location.
692
+ console.error(`Note "${title}" was copied to "${destinationFolder}" but original could not be deleted`);
693
+ return true;
694
+ }
695
+ return true;
696
+ }
697
+ // ===========================================================================
698
+ // Account Operations
699
+ // ===========================================================================
700
+ /**
701
+ * Lists all available Notes accounts.
702
+ *
703
+ * Common accounts include iCloud, Gmail, Exchange, and other
704
+ * email providers configured on the Mac.
705
+ *
706
+ * @returns Array of Account objects
707
+ */
708
+ listAccounts() {
709
+ const listCommand = `get name of accounts`;
710
+ const script = buildAppLevelScript(listCommand);
711
+ const result = executeAppleScript(script);
712
+ if (!result.success) {
713
+ console.error("Failed to list accounts:", result.error);
714
+ return [];
715
+ }
716
+ // Convert names to Account objects
717
+ const names = parseCommaSeparatedList(result.output);
718
+ return names.map((name) => ({ name }));
719
+ }
720
+ }