apple-notes-mcp 2.5.6 → 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 -1080
  3. package/package.json +3 -3
  4. package/build/index.test.js +0 -446
  5. package/build/services/__fixtures__/notesNormalizedHtml.js +0 -32
  6. package/build/services/appleNotesManager.js +0 -2629
  7. package/build/services/appleNotesManager.test.js +0 -2389
  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,2629 +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
- const noteId = result.output.trim();
590
- // Return a Note object representing the created note with real ID
591
- const now = new Date();
592
- return {
593
- id: noteId || generateFallbackId(), // Use real ID, fallback to unique temp ID
594
- title,
595
- content,
596
- tags,
597
- created: now,
598
- modified: now,
599
- folder,
600
- account: targetAccount,
601
- };
602
- }
603
- /**
604
- * Searches for notes matching a query.
605
- *
606
- * By default, searches note titles. Set searchContent=true to search
607
- * the body text instead. Optionally filter to a specific folder.
608
- *
609
- * @param query - Text to search for
610
- * @param searchContent - If true, search note bodies; if false, search titles
611
- * @param account - Account to search in (defaults to iCloud)
612
- * @param folder - Optional folder to limit search to
613
- * @param modifiedSince - Optional ISO 8601 date string to filter notes modified on or after this date
614
- * @param limit - Optional maximum number of results to return (default: no limit)
615
- * @returns Array of matching notes (with minimal metadata)
616
- *
617
- * @example
618
- * ```typescript
619
- * // Search by title
620
- * const meetingNotes = manager.searchNotes("meeting");
621
- *
622
- * // Search in note content
623
- * const projectRefs = manager.searchNotes("Project Alpha", true);
624
- *
625
- * // Search within a specific folder
626
- * const workNotes = manager.searchNotes("deadline", false, "iCloud", "Work");
627
- *
628
- * // Search only recently modified notes
629
- * const recentNotes = manager.searchNotes("todo", true, undefined, undefined, "2025-01-01");
630
- *
631
- * // Search with a result limit
632
- * const topResults = manager.searchNotes("project", false, undefined, undefined, undefined, 10);
633
- * ```
634
- */
635
- searchNotes(query, searchContent = false, account, folder, modifiedSince, limit) {
636
- const targetAccount = this.resolveAccount(account);
637
- const safeQuery = escapePlainStringForAppleScript(query);
638
- const safeLimit = limit !== undefined && limit > 0 ? Math.floor(limit) : undefined;
639
- // Build the where clause based on search type
640
- // AppleScript uses 'name' for title and 'body' for content
641
- const whereParts = [];
642
- if (searchContent) {
643
- whereParts.push(`body contains "${safeQuery}"`);
644
- }
645
- else {
646
- whereParts.push(`name contains "${safeQuery}"`);
647
- }
648
- // Add date filter if specified (uses locale-safe date variable)
649
- let dateSetup = "";
650
- if (modifiedSince) {
651
- const date = new Date(modifiedSince);
652
- if (!isNaN(date.getTime())) {
653
- dateSetup = buildAppleScriptDateVar(date) + "\n";
654
- whereParts.push(`modification date >= thresholdDate`);
655
- }
656
- }
657
- const whereClause = whereParts.join(" and ");
658
- // Build the notes source - either all notes or notes in a specific folder
659
- const notesSource = folder ? `notes of ${buildFolderReference(folder)}` : "notes";
660
- // Build the limit logic for the repeat loop
661
- // Note: The limit only reduces iteration over already-matched results from the whose clause,
662
- // not the query itself. It controls output size, not AppleScript query performance.
663
- const limitCheck = safeLimit !== undefined
664
- ? `
665
- if (count of resultList) >= ${safeLimit} then exit repeat`
666
- : "";
667
- // Get names, IDs, and folder for each matching note.
668
- // Notes.app can return the same CoreData note more than once when asking
669
- // an account for all notes, so dedupe on note ID before adding results.
670
- const searchCommand = `
671
- ${dateSetup}set matchingNotes to ${notesSource} where ${whereClause}
672
- set resultList to {}
673
- set seenIds to {}
674
- repeat with n in matchingNotes
675
- try
676
- set noteName to name of n
677
- set noteId to id of n
678
- if seenIds does not contain noteId then
679
- set end of seenIds to noteId
680
- try
681
- set noteFolder to name of container of n
682
- on error
683
- set noteFolder to "Notes"
684
- end try
685
- set end of resultList to noteName & ${AS_FIELD_SEP} & noteId & ${AS_FIELD_SEP} & noteFolder${limitCheck}
686
- end if
687
- end try
688
- end repeat
689
- set AppleScript's text item delimiters to ${AS_RECORD_SEP}
690
- return resultList as text
691
- `;
692
- const script = buildAccountScopedScript({ account: targetAccount }, searchCommand);
693
- const result = executeAppleScript(script);
694
- if (!result.success) {
695
- // Surface the failure (#19) — an empty array would look like "no matches".
696
- throw new Error(`Failed to search notes for "${query}": ${result.error ?? "unknown error"}`);
697
- }
698
- // Handle empty results
699
- if (!result.output.trim()) {
700
- return [];
701
- }
702
- // Parse the control-char-delimited output (#18): fields by FIELD_SEP, records by RECORD_SEP.
703
- const items = result.output.split(RECORD_SEP);
704
- const notes = [];
705
- const seenIds = new Set();
706
- for (const item of items) {
707
- const [title, id, folder] = item.split(FIELD_SEP);
708
- if (!title?.trim())
709
- continue;
710
- const noteId = id?.trim() || generateFallbackId();
711
- if (seenIds.has(noteId))
712
- continue;
713
- seenIds.add(noteId);
714
- notes.push({
715
- id: noteId,
716
- title: title.trim(),
717
- content: "", // Not fetched in search
718
- tags: [],
719
- created: new Date(),
720
- modified: new Date(),
721
- folder: folder?.trim(),
722
- account: targetAccount,
723
- });
724
- }
725
- return notes;
726
- }
727
- /**
728
- * Retrieves the HTML content of a note by its title.
729
- *
730
- * Note: Password-protected notes will fail with an AppleScript error.
731
- * Callers should check for password protection beforehand using
732
- * getNoteDetails() or isNotePasswordProtected().
733
- *
734
- * @param title - Exact title of the note
735
- * @param account - Account to search in (defaults to iCloud)
736
- * @returns HTML content of the note, or empty string if not found
737
- *
738
- * @example
739
- * ```typescript
740
- * const content = manager.getNoteContent("Shopping List");
741
- * if (content) {
742
- * console.log("Note found:", content);
743
- * }
744
- * ```
745
- */
746
- getNoteContent(title, account) {
747
- const targetAccount = this.resolveAccount(account);
748
- const safeTitle = escapePlainStringForAppleScript(title);
749
- // Retrieve the body property of the note
750
- const getCommand = `get body of note "${safeTitle}"`;
751
- const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
752
- const result = executeAppleScript(script);
753
- if (!result.success) {
754
- console.error(`Failed to get content of note "${title}":`, result.error);
755
- return "";
756
- }
757
- return result.output;
758
- }
759
- /**
760
- * Retrieves the HTML content of a note by its CoreData ID.
761
- *
762
- * This is more reliable than getNoteContent() because IDs are unique
763
- * across all accounts, while titles can be duplicated.
764
- *
765
- * Note: Password-protected notes will fail with an AppleScript error.
766
- * Callers should check for password protection beforehand using
767
- * getNoteById() or isNotePasswordProtectedById().
768
- *
769
- * @param id - CoreData URL identifier for the note
770
- * @returns HTML content of the note, or empty string if not found
771
- */
772
- getNoteContentById(id) {
773
- const safeId = sanitizeId(id);
774
- // Note IDs work at the application level, not scoped to account
775
- const getCommand = `get body of note id "${safeId}"`;
776
- const script = buildAppLevelScript(getCommand);
777
- const result = executeAppleScript(script);
778
- if (!result.success) {
779
- console.error(`Failed to get content of note with ID "${id}":`, result.error);
780
- return "";
781
- }
782
- return result.output;
783
- }
784
- /**
785
- * Retrieves the plain-text content of a note by its exact title.
786
- *
787
- * Reads the note's `plaintext` property, which Notes derives from the body
788
- * with all HTML markup removed. This is the text Notes itself exposes, so it
789
- * is more faithful than converting the HTML body and skips the markup
790
- * round-trip entirely.
791
- *
792
- * @param title - Exact title of the note
793
- * @param account - Account to search in (defaults to iCloud)
794
- * @returns Plain-text content of the note, or empty string if not found
795
- */
796
- getNotePlaintext(title, account) {
797
- const targetAccount = this.resolveAccount(account);
798
- const safeTitle = escapePlainStringForAppleScript(title);
799
- const getCommand = `get plaintext of note "${safeTitle}"`;
800
- const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
801
- const result = executeAppleScript(script);
802
- if (!result.success) {
803
- console.error(`Failed to get plaintext of note "${title}":`, result.error);
804
- return "";
805
- }
806
- return result.output;
807
- }
808
- /**
809
- * Retrieves the plain-text content of a note by its CoreData ID.
810
- *
811
- * Reads the read-only `plaintext` property (the body with HTML removed). More
812
- * reliable than getNotePlaintext() because IDs are unique across accounts.
813
- *
814
- * Note: Password-protected notes will fail with an AppleScript error. Callers
815
- * should check for password protection beforehand using getNoteById().
816
- *
817
- * @param id - CoreData URL identifier for the note
818
- * @returns Plain-text content of the note, or empty string if not found
819
- */
820
- getNotePlaintextById(id) {
821
- const safeId = sanitizeId(id);
822
- const getCommand = `get plaintext of note id "${safeId}"`;
823
- const script = buildAppLevelScript(getCommand);
824
- const result = executeAppleScript(script);
825
- if (!result.success) {
826
- console.error(`Failed to get plaintext of note with ID "${id}":`, result.error);
827
- return "";
828
- }
829
- return result.output;
830
- }
831
- /**
832
- * Retrieves a note by its unique CoreData ID.
833
- *
834
- * Each note has a unique ID in the format:
835
- * "x-coredata://DEVICE-UUID/ICNote/pXXXX"
836
- *
837
- * This method fetches the note and its metadata using this ID.
838
- *
839
- * @param id - CoreData URL identifier for the note
840
- * @returns Note object with metadata, or null if not found
841
- */
842
- getNoteById(id) {
843
- const safeId = sanitizeId(id);
844
- // Note IDs work at the application level, not scoped to account
845
- const getCommand = `
846
- set n to note id "${safeId}"
847
- set cd to creation date of n
848
- set md to modification date of n
849
- set noteProps to {name of n, id of n, ${asDatePartsExpr("cd")}, ${asDatePartsExpr("md")}, (shared of n as text), (password protected of n as text)}
850
- set AppleScript's text item delimiters to ${AS_FIELD_SEP}
851
- return noteProps as text
852
- `;
853
- const script = buildAppLevelScript(getCommand);
854
- const result = executeAppleScript(script);
855
- if (!result.success) {
856
- console.error(`Failed to get note with ID "${id}":`, result.error);
857
- return null;
858
- }
859
- // Parse the AppleScript output using the shared helper
860
- const parsed = parseNotePropertiesOutput(result.output);
861
- if (!parsed) {
862
- return null;
863
- }
864
- return {
865
- id: parsed.id,
866
- title: parsed.title,
867
- content: "", // Not fetched to keep response small
868
- tags: [],
869
- created: parsed.created,
870
- modified: parsed.modified,
871
- shared: parsed.shared,
872
- passwordProtected: parsed.passwordProtected,
873
- };
874
- }
875
- /**
876
- * Retrieves detailed metadata for a note by title.
877
- *
878
- * Similar to getNoteContent but returns structured metadata
879
- * including creation date, modification date, and sharing status.
880
- *
881
- * @param title - Exact title of the note
882
- * @param account - Account to search in (defaults to iCloud)
883
- * @returns Note object with full metadata, or null if not found
884
- */
885
- getNoteDetails(title, account) {
886
- const targetAccount = this.resolveAccount(account);
887
- const safeTitle = escapePlainStringForAppleScript(title);
888
- // Fetch multiple properties at once
889
- const getCommand = `
890
- set n to note "${safeTitle}"
891
- set cd to creation date of n
892
- set md to modification date of n
893
- set noteProps to {name of n, id of n, ${asDatePartsExpr("cd")}, ${asDatePartsExpr("md")}, (shared of n as text), (password protected of n as text)}
894
- set AppleScript's text item delimiters to ${AS_FIELD_SEP}
895
- return noteProps as text
896
- `;
897
- const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
898
- const result = executeAppleScript(script);
899
- if (!result.success) {
900
- console.error(`Failed to get details for note "${title}":`, result.error);
901
- return null;
902
- }
903
- // Parse the AppleScript output using the shared helper
904
- const parsed = parseNotePropertiesOutput(result.output);
905
- if (!parsed) {
906
- return null;
907
- }
908
- return {
909
- id: parsed.id,
910
- title: parsed.title,
911
- content: "", // Not fetched
912
- tags: [],
913
- created: parsed.created,
914
- modified: parsed.modified,
915
- shared: parsed.shared,
916
- passwordProtected: parsed.passwordProtected,
917
- account: targetAccount,
918
- };
919
- }
920
- /**
921
- * Deletes a note by its title.
922
- *
923
- * Note: This permanently deletes the note. It may be recoverable
924
- * from the "Recently Deleted" folder in Notes.app.
925
- *
926
- * @param title - Exact title of the note to delete
927
- * @param account - Account containing the note (defaults to iCloud)
928
- * @returns true if deletion succeeded, false otherwise
929
- */
930
- deleteNote(title, account) {
931
- const targetAccount = this.resolveAccount(account);
932
- const safeTitle = escapePlainStringForAppleScript(title);
933
- const deleteCommand = `delete note "${safeTitle}"`;
934
- const script = buildAccountScopedScript({ account: targetAccount }, deleteCommand);
935
- const result = executeAppleScript(script);
936
- if (!result.success) {
937
- console.error(`Failed to delete note "${title}":`, result.error);
938
- return false;
939
- }
940
- return true;
941
- }
942
- /**
943
- * Deletes a note by its CoreData ID.
944
- *
945
- * This is more reliable than deleteNote() because IDs are unique
946
- * across all accounts, while titles can be duplicated.
947
- *
948
- * @param id - CoreData URL identifier for the note
949
- * @returns true if deletion succeeded, false otherwise
950
- */
951
- deleteNoteById(id) {
952
- const safeId = sanitizeId(id);
953
- const deleteCommand = `delete note id "${safeId}"`;
954
- const script = buildAppLevelScript(deleteCommand);
955
- const result = executeAppleScript(script);
956
- if (!result.success) {
957
- console.error(`Failed to delete note with ID "${id}":`, result.error);
958
- return false;
959
- }
960
- return true;
961
- }
962
- /**
963
- * Updates an existing note's content and optionally its title.
964
- *
965
- * Apple Notes derives the title from the first line of the body,
966
- * so updating content also allows title changes. If newTitle is
967
- * not provided, the original title is preserved.
968
- *
969
- * When format is 'html', newTitle is ignored — the caller must include
970
- * the title in the HTML content.
971
- *
972
- * Note: Password-protected notes will fail with an AppleScript error.
973
- * Callers should check for password protection beforehand using
974
- * getNoteDetails() or isNotePasswordProtected().
975
- *
976
- * @param title - Current title of the note to update
977
- * @param newTitle - New title (optional, keeps existing if not provided; ignored in html format)
978
- * @param newContent - New content for the note body
979
- * @param account - Account containing the note (defaults to iCloud)
980
- * @param format - Content format: "plaintext" wraps in div tags (default), "html" uses content as-is
981
- * @returns true if update succeeded, false otherwise
982
- */
983
- updateNote(title, newTitle, newContent, account, format = "plaintext") {
984
- if (newTitle)
985
- validateLength(newTitle, MAX_TITLE_LENGTH, "Note title");
986
- validateLength(newContent, MAX_CONTENT_LENGTH, "Note content");
987
- const targetAccount = this.resolveAccount(account);
988
- const safeCurrentTitle = escapePlainStringForAppleScript(title);
989
- let fullBody;
990
- if (format === "html") {
991
- // HTML mode: content is the complete body, escaped only for AppleScript string
992
- fullBody = escapeHtmlForAppleScript(newContent);
993
- }
994
- else {
995
- // Plaintext mode: wrap title + content in <div> tags (existing behavior)
996
- const effectiveTitle = newTitle || title;
997
- const safeEffectiveTitle = escapeForAppleScript(effectiveTitle);
998
- const safeContent = escapeForAppleScript(newContent);
999
- fullBody = `<div>${safeEffectiveTitle}</div><div>${safeContent}</div>`;
1000
- }
1001
- const updateCommand = `set body of note "${safeCurrentTitle}" to "${fullBody}"`;
1002
- const script = buildAccountScopedScript({ account: targetAccount }, updateCommand);
1003
- const result = executeAppleScript(script);
1004
- if (!result.success) {
1005
- console.error(`Failed to update note "${title}":`, result.error);
1006
- return false;
1007
- }
1008
- return true;
1009
- }
1010
- /**
1011
- * Updates an existing note by its CoreData ID.
1012
- *
1013
- * This is more reliable than updateNote() because IDs are unique,
1014
- * while titles can be duplicated.
1015
- *
1016
- * When format is 'html', newTitle is ignored — the caller must include
1017
- * the title in the HTML content.
1018
- *
1019
- * Note: Password-protected notes will fail with an AppleScript error.
1020
- * Callers should check for password protection beforehand using
1021
- * getNoteById() or isNotePasswordProtectedById().
1022
- *
1023
- * @param id - CoreData URL identifier for the note
1024
- * @param newTitle - New title (optional, keeps existing if not provided; ignored in html format)
1025
- * @param newContent - New content for the note body
1026
- * @param format - Content format: "plaintext" wraps in div tags (default), "html" uses content as-is
1027
- * @returns true if update succeeded, false otherwise
1028
- */
1029
- updateNoteById(id, newTitle, newContent, format = "plaintext") {
1030
- if (newTitle)
1031
- validateLength(newTitle, MAX_TITLE_LENGTH, "Note title");
1032
- validateLength(newContent, MAX_CONTENT_LENGTH, "Note content");
1033
- let fullBody;
1034
- if (format === "html") {
1035
- // HTML mode: content is the complete body, escaped only for AppleScript string
1036
- fullBody = escapeHtmlForAppleScript(newContent);
1037
- }
1038
- else {
1039
- // Plaintext mode: wrap title + content in <div> tags (existing behavior)
1040
- // Get the note to retrieve current title if newTitle not provided
1041
- let effectiveTitle = newTitle;
1042
- if (!effectiveTitle) {
1043
- const note = this.getNoteById(id);
1044
- if (!note) {
1045
- console.error(`Cannot update note: note with ID "${id}" not found`);
1046
- return false;
1047
- }
1048
- effectiveTitle = note.title;
1049
- }
1050
- const safeEffectiveTitle = escapeForAppleScript(effectiveTitle);
1051
- const safeContent = escapeForAppleScript(newContent);
1052
- fullBody = `<div>${safeEffectiveTitle}</div><div>${safeContent}</div>`;
1053
- }
1054
- const safeId = sanitizeId(id);
1055
- const updateCommand = `set body of note id "${safeId}" to "${fullBody}"`;
1056
- const script = buildAppLevelScript(updateCommand);
1057
- const result = executeAppleScript(script);
1058
- if (!result.success) {
1059
- console.error(`Failed to update note with ID "${id}":`, result.error);
1060
- return false;
1061
- }
1062
- return true;
1063
- }
1064
- /**
1065
- * Lists all notes in an account, optionally filtered by folder, date, and limit.
1066
- *
1067
- * @param account - Account to list notes from (defaults to iCloud)
1068
- * @param folder - Optional folder to filter by
1069
- * @param modifiedSince - Optional ISO 8601 date string to filter notes modified on or after this date
1070
- * @param limit - Optional maximum number of results to return (default: no limit)
1071
- * @returns Array of note titles
1072
- */
1073
- listNotes(account, folder, modifiedSince, limit) {
1074
- const targetAccount = this.resolveAccount(account);
1075
- const safeLimit = limit !== undefined && limit > 0 ? Math.floor(limit) : undefined;
1076
- // When date or limit filters are needed, use a repeat loop for fine-grained control
1077
- if (modifiedSince || safeLimit !== undefined) {
1078
- const baseNotesSource = folder ? `notes of ${buildFolderReference(folder)}` : "notes";
1079
- // Use whose clause for date filtering (locale-safe, no sort order assumption)
1080
- let dateSetup = "";
1081
- let notesSource = baseNotesSource;
1082
- if (modifiedSince) {
1083
- const date = new Date(modifiedSince);
1084
- if (!isNaN(date.getTime())) {
1085
- dateSetup = buildAppleScriptDateVar(date) + "\n";
1086
- notesSource = `(${baseNotesSource} whose modification date >= thresholdDate)`;
1087
- }
1088
- }
1089
- // Build the limit check. Check after appending so deduped results,
1090
- // rather than duplicate AppleScript references, determine the limit.
1091
- const limitCheck = safeLimit !== undefined
1092
- ? `
1093
- if (count of resultList) >= ${safeLimit} then exit repeat`
1094
- : "";
1095
- const listCommand = `
1096
- ${dateSetup}set resultList to {}
1097
- set seenIds to {}
1098
- repeat with n in ${notesSource}
1099
- try
1100
- set noteName to name of n
1101
- set noteId to id of n
1102
- if seenIds does not contain noteId then
1103
- set end of seenIds to noteId
1104
- set end of resultList to noteName & ${AS_FIELD_SEP} & noteId${limitCheck}
1105
- end if
1106
- end try
1107
- end repeat
1108
- set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1109
- return resultList as text
1110
- `;
1111
- const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
1112
- const result = executeAppleScript(script);
1113
- if (!result.success) {
1114
- throw new Error(`Failed to list notes: ${result.error ?? "unknown error"}`);
1115
- }
1116
- if (!result.output.trim()) {
1117
- return [];
1118
- }
1119
- const seenIds = new Set();
1120
- const titles = [];
1121
- for (const item of result.output.split(RECORD_SEP)) {
1122
- const [title, id] = item.split(FIELD_SEP);
1123
- if (!title?.trim())
1124
- continue;
1125
- const noteId = id?.trim() || generateFallbackId();
1126
- if (seenIds.has(noteId))
1127
- continue;
1128
- seenIds.add(noteId);
1129
- titles.push(title.trim());
1130
- }
1131
- return titles;
1132
- }
1133
- // Simple path: no date or limit filters. Use a repeat loop so duplicate
1134
- // CoreData note references can be deduped by ID before returning titles.
1135
- const notesRef = folder ? `notes of ${buildFolderReference(folder)}` : `notes`;
1136
- const listCommand = `
1137
- set resultList to {}
1138
- set seenIds to {}
1139
- repeat with n in ${notesRef}
1140
- try
1141
- set noteName to name of n
1142
- set noteId to id of n
1143
- if seenIds does not contain noteId then
1144
- set end of seenIds to noteId
1145
- set end of resultList to noteName & ${AS_FIELD_SEP} & noteId
1146
- end if
1147
- end try
1148
- end repeat
1149
- set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1150
- return resultList as text
1151
- `;
1152
- const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
1153
- const result = executeAppleScript(script);
1154
- if (!result.success) {
1155
- throw new Error(`Failed to list notes: ${result.error ?? "unknown error"}`);
1156
- }
1157
- if (!result.output.trim())
1158
- return [];
1159
- const seenIds = new Set();
1160
- const titles = [];
1161
- for (const item of result.output.split(RECORD_SEP)) {
1162
- const [title, id] = item.split(FIELD_SEP);
1163
- if (!title?.trim())
1164
- continue;
1165
- const noteId = id?.trim() || generateFallbackId();
1166
- if (seenIds.has(noteId))
1167
- continue;
1168
- seenIds.add(noteId);
1169
- titles.push(title.trim());
1170
- }
1171
- return titles;
1172
- }
1173
- /**
1174
- * Lists all shared (collaborative) notes across all accounts.
1175
- *
1176
- * Returns notes that are shared with other users. These notes require
1177
- * extra caution when modifying or deleting as changes affect collaborators.
1178
- *
1179
- * @returns Array of Note objects for all shared notes
1180
- *
1181
- * @example
1182
- * ```typescript
1183
- * const shared = manager.listSharedNotes();
1184
- * console.log(`You have ${shared.length} shared notes`);
1185
- * ```
1186
- */
1187
- listSharedNotes() {
1188
- const sharedNotes = [];
1189
- // Query each account for shared notes
1190
- const accounts = this.listAccounts();
1191
- for (const account of accounts) {
1192
- // Use delimited output to avoid fragile comma-based parsing.
1193
- // Format: name|||id|||createdDate|||modifiedDate|||shared|||passwordProtected
1194
- const script = buildAccountScopedScript({ account: account.name }, `
1195
- set resultList to {}
1196
- repeat with n in notes
1197
- if shared of n is true then
1198
- set cd to creation date of n
1199
- set md to modification date of n
1200
- 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)
1201
- end if
1202
- end repeat
1203
- set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1204
- return resultList as text
1205
- `);
1206
- const result = executeAppleScript(script);
1207
- if (!result.success) {
1208
- console.error(`Failed to list shared notes for ${account.name}:`, result.error);
1209
- continue;
1210
- }
1211
- const output = result.output.trim();
1212
- if (!output) {
1213
- continue;
1214
- }
1215
- // Parse control-char-delimited output (#18): fields by FIELD_SEP, records by RECORD_SEP.
1216
- const items = output.split(RECORD_SEP);
1217
- for (const item of items) {
1218
- const parts = item.split(FIELD_SEP);
1219
- if (parts.length >= 6) {
1220
- const title = parts[0].trim();
1221
- const id = parts[1].trim();
1222
- const createdStr = parts[2].trim();
1223
- const modifiedStr = parts[3].trim();
1224
- const shared = parts[4].trim() === "true";
1225
- const passwordProtected = parts[5].trim() === "true";
1226
- sharedNotes.push({
1227
- id,
1228
- title,
1229
- content: "",
1230
- tags: [],
1231
- created: parseAppleScriptDate(createdStr),
1232
- modified: parseAppleScriptDate(modifiedStr),
1233
- account: account.name,
1234
- shared,
1235
- passwordProtected,
1236
- });
1237
- }
1238
- }
1239
- }
1240
- return sharedNotes;
1241
- }
1242
- // ===========================================================================
1243
- // Folder Operations
1244
- // ===========================================================================
1245
- /**
1246
- * Lists all folders in an account with full hierarchical paths.
1247
- *
1248
- * Each folder's `name` field contains the full path (e.g., "Work/Clients/Omnia")
1249
- * so that duplicate folder names (e.g., multiple "Archive" folders) are
1250
- * distinguishable and can be used directly in other operations.
1251
- *
1252
- * @param account - Account to list folders from (defaults to iCloud)
1253
- * @returns Array of Folder objects with path-based names
1254
- */
1255
- listFolders(account) {
1256
- const targetAccount = this.resolveAccount(account);
1257
- // Get each folder's ID, name, parent ID, and shared state in a single AppleScript call.
1258
- // Using IDs enables correct tree building even with duplicate folder names.
1259
- const listCommand = `
1260
- set folderList to {}
1261
- set allFolders to every folder
1262
- repeat with f in allFolders
1263
- set fRef to contents of f
1264
- set cRef to container of fRef
1265
- set parentId to ""
1266
- if class of cRef is folder then
1267
- set parentId to id of cRef
1268
- end if
1269
- set sharedFlag to shared of fRef as text
1270
- set end of folderList to (id of fRef) & ${AS_FIELD_SEP} & (name of fRef) & ${AS_FIELD_SEP} & parentId & ${AS_FIELD_SEP} & sharedFlag
1271
- end repeat
1272
- set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1273
- return folderList as text
1274
- `;
1275
- const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
1276
- const result = executeAppleScript(script);
1277
- if (!result.success) {
1278
- throw new Error(`Failed to list folders: ${result.error ?? "unknown error"}`);
1279
- }
1280
- if (!result.output.trim()) {
1281
- return [];
1282
- }
1283
- const recordSeparator = result.output.includes(RECORD_SEP) ? RECORD_SEP : "\n";
1284
- const entries = result.output.split(recordSeparator).map((line) => {
1285
- const parts = line.includes(FIELD_SEP) ? line.split(FIELD_SEP) : line.split("\t");
1286
- return {
1287
- id: (parts[0] || "").trim(),
1288
- name: (parts[1] || "").trim(),
1289
- parentId: (parts[2] || "").trim(),
1290
- shared: (parts[3] || "").trim().toLowerCase() === "true",
1291
- };
1292
- });
1293
- // Build an ID-to-entry map for efficient parent lookups
1294
- const byId = new Map(entries.map((e) => [e.id, e]));
1295
- // Build full path by walking up the parent chain using unique IDs
1296
- // Build full path by walking up the parent chain using unique IDs.
1297
- // Literal slashes in folder names are escaped as `\/` so they don't
1298
- // collide with the `/` path separator.
1299
- const buildPath = (entry) => {
1300
- const safeName = escapeFolderName(entry.name);
1301
- if (!entry.parentId)
1302
- return safeName;
1303
- const parent = byId.get(entry.parentId);
1304
- if (parent) {
1305
- return buildPath(parent) + "/" + safeName;
1306
- }
1307
- return safeName;
1308
- };
1309
- return entries.map((entry) => ({
1310
- id: entry.id,
1311
- name: buildPath(entry),
1312
- account: targetAccount,
1313
- shared: entry.shared,
1314
- }));
1315
- }
1316
- /**
1317
- * Creates a new folder in an account.
1318
- *
1319
- * @param name - Name for the new folder
1320
- * @param account - Account to create folder in (defaults to iCloud)
1321
- * @returns Created Folder object, or null on failure
1322
- */
1323
- createFolder(name, account) {
1324
- const targetAccount = this.resolveAccount(account);
1325
- const parts = splitFolderPath(name);
1326
- if (parts.length === 0) {
1327
- console.error(`Invalid folder name: "${name}"`);
1328
- return null;
1329
- }
1330
- // Create each segment of the path, checking existence first to avoid duplicates.
1331
- // For "A/B/C": ensure "A" exists, then "A/B", then "A/B/C".
1332
- for (let i = 0; i < parts.length; i++) {
1333
- const currentPath = parts
1334
- .slice(0, i + 1)
1335
- .map((p) => escapeFolderName(p))
1336
- .join("/");
1337
- const currentRef = buildFolderReference(currentPath);
1338
- // Check if this folder already exists
1339
- const checkScript = buildAccountScopedScript({ account: targetAccount }, `return id of ${currentRef}`);
1340
- const checkResult = executeAppleScript(checkScript);
1341
- if (checkResult.success) {
1342
- // Folder exists, move to next segment
1343
- continue;
1344
- }
1345
- // Folder doesn't exist — create it
1346
- const segmentName = escapePlainStringForAppleScript(parts[i]);
1347
- let createCommand;
1348
- if (i === 0) {
1349
- createCommand = `make new folder with properties {name:"${segmentName}"}`;
1350
- }
1351
- else {
1352
- const parentPath = parts
1353
- .slice(0, i)
1354
- .map((p) => escapeFolderName(p))
1355
- .join("/");
1356
- const parentRef = buildFolderReference(parentPath);
1357
- createCommand = `make new folder at ${parentRef} with properties {name:"${segmentName}"}`;
1358
- }
1359
- const script = buildAccountScopedScript({ account: targetAccount }, createCommand);
1360
- const result = executeAppleScript(script);
1361
- if (!result.success) {
1362
- console.error(`Failed to create folder "${name}":`, result.error);
1363
- return null;
1364
- }
1365
- }
1366
- // Get the ID of the final (deepest) folder
1367
- const fullRef = buildFolderReference(name);
1368
- const idScript = buildAccountScopedScript({ account: targetAccount }, `return id of ${fullRef}`);
1369
- const idResult = executeAppleScript(idScript);
1370
- const folderId = idResult.success ? extractCoreDataId(idResult.output, "folder") : "";
1371
- return {
1372
- id: folderId,
1373
- name,
1374
- account: targetAccount,
1375
- };
1376
- }
1377
- /**
1378
- * Deletes a folder from an account.
1379
- *
1380
- * Note: This may fail if the folder contains notes.
1381
- *
1382
- * @param name - Name of the folder to delete
1383
- * @param account - Account containing the folder (defaults to iCloud)
1384
- * @returns true if deletion succeeded, false otherwise
1385
- */
1386
- deleteFolder(name, account) {
1387
- const targetAccount = this.resolveAccount(account);
1388
- const deleteCommand = `delete ${buildFolderReference(name)}`;
1389
- const script = buildAccountScopedScript({ account: targetAccount }, deleteCommand);
1390
- const result = executeAppleScript(script);
1391
- if (!result.success) {
1392
- console.error(`Failed to delete folder "${name}":`, result.error);
1393
- return false;
1394
- }
1395
- return true;
1396
- }
1397
- /**
1398
- * Moves a note to a different folder, looked up by title.
1399
- *
1400
- * Uses Notes.app's native `move` command (the same one `batchMoveNotes`
1401
- * uses), which relocates the note in place — preserving its identity, id,
1402
- * creation date, AND all embedded attachments (files/images/PDFs/scans/audio).
1403
- * The previous copy-then-delete implementation rebuilt the note from its body
1404
- * HTML, which silently dropped attachments and reset the note's identity.
1405
- *
1406
- * The note is resolved to its id first (titles can be duplicated), then moved
1407
- * by id so the title-based and id-based paths share the same native move.
1408
- *
1409
- * @param title - Title of the note to move
1410
- * @param destinationFolder - Name of the folder to move to (must already exist)
1411
- * @param account - Account containing the note (defaults to iCloud)
1412
- * @returns true if the move succeeded, false otherwise
1413
- */
1414
- moveNote(title, destinationFolder, account) {
1415
- const targetAccount = this.resolveAccount(account);
1416
- // Resolve the note's id first (titles can be duplicated), then delegate to
1417
- // the id-based native move so both paths preserve attachments + identity.
1418
- const originalNote = this.getNoteDetails(title, targetAccount);
1419
- if (!originalNote) {
1420
- console.error(`Cannot move note "${title}": note not found`);
1421
- return false;
1422
- }
1423
- return this.moveNoteById(originalNote.id, destinationFolder, targetAccount);
1424
- }
1425
- /**
1426
- * Moves a note to a different folder by its CoreData ID.
1427
- *
1428
- * Uses Notes.app's native `move <noteRef> to <destFolder>` command — the same
1429
- * one `batchMoveNotes` uses — which relocates the note in place, preserving its
1430
- * id, creation date, and all embedded attachments. (The old copy-then-delete
1431
- * approach rebuilt the note from body HTML and silently lost attachments.)
1432
- *
1433
- * @param id - CoreData URL identifier for the note
1434
- * @param destinationFolder - Name of the folder to move to (must already exist)
1435
- * @param account - Account containing the destination folder (defaults to iCloud)
1436
- * @returns true if the move succeeded, false otherwise
1437
- */
1438
- moveNoteById(id, destinationFolder, account) {
1439
- const targetAccount = this.resolveAccount(account);
1440
- const safeId = sanitizeId(id);
1441
- const safeAccount = sanitizeAccountName(targetAccount);
1442
- // buildFolderReference validates the destination path; a malformed folder is
1443
- // a precondition error, so let it throw. The destination folder must already
1444
- // exist — Notes.app's `move` does not create it.
1445
- const destFolderRef = `${buildFolderReference(destinationFolder)} of account "${safeAccount}"`;
1446
- const moveCommand = `
1447
- set destFolder to ${destFolderRef}
1448
- set noteRef to note id "${safeId}"
1449
- move noteRef to destFolder
1450
- `;
1451
- const script = buildAppLevelScript(moveCommand);
1452
- const result = executeAppleScript(script);
1453
- if (!result.success) {
1454
- console.error(`Cannot move note to "${destinationFolder}" (folder may not exist):`, result.error);
1455
- return false;
1456
- }
1457
- return true;
1458
- }
1459
- // ===========================================================================
1460
- // Account Operations
1461
- // ===========================================================================
1462
- /**
1463
- * Lists all available Notes accounts.
1464
- *
1465
- * Common accounts include iCloud, Gmail, Exchange, and other
1466
- * email providers configured on the Mac.
1467
- *
1468
- * @returns Array of Account objects
1469
- */
1470
- listAccounts() {
1471
- // Coerce account records to text with control-char delimiters so names
1472
- // containing commas or tabs can't split into phantom accounts (#18).
1473
- const listCommand = `
1474
- set resultList to {}
1475
- repeat with a in accounts
1476
- set aRef to contents of a
1477
- set defaultFolderId to ""
1478
- set defaultFolderName to ""
1479
- try
1480
- set fRef to default folder of aRef
1481
- set defaultFolderId to id of fRef
1482
- set defaultFolderName to name of fRef
1483
- end try
1484
- set upgradedFlag to upgraded of aRef as text
1485
- 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
1486
- end repeat
1487
- set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1488
- return resultList as text
1489
- `;
1490
- const script = buildAppLevelScript(listCommand);
1491
- const result = executeAppleScript(script);
1492
- if (!result.success) {
1493
- throw new Error(`Failed to list accounts: ${result.error ?? "unknown error"}`);
1494
- }
1495
- return result.output
1496
- .split(RECORD_SEP)
1497
- .map((s) => s.trim())
1498
- .filter((s) => s.length > 0)
1499
- .map((item) => {
1500
- const parts = item.split(FIELD_SEP);
1501
- if (parts.length === 1) {
1502
- return { name: parts[0].trim() };
1503
- }
1504
- return {
1505
- id: (parts[0] || "").trim(),
1506
- name: (parts[1] || "").trim(),
1507
- upgraded: (parts[2] || "").trim().toLowerCase() === "true",
1508
- defaultFolderId: (parts[3] || "").trim() || undefined,
1509
- defaultFolder: (parts[4] || "").trim() || undefined,
1510
- };
1511
- });
1512
- }
1513
- /**
1514
- * Gets the default account and folder used by Notes.app for new notes.
1515
- *
1516
- * @returns Default account and folder metadata
1517
- */
1518
- getDefaultLocation() {
1519
- const command = `
1520
- set aRef to default account
1521
- set fRef to default folder of aRef
1522
- 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)
1523
- `;
1524
- const result = executeAppleScript(buildAppLevelScript(command));
1525
- if (!result.success) {
1526
- throw new Error(`Failed to get default Notes location: ${result.error ?? "unknown error"}`);
1527
- }
1528
- const parts = result.output.split(FIELD_SEP);
1529
- if (parts.length < 6) {
1530
- throw new Error(`Failed to parse default Notes location: ${result.output}`);
1531
- }
1532
- const accountName = (parts[1] || "").trim();
1533
- return {
1534
- account: {
1535
- id: (parts[0] || "").trim(),
1536
- name: accountName,
1537
- upgraded: (parts[2] || "").trim().toLowerCase() === "true",
1538
- defaultFolderId: (parts[3] || "").trim(),
1539
- defaultFolder: (parts[4] || "").trim(),
1540
- },
1541
- folder: {
1542
- id: (parts[3] || "").trim(),
1543
- name: (parts[4] || "").trim(),
1544
- account: accountName,
1545
- shared: (parts[5] || "").trim().toLowerCase() === "true",
1546
- },
1547
- };
1548
- }
1549
- /**
1550
- * Lists the currently selected Notes in the Notes.app UI.
1551
- *
1552
- * @returns Array of selected notes, or an empty array when nothing is selected
1553
- */
1554
- getSelectedNotes() {
1555
- const command = `
1556
- set selectedNotes to selection
1557
- set noteList to {}
1558
- repeat with n in selectedNotes
1559
- set nRef to contents of n
1560
- set createdDate to creation date of nRef
1561
- set modifiedDate to modification date of nRef
1562
- set createdParts to ${asDatePartsExpr("createdDate")}
1563
- set modifiedParts to ${asDatePartsExpr("modifiedDate")}
1564
- set folderName to ""
1565
- set accountName to ""
1566
- try
1567
- set fRef to container of nRef
1568
- set folderName to name of fRef
1569
- set aRef to container of fRef
1570
- set accountName to name of aRef
1571
- end try
1572
- 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
1573
- end repeat
1574
- set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1575
- return noteList as text
1576
- `;
1577
- const result = executeAppleScript(buildAppLevelScript(command));
1578
- if (!result.success) {
1579
- throw new Error(`Failed to get selected notes: ${result.error ?? "unknown error"}`);
1580
- }
1581
- if (!result.output.trim()) {
1582
- return [];
1583
- }
1584
- return result.output
1585
- .split(RECORD_SEP)
1586
- .filter((s) => s.trim())
1587
- .map((item) => {
1588
- const parts = item.split(FIELD_SEP);
1589
- return {
1590
- id: (parts[0] || "").trim(),
1591
- title: (parts[1] || "").trim(),
1592
- content: "",
1593
- tags: [],
1594
- created: parseAppleScriptDate((parts[2] || "").trim()),
1595
- modified: parseAppleScriptDate((parts[3] || "").trim()),
1596
- shared: (parts[4] || "").trim().toLowerCase() === "true",
1597
- passwordProtected: (parts[5] || "").trim().toLowerCase() === "true",
1598
- folder: (parts[6] || "").trim() || undefined,
1599
- account: (parts[7] || "").trim() || undefined,
1600
- };
1601
- });
1602
- }
1603
- /**
1604
- * Reveals a note in the Notes.app UI by ID.
1605
- *
1606
- * @param id - CoreData URL identifier for the note
1607
- * @param separately - Whether to open the note in a separate window
1608
- * @returns true if Notes.app accepted the show command
1609
- */
1610
- showNoteById(id, separately = false) {
1611
- const safeId = sanitizeId(id);
1612
- const separatelyClause = separately ? " separately true" : "";
1613
- const result = executeAppleScript(buildAppLevelScript(`show note id "${safeId}"${separatelyClause}`));
1614
- if (!result.success) {
1615
- console.error(`Failed to show note with ID "${id}":`, result.error);
1616
- return false;
1617
- }
1618
- return true;
1619
- }
1620
- /**
1621
- * Reveals a folder in the Notes.app UI by its id.
1622
- *
1623
- * Wraps the Notes `show` command, which the scripting dictionary exposes for
1624
- * folders as well as notes. This opens or focuses the Notes UI on the folder.
1625
- *
1626
- * @param id - CoreData identifier for the folder (from list-folders)
1627
- * @param separately - Open in a separate window when supported by Notes.app
1628
- * @returns true if Notes.app accepted the show command, false otherwise
1629
- */
1630
- showFolderById(id, separately = false) {
1631
- const safeId = sanitizeId(id);
1632
- const separatelyClause = separately ? " separately true" : "";
1633
- const result = executeAppleScript(buildAppLevelScript(`show folder id "${safeId}"${separatelyClause}`));
1634
- if (!result.success) {
1635
- console.error(`Failed to show folder with ID "${id}":`, result.error);
1636
- return false;
1637
- }
1638
- return true;
1639
- }
1640
- /**
1641
- * Reveals an account in the Notes.app UI by its id.
1642
- *
1643
- * Wraps the Notes `show` command, which the scripting dictionary exposes for
1644
- * accounts as well as notes. This opens or focuses the Notes UI on the account.
1645
- *
1646
- * @param id - CoreData identifier for the account (from list-accounts)
1647
- * @param separately - Open in a separate window when supported by Notes.app
1648
- * @returns true if Notes.app accepted the show command, false otherwise
1649
- */
1650
- showAccountById(id, separately = false) {
1651
- const safeId = sanitizeId(id);
1652
- const separatelyClause = separately ? " separately true" : "";
1653
- const result = executeAppleScript(buildAppLevelScript(`show account id "${safeId}"${separatelyClause}`));
1654
- if (!result.success) {
1655
- console.error(`Failed to show account with ID "${id}":`, result.error);
1656
- return false;
1657
- }
1658
- return true;
1659
- }
1660
- /**
1661
- * Reveals an attachment in the Notes.app UI.
1662
- *
1663
- * Attachments are elements of a note, so they cannot be referenced at the
1664
- * application level by id alone. This resolves the attachment within its note
1665
- * (the same lookup used by save-attachment) and then runs the Notes `show`
1666
- * command on it, opening or focusing the Notes UI on the attachment.
1667
- *
1668
- * @param noteId - CoreData identifier for the note containing the attachment
1669
- * @param attachmentId - id of the attachment (from list-attachments)
1670
- * @param separately - Open in a separate window when supported by Notes.app
1671
- * @returns true if Notes.app revealed the attachment, false otherwise
1672
- */
1673
- showAttachmentById(noteId, attachmentId, separately = false) {
1674
- const safeNoteId = sanitizeId(noteId);
1675
- const safeAttId = escapePlainStringForAppleScript(attachmentId);
1676
- const separatelyClause = separately ? " separately true" : "";
1677
- const script = `
1678
- tell application "Notes"
1679
- set theNote to note id "${safeNoteId}"
1680
- set theAttachment to missing value
1681
- repeat with a in attachments of theNote
1682
- if (id of a as text) is "${safeAttId}" then
1683
- set theAttachment to a
1684
- exit repeat
1685
- end if
1686
- end repeat
1687
- if theAttachment is missing value then
1688
- return "ERR${AS_FIELD_SEP}attachment not found"
1689
- end if
1690
- show theAttachment${separatelyClause}
1691
- return "OK"
1692
- end tell
1693
- `;
1694
- const result = executeAppleScript(script);
1695
- if (!result.success) {
1696
- console.error(`Failed to show attachment "${attachmentId}" on note "${noteId}":`, result.error);
1697
- return false;
1698
- }
1699
- if ((result.output ?? "").trim().startsWith("ERR")) {
1700
- console.error(`Attachment "${attachmentId}" not found on note "${noteId}"`);
1701
- return false;
1702
- }
1703
- return true;
1704
- }
1705
- // ===========================================================================
1706
- // Health Check
1707
- // ===========================================================================
1708
- /**
1709
- * Performs a health check on Notes.app accessibility and functionality.
1710
- *
1711
- * This method verifies:
1712
- * - Notes.app is installed and accessible
1713
- * - AppleScript automation permissions are granted
1714
- * - At least one account is available
1715
- * - Basic list operations work
1716
- *
1717
- * Use this to diagnose connection issues or verify setup.
1718
- *
1719
- * @returns HealthCheckResult with overall status and individual check details
1720
- *
1721
- * @example
1722
- * ```typescript
1723
- * const health = manager.healthCheck();
1724
- * if (!health.healthy) {
1725
- * console.log("Issues found:");
1726
- * health.checks.filter(c => !c.passed).forEach(c => console.log(`- ${c.message}`));
1727
- * }
1728
- * ```
1729
- */
1730
- healthCheck() {
1731
- const checks = [];
1732
- // Check 1: Notes.app is accessible
1733
- const appCheck = executeAppleScript('tell application "Notes" to return "ok"');
1734
- if (appCheck.success && appCheck.output === "ok") {
1735
- checks.push({
1736
- name: "notes_app",
1737
- passed: true,
1738
- message: "Notes.app is accessible",
1739
- });
1740
- }
1741
- else {
1742
- const errorHint = appCheck.error?.includes("not authorized")
1743
- ? " (check Automation permissions in System Preferences)"
1744
- : "";
1745
- checks.push({
1746
- name: "notes_app",
1747
- passed: false,
1748
- message: `Notes.app is not accessible${errorHint}`,
1749
- });
1750
- // If Notes.app isn't accessible, skip other checks
1751
- return { healthy: false, checks };
1752
- }
1753
- // Check 2: AppleScript permissions (can we execute commands?)
1754
- const permCheck = executeAppleScript('tell application "Notes" to get name of account 1');
1755
- if (permCheck.success) {
1756
- checks.push({
1757
- name: "permissions",
1758
- passed: true,
1759
- message: "AppleScript automation permissions granted",
1760
- });
1761
- }
1762
- else {
1763
- const isPermError = permCheck.error?.includes("not authorized") || permCheck.error?.includes("not permitted");
1764
- checks.push({
1765
- name: "permissions",
1766
- passed: !isPermError,
1767
- message: isPermError
1768
- ? "AppleScript permissions denied. Grant access in System Preferences > Privacy & Security > Automation"
1769
- : `Permission check returned: ${permCheck.error}`,
1770
- });
1771
- if (isPermError) {
1772
- return { healthy: false, checks };
1773
- }
1774
- }
1775
- // Check 3: At least one account accessible
1776
- const accounts = this.listAccounts();
1777
- if (accounts.length > 0) {
1778
- const accountNames = accounts.map((a) => a.name).join(", ");
1779
- checks.push({
1780
- name: "accounts",
1781
- passed: true,
1782
- message: `Found ${accounts.length} account(s): ${accountNames}`,
1783
- });
1784
- }
1785
- else {
1786
- checks.push({
1787
- name: "accounts",
1788
- passed: false,
1789
- message: "No Notes accounts found. Set up an account in Notes.app first.",
1790
- });
1791
- return { healthy: false, checks };
1792
- }
1793
- // Check 4: Basic operations work (list notes in default account)
1794
- const defaultAccount = accounts[0]?.name || "iCloud";
1795
- const notes = this.listNotes(defaultAccount);
1796
- // Even 0 notes is fine - we just want to verify the operation works
1797
- checks.push({
1798
- name: "operations",
1799
- passed: true,
1800
- message: `Basic operations working (${notes.length} note(s) in ${defaultAccount})`,
1801
- });
1802
- const allPassed = checks.every((c) => c.passed);
1803
- return { healthy: allPassed, checks };
1804
- }
1805
- // ===========================================================================
1806
- // Statistics
1807
- // ===========================================================================
1808
- /**
1809
- * Gets comprehensive statistics about notes across all accounts.
1810
- *
1811
- * Returns total note counts, per-account breakdowns, folder statistics,
1812
- * and counts of recently modified notes.
1813
- *
1814
- * @returns NotesStats object with comprehensive statistics
1815
- *
1816
- * @example
1817
- * ```typescript
1818
- * const stats = manager.getNotesStats();
1819
- * console.log(`Total notes: ${stats.totalNotes}`);
1820
- * console.log(`Modified today: ${stats.recentlyModified.last24h}`);
1821
- * ```
1822
- */
1823
- getNotesStats() {
1824
- const accounts = this.listAccounts();
1825
- const accountStats = [];
1826
- const warnings = [];
1827
- let totalNotes = 0;
1828
- // Collect stats per account with ONE bounded script per account (#20/#26):
1829
- // count notes server-side per folder instead of fetching every note's name
1830
- // (unbounded) via a listNotes call per folder (N+1 osascript spawns).
1831
- //
1832
- // Per-account failures degrade gracefully (#19): a single unreachable or
1833
- // locked account is recorded as a coverage warning and skipped, rather than
1834
- // discarding the stats for every healthy account. Only a total wipeout
1835
- // (no account readable) is escalated to a thrown error below.
1836
- for (const account of accounts) {
1837
- const countScript = buildAccountScopedScript({ account: account.name }, `
1838
- set out to ""
1839
- repeat with fldr in folders
1840
- set out to out & (name of fldr) & ${AS_FIELD_SEP} & (count of notes of fldr) & ${AS_RECORD_SEP}
1841
- end repeat
1842
- return out
1843
- `);
1844
- const res = executeAppleScript(countScript);
1845
- if (!res.success) {
1846
- warnings.push({ scope: account.name, reason: res.error ?? "unknown error" });
1847
- continue;
1848
- }
1849
- const folderStats = [];
1850
- let accountTotal = 0;
1851
- for (const rec of res.output.split(RECORD_SEP)) {
1852
- if (!rec.trim())
1853
- continue;
1854
- const [fname, cnt] = rec.split(FIELD_SEP);
1855
- const noteCount = parseInt((cnt ?? "").trim(), 10) || 0;
1856
- accountTotal += noteCount;
1857
- folderStats.push({ name: (fname ?? "").trim(), noteCount });
1858
- }
1859
- totalNotes += accountTotal;
1860
- accountStats.push({
1861
- name: account.name,
1862
- totalNotes: accountTotal,
1863
- folderCount: folderStats.length,
1864
- folders: folderStats,
1865
- });
1866
- }
1867
- // If every account failed, there is no data to report — surface the error
1868
- // (#19) rather than returning a deceptively empty stats object.
1869
- if (accounts.length > 0 && accountStats.length === 0) {
1870
- throw new Error(`Failed to read folder stats for any of ${accounts.length} account(s): ${warnings
1871
- .map((w) => `${w.scope} (${w.reason})`)
1872
- .join("; ")}`);
1873
- }
1874
- // Get recently modified notes counts. A failure here is non-fatal — record a
1875
- // coverage warning and report zeros, flagged as not-covered (#19), instead of
1876
- // passing off fake zero activity as real.
1877
- const recent = this.getRecentlyModifiedCounts();
1878
- if (recent.error) {
1879
- warnings.push({ scope: "recent-activity", reason: recent.error });
1880
- }
1881
- // scopes = each account + the recent-activity scan
1882
- const scanned = accounts.length + 1;
1883
- const covered = scanned - warnings.length;
1884
- return {
1885
- totalNotes,
1886
- accounts: accountStats,
1887
- recentlyModified: recent.counts,
1888
- coverage: {
1889
- complete: warnings.length === 0,
1890
- scanned,
1891
- covered,
1892
- warnings,
1893
- },
1894
- };
1895
- }
1896
- /**
1897
- * Helper to get counts of recently modified notes.
1898
- */
1899
- getRecentlyModifiedCounts() {
1900
- // Count server-side with locale-safe date variables (#20/#25): instead of
1901
- // streaming every note's modification date to JS (unbounded, ENOBUFS-prone,
1902
- // locale-fragile), let AppleScript count matches via a `whose` filter — three
1903
- // counts per account, regardless of library size.
1904
- const now = new Date();
1905
- const d1 = new Date(now.getTime() - 24 * 60 * 60 * 1000);
1906
- const d7 = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
1907
- const d30 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
1908
- const script = `
1909
- tell application "Notes"
1910
- ${buildAppleScriptDateVar(d1, "d1")}
1911
- ${buildAppleScriptDateVar(d7, "d7")}
1912
- ${buildAppleScriptDateVar(d30, "d30")}
1913
- set c1 to 0
1914
- set c7 to 0
1915
- set c30 to 0
1916
- repeat with acct in accounts
1917
- set c1 to c1 + (count of (notes of acct whose modification date >= d1))
1918
- set c7 to c7 + (count of (notes of acct whose modification date >= d7))
1919
- set c30 to c30 + (count of (notes of acct whose modification date >= d30))
1920
- end repeat
1921
- return (c1 as text) & ${AS_FIELD_SEP} & (c7 as text) & ${AS_FIELD_SEP} & (c30 as text)
1922
- end tell
1923
- `;
1924
- const result = executeAppleScript(script);
1925
- if (!result.success) {
1926
- // Non-fatal (#19): report the error to the caller so it becomes a coverage
1927
- // warning, with zeroed counts, instead of throwing away the whole stats
1928
- // result or passing off fake zero activity as real.
1929
- return {
1930
- counts: { last24h: 0, last7d: 0, last30d: 0 },
1931
- error: result.error ?? "unknown error",
1932
- };
1933
- }
1934
- const parts = result.output.trim().split(FIELD_SEP);
1935
- const toInt = (s) => {
1936
- const n = parseInt((s ?? "").trim(), 10);
1937
- return Number.isFinite(n) ? n : 0;
1938
- };
1939
- return {
1940
- counts: { last24h: toInt(parts[0]), last7d: toInt(parts[1]), last30d: toInt(parts[2]) },
1941
- };
1942
- }
1943
- // ===========================================================================
1944
- // Attachments
1945
- // ===========================================================================
1946
- /**
1947
- * Lists attachments for a note by its ID.
1948
- *
1949
- * Returns metadata about each attachment including name and content type.
1950
- * Note: The position within the note cannot be determined via AppleScript.
1951
- *
1952
- * @param id - CoreData URL identifier for the note
1953
- * @returns Array of Attachment objects, or empty array if none found
1954
- *
1955
- * @example
1956
- * ```typescript
1957
- * const attachments = manager.listAttachmentsById("x-coredata://ABC/ICNote/p123");
1958
- * attachments.forEach(a => console.log(`${a.name}: ${a.contentType}`));
1959
- * ```
1960
- */
1961
- listAttachmentsById(id) {
1962
- const safeId = sanitizeId(id);
1963
- const script = `
1964
- tell application "Notes"
1965
- set theNote to note id "${safeId}"
1966
- set attachmentList to {}
1967
- repeat with a in attachments of theNote
1968
- set attachId to id of a
1969
- set attachName to name of a
1970
- set attachContentId to content identifier of a
1971
- set attachUrl to ""
1972
- try
1973
- set attachUrl to URL of a as text
1974
- end try
1975
- set createdDate to creation date of a
1976
- set modifiedDate to modification date of a
1977
- set createdParts to ${asDatePartsExpr("createdDate")}
1978
- set modifiedParts to ${asDatePartsExpr("modifiedDate")}
1979
- set sharedFlag to shared of a as text
1980
- 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
1981
- end repeat
1982
- set output to ""
1983
- repeat with item in attachmentList
1984
- set output to output & item & ${AS_RECORD_SEP}
1985
- end repeat
1986
- return output
1987
- end tell
1988
- `;
1989
- const result = executeAppleScript(script);
1990
- if (!result.success || !result.output) {
1991
- if (result.error) {
1992
- console.error(`Failed to list attachments for note ID "${id}":`, result.error);
1993
- }
1994
- return [];
1995
- }
1996
- // Parse the results
1997
- const attachments = [];
1998
- const items = result.output.split(RECORD_SEP).filter((s) => s.trim());
1999
- for (const item of items) {
2000
- const parts = item.split(FIELD_SEP);
2001
- if (parts.length >= 3) {
2002
- attachments.push({
2003
- id: parts[0].trim(),
2004
- name: parts[1].trim(),
2005
- contentType: parts[2].trim(),
2006
- contentId: parts[2].trim() || undefined,
2007
- url: parts[3]?.trim() || undefined,
2008
- created: parts[4] ? parseAppleScriptDate(parts[4].trim()) : undefined,
2009
- modified: parts[5] ? parseAppleScriptDate(parts[5].trim()) : undefined,
2010
- shared: parts[6] ? parts[6].trim().toLowerCase() === "true" : undefined,
2011
- });
2012
- }
2013
- }
2014
- return attachments;
2015
- }
2016
- /**
2017
- * Lists attachments for a note by its title.
2018
- *
2019
- * @param title - Title of the note
2020
- * @param account - Account containing the note (defaults to iCloud)
2021
- * @returns Array of Attachment objects, or empty array if none found
2022
- */
2023
- listAttachments(title, account) {
2024
- const targetAccount = this.resolveAccount(account);
2025
- const safeAccount = escapePlainStringForAppleScript(targetAccount);
2026
- const safeTitle = escapePlainStringForAppleScript(title);
2027
- const script = `
2028
- tell application "Notes"
2029
- tell account "${safeAccount}"
2030
- set theNote to note "${safeTitle}"
2031
- set attachmentList to {}
2032
- repeat with a in attachments of theNote
2033
- set attachId to id of a
2034
- set attachName to name of a
2035
- set attachContentId to content identifier of a
2036
- set attachUrl to ""
2037
- try
2038
- set attachUrl to URL of a as text
2039
- end try
2040
- set createdDate to creation date of a
2041
- set modifiedDate to modification date of a
2042
- set createdParts to ${asDatePartsExpr("createdDate")}
2043
- set modifiedParts to ${asDatePartsExpr("modifiedDate")}
2044
- set sharedFlag to shared of a as text
2045
- 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
2046
- end repeat
2047
- set output to ""
2048
- repeat with item in attachmentList
2049
- set output to output & item & ${AS_RECORD_SEP}
2050
- end repeat
2051
- return output
2052
- end tell
2053
- end tell
2054
- `;
2055
- const result = executeAppleScript(script);
2056
- if (!result.success || !result.output) {
2057
- if (result.error) {
2058
- console.error(`Failed to list attachments for note "${title}":`, result.error);
2059
- }
2060
- return [];
2061
- }
2062
- // Parse the results
2063
- const attachments = [];
2064
- const items = result.output.split(RECORD_SEP).filter((s) => s.trim());
2065
- for (const item of items) {
2066
- const parts = item.split(FIELD_SEP);
2067
- if (parts.length >= 3) {
2068
- attachments.push({
2069
- id: parts[0].trim(),
2070
- name: parts[1].trim(),
2071
- contentType: parts[2].trim(),
2072
- contentId: parts[2].trim() || undefined,
2073
- url: parts[3]?.trim() || undefined,
2074
- created: parts[4] ? parseAppleScriptDate(parts[4].trim()) : undefined,
2075
- modified: parts[5] ? parseAppleScriptDate(parts[5].trim()) : undefined,
2076
- shared: parts[6] ? parts[6].trim().toLowerCase() === "true" : undefined,
2077
- });
2078
- }
2079
- }
2080
- return attachments;
2081
- }
2082
- /**
2083
- * Saves a single attachment of a note (identified by attachment id) to a file
2084
- * on disk via Notes.app's AppleScript `save` (#27).
2085
- *
2086
- * @param noteId - CoreData URL identifier for the note
2087
- * @param attachmentId - id of the attachment (from list-attachments)
2088
- * @param savePath - absolute destination file path (within home / temp / /Volumes)
2089
- * @returns { success, savedPath?, name?, contentType?, error? }
2090
- */
2091
- saveAttachmentById(noteId, attachmentId, savePath) {
2092
- let abs;
2093
- try {
2094
- abs = assertSafeSavePath(savePath);
2095
- }
2096
- catch (e) {
2097
- return { success: false, error: e instanceof Error ? e.message : String(e) };
2098
- }
2099
- const safeNoteId = sanitizeId(noteId);
2100
- const safeAttId = escapePlainStringForAppleScript(attachmentId);
2101
- const safePath = escapePlainStringForAppleScript(abs);
2102
- const script = `
2103
- tell application "Notes"
2104
- set theNote to note id "${safeNoteId}"
2105
- set theAttachment to missing value
2106
- repeat with a in attachments of theNote
2107
- if (id of a as text) is "${safeAttId}" then
2108
- set theAttachment to a
2109
- exit repeat
2110
- end if
2111
- end repeat
2112
- if theAttachment is missing value then
2113
- return "ERR${AS_FIELD_SEP}attachment not found"
2114
- end if
2115
- save theAttachment in (POSIX file "${safePath}")
2116
- return "OK${AS_FIELD_SEP}" & (name of theAttachment) & "${AS_FIELD_SEP}" & (content identifier of theAttachment)
2117
- end tell
2118
- `;
2119
- const result = executeAppleScript(script);
2120
- if (!result.success) {
2121
- return { success: false, error: result.error ?? "unknown error" };
2122
- }
2123
- const parts = (result.output ?? "").trim().split(FIELD_SEP);
2124
- if (parts[0] !== "OK") {
2125
- return { success: false, error: parts[1]?.trim() || "attachment not found" };
2126
- }
2127
- if (!existsSync(abs) || fileSize(abs) === 0) {
2128
- return { success: false, error: `Notes reported success but no file was written to ${abs}` };
2129
- }
2130
- return {
2131
- success: true,
2132
- savedPath: abs,
2133
- name: parts[1]?.trim(),
2134
- contentType: parts[2]?.trim(),
2135
- };
2136
- }
2137
- /**
2138
- * Fetches a note attachment as base64 (#27). Exports to a private temp file,
2139
- * reads it, then deletes the temp copy.
2140
- *
2141
- * @param noteId - CoreData URL identifier for the note
2142
- * @param attachmentId - id of the attachment
2143
- * @returns { success, name?, contentType?, base64?, bytes?, error? }
2144
- */
2145
- getAttachmentBase64ById(noteId, attachmentId) {
2146
- const dir = makeTempDir();
2147
- try {
2148
- const dest = `${dir}/attachment.bin`;
2149
- const saved = this.saveAttachmentById(noteId, attachmentId, dest);
2150
- if (!saved.success || !saved.savedPath) {
2151
- return { success: false, error: saved.error };
2152
- }
2153
- // readFileBase64Capped checks the file size BEFORE reading and throws if it
2154
- // exceeds APPLE_NOTES_MCP_MAX_ATTACHMENT_BYTES — the throw is caught below
2155
- // and the temp dir is still cleaned up in `finally`.
2156
- const base64 = readFileBase64Capped(saved.savedPath);
2157
- return {
2158
- success: true,
2159
- name: saved.name,
2160
- contentType: saved.contentType,
2161
- base64,
2162
- bytes: fileSize(saved.savedPath),
2163
- };
2164
- }
2165
- catch (e) {
2166
- return { success: false, error: e instanceof Error ? e.message : String(e) };
2167
- }
2168
- finally {
2169
- cleanupTempDir(dir);
2170
- }
2171
- }
2172
- // ===========================================================================
2173
- // Batch Operations
2174
- // ===========================================================================
2175
- /**
2176
- * Result of a batch operation on a single item.
2177
- */
2178
- createBatchResult(id, success, error) {
2179
- return error ? { id, success, error } : { id, success };
2180
- }
2181
- /**
2182
- * Deletes multiple notes by their IDs.
2183
- *
2184
- * Each deletion is attempted independently; failures don't stop other deletions.
2185
- * Returns results for each note indicating success or failure.
2186
- *
2187
- * @param ids - Array of CoreData URL identifiers for notes to delete
2188
- * @returns Array of results with id, success status, and optional error message
2189
- *
2190
- * @example
2191
- * ```typescript
2192
- * const results = manager.batchDeleteNotes([
2193
- * "x-coredata://ABC/ICNote/p1",
2194
- * "x-coredata://ABC/ICNote/p2"
2195
- * ]);
2196
- * results.forEach(r => {
2197
- * if (r.success) console.log(`Deleted ${r.id}`);
2198
- * else console.log(`Failed to delete ${r.id}: ${r.error}`);
2199
- * });
2200
- * ```
2201
- */
2202
- batchDeleteNotes(ids) {
2203
- if (ids.length === 0)
2204
- return [];
2205
- // Collapse the whole batch into ONE osascript spawn (#26): a single
2206
- // app-level script loops over every id, with a per-id `try` so one bad note
2207
- // can't abort the rest. The old path spawned 3 processes per note
2208
- // (getNoteById + isNotePasswordProtectedById + deleteNoteById) — i.e. 3N
2209
- // spawns for N notes. This is one spawn total, with the same per-item
2210
- // isolation and result semantics.
2211
- const results = new Array(ids.length);
2212
- const runnable = [];
2213
- ids.forEach((id, i) => {
2214
- try {
2215
- runnable.push({ index: i, safe: sanitizeId(id) });
2216
- }
2217
- catch (e) {
2218
- results[i] = this.createBatchResult(id, false, e instanceof Error ? e.message : "Invalid note ID");
2219
- }
2220
- });
2221
- if (runnable.length > 0) {
2222
- const idList = runnable.map((r) => `"${r.safe}"`).join(", ");
2223
- const script = buildAppLevelScript(`
2224
- set out to ""
2225
- repeat with rawId in {${idList}}
2226
- set theId to (rawId as text)
2227
- set noteRef to missing value
2228
- try
2229
- set noteRef to note id theId
2230
- end try
2231
- if noteRef is missing value then
2232
- set out to out & "missing" & ${AS_RECORD_SEP}
2233
- else
2234
- set isPw to false
2235
- try
2236
- set isPw to (password protected of noteRef)
2237
- end try
2238
- if isPw then
2239
- set out to out & "pw" & ${AS_RECORD_SEP}
2240
- else
2241
- try
2242
- delete noteRef
2243
- set out to out & "ok" & ${AS_RECORD_SEP}
2244
- on error
2245
- set out to out & "fail" & ${AS_RECORD_SEP}
2246
- end try
2247
- end if
2248
- end if
2249
- end repeat
2250
- return out
2251
- `);
2252
- const res = executeAppleScript(script);
2253
- if (!res.success) {
2254
- // Whole-batch failure (e.g. Notes.app not responding): can't isolate,
2255
- // so mark every runnable note as failed with the underlying error.
2256
- for (const r of runnable) {
2257
- results[r.index] = this.createBatchResult(ids[r.index], false, res.error ?? "Batch delete failed");
2258
- }
2259
- }
2260
- else {
2261
- const statuses = res.output
2262
- .split(RECORD_SEP)
2263
- .map((s) => s.trim())
2264
- .filter((s) => s.length > 0);
2265
- runnable.forEach((r, k) => {
2266
- results[r.index] = this.mapBatchStatus(ids[r.index], statuses[k], "delete");
2267
- });
2268
- }
2269
- }
2270
- return results;
2271
- }
2272
- /**
2273
- * Maps a per-item status token emitted by a batch AppleScript loop to a
2274
- * BatchResult, preserving the human-readable error messages of the original
2275
- * per-note implementation. See {@link batchDeleteNotes} / {@link batchMoveNotes}.
2276
- */
2277
- mapBatchStatus(id, status, op) {
2278
- switch (status) {
2279
- case "ok":
2280
- return this.createBatchResult(id, true);
2281
- case "pw":
2282
- return this.createBatchResult(id, false, "Note is password-protected");
2283
- case "missing":
2284
- return this.createBatchResult(id, false, "Note not found");
2285
- case "fail":
2286
- return this.createBatchResult(id, false, op === "delete" ? "Deletion failed" : "Move failed");
2287
- default:
2288
- return this.createBatchResult(id, false, "Unknown error");
2289
- }
2290
- }
2291
- /**
2292
- * Moves multiple notes to a folder by their IDs.
2293
- *
2294
- * Each move is attempted independently; failures don't stop other moves.
2295
- * Returns results for each note indicating success or failure.
2296
- *
2297
- * @param ids - Array of CoreData URL identifiers for notes to move
2298
- * @param folder - Destination folder name
2299
- * @param account - Account containing the folder (defaults to iCloud)
2300
- * @returns Array of results with id, success status, and optional error message
2301
- *
2302
- * @example
2303
- * ```typescript
2304
- * const results = manager.batchMoveNotes(
2305
- * ["x-coredata://ABC/ICNote/p1", "x-coredata://ABC/ICNote/p2"],
2306
- * "Archive"
2307
- * );
2308
- * ```
2309
- */
2310
- batchMoveNotes(ids, folder, account) {
2311
- if (ids.length === 0)
2312
- return [];
2313
- // Collapse the whole batch into ONE osascript spawn (#26). The old path
2314
- // spawned 5+ processes per note (getNoteById + isNotePasswordProtectedById +
2315
- // moveNoteById's copy-then-delete fan-out). This uses the native `move`
2316
- // command — which preserves the note's identity and metadata rather than
2317
- // copy+delete — inside a single app-level loop with per-id `try` isolation.
2318
- const targetAccount = this.resolveAccount(account);
2319
- const safeAccount = sanitizeAccountName(targetAccount);
2320
- // buildFolderReference validates the (single, shared) destination path; a
2321
- // malformed folder is a precondition error for the whole call, so let it throw.
2322
- const destFolderRef = `${buildFolderReference(folder)} of account "${safeAccount}"`;
2323
- const results = new Array(ids.length);
2324
- const runnable = [];
2325
- ids.forEach((id, i) => {
2326
- try {
2327
- runnable.push({ index: i, safe: sanitizeId(id) });
2328
- }
2329
- catch (e) {
2330
- results[i] = this.createBatchResult(id, false, e instanceof Error ? e.message : "Invalid note ID");
2331
- }
2332
- });
2333
- if (runnable.length > 0) {
2334
- const idList = runnable.map((r) => `"${r.safe}"`).join(", ");
2335
- const script = buildAppLevelScript(`
2336
- set destFolder to ${destFolderRef}
2337
- set out to ""
2338
- repeat with rawId in {${idList}}
2339
- set theId to (rawId as text)
2340
- set noteRef to missing value
2341
- try
2342
- set noteRef to note id theId
2343
- end try
2344
- if noteRef is missing value then
2345
- set out to out & "missing" & ${AS_RECORD_SEP}
2346
- else
2347
- set isPw to false
2348
- try
2349
- set isPw to (password protected of noteRef)
2350
- end try
2351
- if isPw then
2352
- set out to out & "pw" & ${AS_RECORD_SEP}
2353
- else
2354
- try
2355
- move noteRef to destFolder
2356
- set out to out & "ok" & ${AS_RECORD_SEP}
2357
- on error
2358
- set out to out & "fail" & ${AS_RECORD_SEP}
2359
- end try
2360
- end if
2361
- end if
2362
- end repeat
2363
- return out
2364
- `);
2365
- const res = executeAppleScript(script);
2366
- if (!res.success) {
2367
- // Whole-batch failure (e.g. destination folder unresolved, Notes not
2368
- // responding): can't isolate, so fail every runnable note.
2369
- for (const r of runnable) {
2370
- results[r.index] = this.createBatchResult(ids[r.index], false, res.error ?? "Batch move failed");
2371
- }
2372
- }
2373
- else {
2374
- const statuses = res.output
2375
- .split(RECORD_SEP)
2376
- .map((s) => s.trim())
2377
- .filter((s) => s.length > 0);
2378
- runnable.forEach((r, k) => {
2379
- results[r.index] = this.mapBatchStatus(ids[r.index], statuses[k], "move");
2380
- });
2381
- }
2382
- }
2383
- return results;
2384
- }
2385
- // ===========================================================================
2386
- // Export Operations
2387
- // ===========================================================================
2388
- /**
2389
- * Export structure for a single note.
2390
- */
2391
- exportNote(note, content) {
2392
- return {
2393
- id: note.id,
2394
- title: note.title,
2395
- content: content,
2396
- plaintext: this.htmlToPlaintext(content),
2397
- folder: note.folder || "Notes",
2398
- account: note.account || "iCloud",
2399
- created: note.created.toISOString(),
2400
- modified: note.modified.toISOString(),
2401
- shared: note.shared || false,
2402
- passwordProtected: note.passwordProtected || false,
2403
- };
2404
- }
2405
- /**
2406
- * Simple HTML to plaintext conversion for export.
2407
- */
2408
- htmlToPlaintext(html) {
2409
- // Convert block/line breaks to newlines first.
2410
- let text = html
2411
- .replace(/<br\s*\/?>/gi, "\n")
2412
- .replace(/<\/div>/gi, "\n")
2413
- .replace(/<\/p>/gi, "\n");
2414
- // Strip any remaining tags, looping until the string stabilizes. A single
2415
- // pass can leave residue when removing one tag re-forms another (e.g.
2416
- // "<<i>>"), so we repeat until there are no more matches — the recognized
2417
- // fix for CodeQL js/incomplete-multi-character-sanitization.
2418
- let prev;
2419
- do {
2420
- prev = text;
2421
- text = text.replace(/<[^>]*>/g, "");
2422
- } while (text !== prev);
2423
- return (text
2424
- .replace(/&nbsp;/g, " ")
2425
- .replace(/&lt;/g, "<")
2426
- .replace(/&gt;/g, ">")
2427
- .replace(/&quot;/g, '"')
2428
- .replace(/&#92;/g, "\\")
2429
- // Decode &amp; LAST so an encoded entity like "&amp;lt;" round-trips to the
2430
- // literal "&lt;" instead of being double-unescaped to "<".
2431
- .replace(/&amp;/g, "&")
2432
- .replace(/\n{3,}/g, "\n\n")
2433
- .trim());
2434
- }
2435
- /**
2436
- * Exports all notes as a JSON structure for backup/migration.
2437
- *
2438
- * Exports complete note data including:
2439
- * - Metadata (id, title, dates, flags)
2440
- * - Content (HTML and plaintext)
2441
- * - Organization (folder, account)
2442
- *
2443
- * Note: Password-protected notes are included with metadata only (no content).
2444
- *
2445
- * @returns JSON-serializable export object
2446
- *
2447
- * @example
2448
- * ```typescript
2449
- * const snapshot = manager.exportNotesAsJson();
2450
- * fs.writeFileSync('notes-backup.json', JSON.stringify(snapshot, null, 2));
2451
- * ```
2452
- */
2453
- exportNotesAsJson() {
2454
- const accounts = this.listAccounts();
2455
- const exportData = {
2456
- exportDate: new Date().toISOString(),
2457
- version: "1.0",
2458
- accounts: [],
2459
- summary: { totalNotes: 0, totalFolders: 0, totalAccounts: accounts.length },
2460
- };
2461
- for (const account of accounts) {
2462
- const folders = this.listFolders(account.name);
2463
- const accountData = {
2464
- name: account.name,
2465
- folders: [],
2466
- };
2467
- for (const folder of folders) {
2468
- const folderData = {
2469
- name: folder.name,
2470
- notes: [],
2471
- };
2472
- // Get all note titles in this folder
2473
- const noteTitles = this.listNotes(account.name, folder.name);
2474
- for (const title of noteTitles) {
2475
- // Get note details
2476
- const note = this.getNoteDetails(title, account.name);
2477
- if (!note)
2478
- continue;
2479
- // Skip password-protected notes' content but include metadata
2480
- let content = "";
2481
- if (!note.passwordProtected) {
2482
- content = this.getNoteContent(title, account.name);
2483
- }
2484
- folderData.notes.push(this.exportNote(note, content));
2485
- exportData.summary.totalNotes++;
2486
- }
2487
- accountData.folders.push(folderData);
2488
- exportData.summary.totalFolders++;
2489
- }
2490
- exportData.accounts.push(accountData);
2491
- }
2492
- return exportData;
2493
- }
2494
- // ===========================================================================
2495
- // Markdown Conversion
2496
- // ===========================================================================
2497
- /**
2498
- * Turndown service instance for HTML to Markdown conversion.
2499
- * Configured for Apple Notes HTML quirks.
2500
- * Initialized lazily on first use.
2501
- */
2502
- turndownService;
2503
- /**
2504
- * Initialize the Turndown service with Apple Notes-specific rules.
2505
- */
2506
- initTurndownService() {
2507
- if (this.turndownService)
2508
- return;
2509
- this.turndownService = new TurndownService({
2510
- headingStyle: "atx",
2511
- codeBlockStyle: "fenced",
2512
- bulletListMarker: "-",
2513
- });
2514
- // Handle Apple Notes-specific HTML patterns
2515
- // Notes.app uses <div> instead of <p> for paragraphs
2516
- this.turndownService.addRule("notesDivs", {
2517
- filter: "div",
2518
- replacement: (content) => {
2519
- return content + "\n";
2520
- },
2521
- });
2522
- }
2523
- /**
2524
- * Converts HTML content to Markdown.
2525
- *
2526
- * @param html - HTML content from Notes.app
2527
- * @returns Markdown formatted content
2528
- */
2529
- htmlToMarkdown(html) {
2530
- this.initTurndownService();
2531
- return this.turndownService.turndown(html).trim();
2532
- }
2533
- /**
2534
- * Enriches markdown with checklist state from the NoteStore database.
2535
- *
2536
- * Apple Notes checklists appear as plain list items in the AppleScript HTML
2537
- * output. This method reads the protobuf data to get done/undone state and
2538
- * annotates matching list items with [x] or [ ] prefixes.
2539
- *
2540
- * Fails silently (returns original markdown) if the database is inaccessible
2541
- * or the note has no checklists.
2542
- *
2543
- * @param markdown - The base markdown content
2544
- * @param checklistItems - Checklist items with done state
2545
- * @returns Markdown with checklist annotations
2546
- */
2547
- enrichMarkdownWithChecklists(markdown, checklistItems) {
2548
- if (checklistItems.length === 0)
2549
- return markdown;
2550
- // Build a map of checklist text → done state
2551
- const checklistMap = new Map();
2552
- for (const item of checklistItems) {
2553
- checklistMap.set(item.text.trim(), item.done);
2554
- }
2555
- // Replace matching list items with checkbox syntax
2556
- const lines = markdown.split("\n");
2557
- const enriched = lines.map((line) => {
2558
- // Match markdown list items: "- text" or "* text"
2559
- const listMatch = line.match(/^(\s*[-*])\s+(.+)$/);
2560
- if (!listMatch)
2561
- return line;
2562
- const [, prefix, text] = listMatch;
2563
- const done = checklistMap.get(text.trim());
2564
- if (done === undefined)
2565
- return line;
2566
- // Remove from map so duplicate text lines aren't all converted
2567
- checklistMap.delete(text.trim());
2568
- return `${prefix} ${done ? "[x]" : "[ ]"} ${text}`;
2569
- });
2570
- return enriched.join("\n");
2571
- }
2572
- /**
2573
- * Gets note content as Markdown by title.
2574
- *
2575
- * If the note contains checklists and the NoteStore database is accessible
2576
- * (Full Disk Access required), checklist items will be annotated with
2577
- * [x] (done) or [ ] (undone) prefixes.
2578
- *
2579
- * @param title - Exact title of the note
2580
- * @param account - Account containing the note (defaults to iCloud)
2581
- * @returns Markdown content, or empty string if not found
2582
- *
2583
- * @example
2584
- * ```typescript
2585
- * const md = manager.getNoteMarkdown("Shopping List");
2586
- * console.log(md); // "# Shopping List\n\n- [x] Eggs\n- [ ] Milk"
2587
- * ```
2588
- */
2589
- getNoteMarkdown(title, account) {
2590
- const html = this.getNoteContent(title, account);
2591
- if (!html)
2592
- return "";
2593
- let markdown = this.htmlToMarkdown(html);
2594
- // Try to enrich with checklist state (requires note ID)
2595
- const note = this.getNoteDetails(title, account);
2596
- if (note?.id) {
2597
- const result = getChecklistItems(note.id);
2598
- if (result.items) {
2599
- markdown = this.enrichMarkdownWithChecklists(markdown, result.items);
2600
- }
2601
- }
2602
- return markdown;
2603
- }
2604
- /**
2605
- * Gets note content as Markdown by ID.
2606
- *
2607
- * This is more reliable than getNoteMarkdown() because IDs are unique
2608
- * across all accounts, while titles can be duplicated.
2609
- *
2610
- * If the note contains checklists and the NoteStore database is accessible
2611
- * (Full Disk Access required), checklist items will be annotated with
2612
- * [x] (done) or [ ] (undone) prefixes.
2613
- *
2614
- * @param id - CoreData URL identifier for the note
2615
- * @returns Markdown content, or empty string if not found
2616
- */
2617
- getNoteMarkdownById(id) {
2618
- const html = this.getNoteContentById(id);
2619
- if (!html)
2620
- return "";
2621
- let markdown = this.htmlToMarkdown(html);
2622
- // Try to enrich with checklist state
2623
- const result = getChecklistItems(id);
2624
- if (result.items) {
2625
- markdown = this.enrichMarkdownWithChecklists(markdown, result.items);
2626
- }
2627
- return markdown;
2628
- }
2629
- }