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.
- package/LICENSE +21 -0
- package/README.md +522 -0
- package/build/index.js +289 -0
- package/build/index.test.js +446 -0
- package/build/services/appleNotesManager.js +720 -0
- package/build/services/appleNotesManager.test.js +684 -0
- package/build/types.js +13 -0
- package/build/utils/applescript.js +141 -0
- package/build/utils/applescript.test.js +129 -0
- package/package.json +70 -0
|
@@ -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 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
|
+
}
|