apple-notes-mcp 1.4.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,8 +16,25 @@
16
16
  */
17
17
  import { executeAppleScript } from "../utils/applescript.js";
18
18
  import { getChecklistItems } from "../utils/checklistParser.js";
19
+ import { assertSafeSavePath, readFileBase64, fileSize, makeTempDir, cleanupTempDir, } from "../utils/attachmentFs.js";
20
+ import { existsSync } from "fs";
19
21
  import TurndownService from "turndown";
20
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
+ // =============================================================================
21
38
  // Text Processing Utilities
22
39
  // =============================================================================
23
40
  /**
@@ -97,6 +114,22 @@ export function escapeHtmlForAppleScript(htmlContent) {
97
114
  // We do NOT re-encode HTML entities since content is already HTML from Notes.app
98
115
  return htmlContent.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
99
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&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
+ }
100
133
  // =============================================================================
101
134
  // Input Validation & Sanitization
102
135
  // =============================================================================
@@ -154,7 +187,7 @@ export function sanitizeId(id) {
154
187
  */
155
188
  function sanitizeAccountName(account) {
156
189
  validateLength(account, MAX_ACCOUNT_LENGTH, "Account name");
157
- return escapeForAppleScript(account);
190
+ return escapePlainStringForAppleScript(account);
158
191
  }
159
192
  /**
160
193
  * Counter for generating unique fallback IDs within the same millisecond.
@@ -192,8 +225,20 @@ export function generateFallbackId() {
192
225
  * // Returns: Date object for Dec 27, 2025 3:44:02 PM
193
226
  */
194
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".
195
240
  // Remove the "date " prefix if present
196
- const withoutPrefix = appleScriptDate.replace(/^date\s+/, "");
241
+ const withoutPrefix = s.replace(/^date\s+/, "");
197
242
  // Replace " at " with a space for standard date parsing
198
243
  // "Saturday, December 27, 2025 at 3:44:02 PM" ->
199
244
  // "Saturday, December 27, 2025 3:44:02 PM"
@@ -230,6 +275,19 @@ export function buildAppleScriptDateVar(date, varName = "thresholdDate") {
230
275
  `set time of ${varName} to ${timeInSeconds}`,
231
276
  ].join("\n");
232
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
+ }
233
291
  /**
234
292
  * Parses AppleScript note properties output into structured data.
235
293
  *
@@ -242,37 +300,22 @@ export function buildAppleScriptDateVar(date, varName = "thresholdDate") {
242
300
  * @returns Parsed properties, or null if format is invalid
243
301
  */
244
302
  export function parseNotePropertiesOutput(output) {
245
- // Extract dates using regex - they start with "date " and have a recognizable format
246
- // Pattern matches: "date Saturday, December 27, 2025 at 3:44:02 PM"
247
- const dateMatches = output.match(/date [A-Z][^,]*(?:, [A-Z][^,]*)* at \d+:\d+:\d+ [AP]M/g) || [];
248
- // Extract title (everything before the first comma)
249
- const firstComma = output.indexOf(",");
250
- if (firstComma === -1) {
251
- console.error("Unexpected response format: no comma found in note properties");
252
- return null;
253
- }
254
- const title = output.substring(0, firstComma).trim();
255
- // Extract ID (between first and second comma)
256
- const afterTitle = output.substring(firstComma + 1);
257
- const secondComma = afterTitle.indexOf(",");
258
- if (secondComma === -1) {
259
- console.error("Unexpected response format: missing second comma in note properties");
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");
260
309
  return null;
261
310
  }
262
- const id = afterTitle.substring(0, secondComma).trim();
263
- // Extract boolean values from the end (shared, passwordProtected)
264
- // They appear as ", true" or ", false" at the end
265
- const boolPattern = /, (true|false), (true|false)$/;
266
- const boolMatch = output.match(boolPattern);
267
- const shared = boolMatch ? boolMatch[1] === "true" : false;
268
- const passwordProtected = boolMatch ? boolMatch[2] === "true" : false;
311
+ const [title, id, createdStr, modifiedStr, sharedStr, ppStr] = parts;
269
312
  return {
270
- title,
271
- id,
272
- created: dateMatches[0] ? parseAppleScriptDate(dateMatches[0]) : new Date(),
273
- modified: dateMatches[1] ? parseAppleScriptDate(dateMatches[1]) : new Date(),
274
- shared,
275
- passwordProtected,
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",
276
319
  };
277
320
  }
278
321
  /**
@@ -325,7 +368,7 @@ export function buildFolderReference(folderPath) {
325
368
  // Build inside-out: last part is innermost, first part is outermost
326
369
  return parts
327
370
  .reverse()
328
- .map((part) => `folder "${escapeForAppleScript(part)}"`)
371
+ .map((part) => `folder "${escapePlainStringForAppleScript(part)}"`)
329
372
  .join(" of ");
330
373
  }
331
374
  /**
@@ -379,23 +422,6 @@ function buildAppLevelScript(command) {
379
422
  // =============================================================================
380
423
  // Result Parsing Utilities
381
424
  // =============================================================================
382
- /**
383
- * Parses a comma-separated list from AppleScript output.
384
- *
385
- * AppleScript often returns lists as comma-separated strings:
386
- * "Note 1, Note 2, Note 3"
387
- *
388
- * This function splits and cleans the output.
389
- *
390
- * @param output - Raw AppleScript output
391
- * @returns Array of trimmed, non-empty strings
392
- */
393
- function parseCommaSeparatedList(output) {
394
- return output
395
- .split(",")
396
- .map((item) => item.trim())
397
- .filter((item) => item.length > 0);
398
- }
399
425
  /**
400
426
  * Extracts a CoreData ID from AppleScript output.
401
427
  *
@@ -649,33 +675,33 @@ export class AppleNotesManager {
649
675
  set noteName to name of n
650
676
  set noteId to id of n
651
677
  set noteFolder to name of container of n
652
- set end of resultList to noteName & "|||" & noteId & "|||" & noteFolder
678
+ set end of resultList to noteName & ${AS_FIELD_SEP} & noteId & ${AS_FIELD_SEP} & noteFolder
653
679
  on error
654
680
  try
655
681
  set noteName to name of n
656
682
  set noteId to id of n
657
- set end of resultList to noteName & "|||" & noteId & "|||" & "Notes"
683
+ set end of resultList to noteName & ${AS_FIELD_SEP} & noteId & ${AS_FIELD_SEP} & "Notes"
658
684
  end try
659
685
  end try
660
686
  end repeat
661
- set AppleScript's text item delimiters to "|||ITEM|||"
687
+ set AppleScript's text item delimiters to ${AS_RECORD_SEP}
662
688
  return resultList as text
663
689
  `;
664
690
  const script = buildAccountScopedScript({ account: targetAccount }, searchCommand);
665
691
  const result = executeAppleScript(script);
666
692
  if (!result.success) {
667
- console.error(`Failed to search notes for "${query}":`, result.error);
668
- return [];
693
+ // Surface the failure (#19) an empty array would look like "no matches".
694
+ throw new Error(`Failed to search notes for "${query}": ${result.error ?? "unknown error"}`);
669
695
  }
670
696
  // Handle empty results
671
697
  if (!result.output.trim()) {
672
698
  return [];
673
699
  }
674
- // Parse the delimited output: "name|||id|||folder|||ITEM|||name|||id|||folder..."
675
- const items = result.output.split("|||ITEM|||");
700
+ // Parse the control-char-delimited output (#18): fields by FIELD_SEP, records by RECORD_SEP.
701
+ const items = result.output.split(RECORD_SEP);
676
702
  const notes = [];
677
703
  for (const item of items) {
678
- const [title, id, folder] = item.split("|||");
704
+ const [title, id, folder] = item.split(FIELD_SEP);
679
705
  if (!title?.trim())
680
706
  continue;
681
707
  notes.push({
@@ -764,8 +790,11 @@ export class AppleNotesManager {
764
790
  // Note IDs work at the application level, not scoped to account
765
791
  const getCommand = `
766
792
  set n to note id "${safeId}"
767
- set noteProps to {name of n, id of n, creation date of n, modification date of n, shared of n, password protected of n}
768
- return noteProps
793
+ set cd to creation date of n
794
+ set md to modification date of n
795
+ set noteProps to {name of n, id of n, ${asDatePartsExpr("cd")}, ${asDatePartsExpr("md")}, (shared of n as text), (password protected of n as text)}
796
+ set AppleScript's text item delimiters to ${AS_FIELD_SEP}
797
+ return noteProps as text
769
798
  `;
770
799
  const script = buildAppLevelScript(getCommand);
771
800
  const result = executeAppleScript(script);
@@ -805,8 +834,11 @@ export class AppleNotesManager {
805
834
  // Fetch multiple properties at once
806
835
  const getCommand = `
807
836
  set n to note "${safeTitle}"
808
- set noteProps to {name of n, id of n, creation date of n, modification date of n, shared of n, password protected of n}
809
- return noteProps
837
+ set cd to creation date of n
838
+ set md to modification date of n
839
+ set noteProps to {name of n, id of n, ${asDatePartsExpr("cd")}, ${asDatePartsExpr("md")}, (shared of n as text), (password protected of n as text)}
840
+ set AppleScript's text item delimiters to ${AS_FIELD_SEP}
841
+ return noteProps as text
810
842
  `;
811
843
  const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
812
844
  const result = executeAppleScript(script);
@@ -1010,38 +1042,41 @@ export class AppleNotesManager {
1010
1042
  repeat with n in ${notesSource}${limitCheck}
1011
1043
  set end of resultList to name of n
1012
1044
  end repeat
1013
- set AppleScript's text item delimiters to "|||"
1045
+ set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1014
1046
  return resultList as text
1015
1047
  `;
1016
1048
  const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
1017
1049
  const result = executeAppleScript(script);
1018
1050
  if (!result.success) {
1019
- console.error("Failed to list notes:", result.error);
1020
- return [];
1051
+ throw new Error(`Failed to list notes: ${result.error ?? "unknown error"}`);
1021
1052
  }
1022
1053
  if (!result.output.trim()) {
1023
1054
  return [];
1024
1055
  }
1025
1056
  return result.output
1026
- .split("|||")
1057
+ .split(RECORD_SEP)
1027
1058
  .map((item) => item.trim())
1028
1059
  .filter((item) => item.length > 0);
1029
1060
  }
1030
- // Simple path: no date or limit filters
1031
- let listCommand;
1032
- if (folder) {
1033
- listCommand = `get name of notes of ${buildFolderReference(folder)}`;
1034
- }
1035
- else {
1036
- listCommand = `get name of notes`;
1037
- }
1061
+ // Simple path: no date or limit filters. Coerce the name list to text with a
1062
+ // control-char record separator so titles containing commas don't split (#18).
1063
+ const notesRef = folder ? `notes of ${buildFolderReference(folder)}` : `notes`;
1064
+ const listCommand = `
1065
+ set resultList to name of ${notesRef}
1066
+ set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1067
+ return resultList as text
1068
+ `;
1038
1069
  const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
1039
1070
  const result = executeAppleScript(script);
1040
1071
  if (!result.success) {
1041
- console.error("Failed to list notes:", result.error);
1042
- return [];
1072
+ throw new Error(`Failed to list notes: ${result.error ?? "unknown error"}`);
1043
1073
  }
1044
- return parseCommaSeparatedList(result.output);
1074
+ if (!result.output.trim())
1075
+ return [];
1076
+ return result.output
1077
+ .split(RECORD_SEP)
1078
+ .map((item) => item.trim())
1079
+ .filter((item) => item.length > 0);
1045
1080
  }
1046
1081
  /**
1047
1082
  * Lists all shared (collaborative) notes across all accounts.
@@ -1068,10 +1103,12 @@ export class AppleNotesManager {
1068
1103
  set resultList to {}
1069
1104
  repeat with n in notes
1070
1105
  if shared of n is true then
1071
- set end of resultList to (name of n) & "|||" & (id of n) & "|||" & (creation date of n as text) & "|||" & (modification date of n as text) & "|||" & (shared of n as text) & "|||" & (password protected of n as text)
1106
+ set cd to creation date of n
1107
+ set md to modification date of n
1108
+ 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)
1072
1109
  end if
1073
1110
  end repeat
1074
- set AppleScript's text item delimiters to "|||ITEM|||"
1111
+ set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1075
1112
  return resultList as text
1076
1113
  `);
1077
1114
  const result = executeAppleScript(script);
@@ -1083,10 +1120,10 @@ export class AppleNotesManager {
1083
1120
  if (!output) {
1084
1121
  continue;
1085
1122
  }
1086
- // Parse delimited output: "name|||id|||created|||modified|||shared|||pp|||ITEM|||..."
1087
- const items = output.split("|||ITEM|||");
1123
+ // Parse control-char-delimited output (#18): fields by FIELD_SEP, records by RECORD_SEP.
1124
+ const items = output.split(RECORD_SEP);
1088
1125
  for (const item of items) {
1089
- const parts = item.split("|||");
1126
+ const parts = item.split(FIELD_SEP);
1090
1127
  if (parts.length >= 6) {
1091
1128
  const title = parts[0].trim();
1092
1129
  const id = parts[1].trim();
@@ -1148,8 +1185,7 @@ export class AppleNotesManager {
1148
1185
  const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
1149
1186
  const result = executeAppleScript(script);
1150
1187
  if (!result.success) {
1151
- console.error("Failed to list folders:", result.error);
1152
- return [];
1188
+ throw new Error(`Failed to list folders: ${result.error ?? "unknown error"}`);
1153
1189
  }
1154
1190
  if (!result.output.trim()) {
1155
1191
  return [];
@@ -1374,15 +1410,22 @@ export class AppleNotesManager {
1374
1410
  * @returns Array of Account objects
1375
1411
  */
1376
1412
  listAccounts() {
1377
- const listCommand = `get name of accounts`;
1413
+ // Coerce the name list to text with a control-char record separator so an
1414
+ // account name containing a comma can't split into phantom accounts (#18).
1415
+ const listCommand = `
1416
+ set resultList to name of accounts
1417
+ set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1418
+ return resultList as text
1419
+ `;
1378
1420
  const script = buildAppLevelScript(listCommand);
1379
1421
  const result = executeAppleScript(script);
1380
1422
  if (!result.success) {
1381
- console.error("Failed to list accounts:", result.error);
1382
- return [];
1423
+ throw new Error(`Failed to list accounts: ${result.error ?? "unknown error"}`);
1383
1424
  }
1384
- // Convert names to Account objects
1385
- const names = parseCommaSeparatedList(result.output);
1425
+ const names = result.output
1426
+ .split(RECORD_SEP)
1427
+ .map((s) => s.trim())
1428
+ .filter((s) => s.length > 0);
1386
1429
  return names.map((name) => ({ name }));
1387
1430
  }
1388
1431
  // ===========================================================================
@@ -1507,25 +1550,36 @@ export class AppleNotesManager {
1507
1550
  const accounts = this.listAccounts();
1508
1551
  const accountStats = [];
1509
1552
  let totalNotes = 0;
1510
- // Collect stats per account
1553
+ // Collect stats per account with ONE bounded script per account (#20/#26):
1554
+ // count notes server-side per folder instead of fetching every note's name
1555
+ // (unbounded) via a listNotes call per folder (N+1 osascript spawns).
1511
1556
  for (const account of accounts) {
1512
- const folders = this.listFolders(account.name);
1557
+ const countScript = buildAccountScopedScript({ account: account.name }, `
1558
+ set out to ""
1559
+ repeat with fldr in folders
1560
+ set out to out & (name of fldr) & ${AS_FIELD_SEP} & (count of notes of fldr) & ${AS_RECORD_SEP}
1561
+ end repeat
1562
+ return out
1563
+ `);
1564
+ const res = executeAppleScript(countScript);
1565
+ if (!res.success) {
1566
+ throw new Error(`Failed to read folder stats for "${account.name}": ${res.error ?? "unknown error"}`);
1567
+ }
1513
1568
  const folderStats = [];
1514
1569
  let accountTotal = 0;
1515
- for (const folder of folders) {
1516
- const notes = this.listNotes(account.name, folder.name);
1517
- const noteCount = notes.length;
1570
+ for (const rec of res.output.split(RECORD_SEP)) {
1571
+ if (!rec.trim())
1572
+ continue;
1573
+ const [fname, cnt] = rec.split(FIELD_SEP);
1574
+ const noteCount = parseInt((cnt ?? "").trim(), 10) || 0;
1518
1575
  accountTotal += noteCount;
1519
- folderStats.push({
1520
- name: folder.name,
1521
- noteCount,
1522
- });
1576
+ folderStats.push({ name: (fname ?? "").trim(), noteCount });
1523
1577
  }
1524
1578
  totalNotes += accountTotal;
1525
1579
  accountStats.push({
1526
1580
  name: account.name,
1527
1581
  totalNotes: accountTotal,
1528
- folderCount: folders.length,
1582
+ folderCount: folderStats.length,
1529
1583
  folders: folderStats,
1530
1584
  });
1531
1585
  }
@@ -1541,51 +1595,41 @@ export class AppleNotesManager {
1541
1595
  * Helper to get counts of recently modified notes.
1542
1596
  */
1543
1597
  getRecentlyModifiedCounts() {
1544
- // Get modification dates for all notes across all accounts
1598
+ // Count server-side with locale-safe date variables (#20/#25): instead of
1599
+ // streaming every note's modification date to JS (unbounded, ENOBUFS-prone,
1600
+ // locale-fragile), let AppleScript count matches via a `whose` filter — three
1601
+ // counts per account, regardless of library size.
1602
+ const now = new Date();
1603
+ const d1 = new Date(now.getTime() - 24 * 60 * 60 * 1000);
1604
+ const d7 = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
1605
+ const d30 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
1545
1606
  const script = `
1546
1607
  tell application "Notes"
1547
- set modDates to {}
1608
+ ${buildAppleScriptDateVar(d1, "d1")}
1609
+ ${buildAppleScriptDateVar(d7, "d7")}
1610
+ ${buildAppleScriptDateVar(d30, "d30")}
1611
+ set c1 to 0
1612
+ set c7 to 0
1613
+ set c30 to 0
1548
1614
  repeat with acct in accounts
1549
- repeat with n in notes of acct
1550
- set end of modDates to modification date of n
1551
- end repeat
1552
- end repeat
1553
- set output to ""
1554
- repeat with d in modDates
1555
- set output to output & (d as string) & "|||"
1615
+ set c1 to c1 + (count of (notes of acct whose modification date >= d1))
1616
+ set c7 to c7 + (count of (notes of acct whose modification date >= d7))
1617
+ set c30 to c30 + (count of (notes of acct whose modification date >= d30))
1556
1618
  end repeat
1557
- return output
1619
+ return (c1 as text) & ${AS_FIELD_SEP} & (c7 as text) & ${AS_FIELD_SEP} & (c30 as text)
1558
1620
  end tell
1559
1621
  `;
1560
1622
  const result = executeAppleScript(script);
1561
- if (!result.success || !result.output) {
1562
- return { last24h: 0, last7d: 0, last30d: 0 };
1563
- }
1564
- const now = new Date();
1565
- const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
1566
- const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
1567
- const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
1568
- let last24h = 0;
1569
- let last7d = 0;
1570
- let last30d = 0;
1571
- const dateStrings = result.output.split("|||").filter((s) => s.trim());
1572
- for (const dateStr of dateStrings) {
1573
- try {
1574
- const date = new Date(dateStr.trim());
1575
- if (isNaN(date.getTime()))
1576
- continue;
1577
- if (date >= oneDayAgo)
1578
- last24h++;
1579
- if (date >= sevenDaysAgo)
1580
- last7d++;
1581
- if (date >= thirtyDaysAgo)
1582
- last30d++;
1583
- }
1584
- catch {
1585
- // Skip invalid date strings
1586
- }
1623
+ if (!result.success) {
1624
+ // Surface the failure (#19) rather than reporting fake zero recent activity.
1625
+ throw new Error(`Failed to read recent activity: ${result.error ?? "unknown error"}`);
1587
1626
  }
1588
- return { last24h, last7d, last30d };
1627
+ const parts = result.output.trim().split(FIELD_SEP);
1628
+ const toInt = (s) => {
1629
+ const n = parseInt((s ?? "").trim(), 10);
1630
+ return Number.isFinite(n) ? n : 0;
1631
+ };
1632
+ return { last24h: toInt(parts[0]), last7d: toInt(parts[1]), last30d: toInt(parts[2]) };
1589
1633
  }
1590
1634
  // ===========================================================================
1591
1635
  // Attachments
@@ -1615,11 +1659,11 @@ export class AppleNotesManager {
1615
1659
  set attachId to id of a
1616
1660
  set attachName to name of a
1617
1661
  set attachType to content identifier of a
1618
- set end of attachmentList to attachId & "|||" & attachName & "|||" & attachType
1662
+ set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachType
1619
1663
  end repeat
1620
1664
  set output to ""
1621
1665
  repeat with item in attachmentList
1622
- set output to output & item & "ITEM"
1666
+ set output to output & item & ${AS_RECORD_SEP}
1623
1667
  end repeat
1624
1668
  return output
1625
1669
  end tell
@@ -1633,9 +1677,9 @@ export class AppleNotesManager {
1633
1677
  }
1634
1678
  // Parse the results
1635
1679
  const attachments = [];
1636
- const items = result.output.split("ITEM").filter((s) => s.trim());
1680
+ const items = result.output.split(RECORD_SEP).filter((s) => s.trim());
1637
1681
  for (const item of items) {
1638
- const parts = item.split("|||");
1682
+ const parts = item.split(FIELD_SEP);
1639
1683
  if (parts.length >= 3) {
1640
1684
  attachments.push({
1641
1685
  id: parts[0].trim(),
@@ -1665,11 +1709,11 @@ export class AppleNotesManager {
1665
1709
  set attachId to id of a
1666
1710
  set attachName to name of a
1667
1711
  set attachType to content identifier of a
1668
- set end of attachmentList to attachId & "|||" & attachName & "|||" & attachType
1712
+ set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachType
1669
1713
  end repeat
1670
1714
  set output to ""
1671
1715
  repeat with item in attachmentList
1672
- set output to output & item & "ITEM"
1716
+ set output to output & item & ${AS_RECORD_SEP}
1673
1717
  end repeat
1674
1718
  return output
1675
1719
  end tell
@@ -1684,9 +1728,9 @@ export class AppleNotesManager {
1684
1728
  }
1685
1729
  // Parse the results
1686
1730
  const attachments = [];
1687
- const items = result.output.split("ITEM").filter((s) => s.trim());
1731
+ const items = result.output.split(RECORD_SEP).filter((s) => s.trim());
1688
1732
  for (const item of items) {
1689
- const parts = item.split("|||");
1733
+ const parts = item.split(FIELD_SEP);
1690
1734
  if (parts.length >= 3) {
1691
1735
  attachments.push({
1692
1736
  id: parts[0].trim(),
@@ -1697,6 +1741,92 @@ export class AppleNotesManager {
1697
1741
  }
1698
1742
  return attachments;
1699
1743
  }
1744
+ /**
1745
+ * Saves a single attachment of a note (identified by attachment id) to a file
1746
+ * on disk via Notes.app's AppleScript `save` (#27).
1747
+ *
1748
+ * @param noteId - CoreData URL identifier for the note
1749
+ * @param attachmentId - id of the attachment (from list-attachments)
1750
+ * @param savePath - absolute destination file path (within home / temp / /Volumes)
1751
+ * @returns { success, savedPath?, name?, contentType?, error? }
1752
+ */
1753
+ saveAttachmentById(noteId, attachmentId, savePath) {
1754
+ let abs;
1755
+ try {
1756
+ abs = assertSafeSavePath(savePath);
1757
+ }
1758
+ catch (e) {
1759
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
1760
+ }
1761
+ const safeNoteId = sanitizeId(noteId);
1762
+ const safeAttId = escapeForAppleScript(attachmentId);
1763
+ const safePath = escapeForAppleScript(abs);
1764
+ const script = `
1765
+ tell application "Notes"
1766
+ set theNote to note id "${safeNoteId}"
1767
+ set theAttachment to missing value
1768
+ repeat with a in attachments of theNote
1769
+ if (id of a as text) is "${safeAttId}" then
1770
+ set theAttachment to a
1771
+ exit repeat
1772
+ end if
1773
+ end repeat
1774
+ if theAttachment is missing value then
1775
+ return "ERR${AS_FIELD_SEP}attachment not found"
1776
+ end if
1777
+ save theAttachment in (POSIX file "${safePath}")
1778
+ return "OK${AS_FIELD_SEP}" & (name of theAttachment) & "${AS_FIELD_SEP}" & (content identifier of theAttachment)
1779
+ end tell
1780
+ `;
1781
+ const result = executeAppleScript(script);
1782
+ if (!result.success) {
1783
+ return { success: false, error: result.error ?? "unknown error" };
1784
+ }
1785
+ const parts = (result.output ?? "").trim().split(FIELD_SEP);
1786
+ if (parts[0] !== "OK") {
1787
+ return { success: false, error: parts[1]?.trim() || "attachment not found" };
1788
+ }
1789
+ if (!existsSync(abs) || fileSize(abs) === 0) {
1790
+ return { success: false, error: `Notes reported success but no file was written to ${abs}` };
1791
+ }
1792
+ return {
1793
+ success: true,
1794
+ savedPath: abs,
1795
+ name: parts[1]?.trim(),
1796
+ contentType: parts[2]?.trim(),
1797
+ };
1798
+ }
1799
+ /**
1800
+ * Fetches a note attachment as base64 (#27). Exports to a private temp file,
1801
+ * reads it, then deletes the temp copy.
1802
+ *
1803
+ * @param noteId - CoreData URL identifier for the note
1804
+ * @param attachmentId - id of the attachment
1805
+ * @returns { success, name?, contentType?, base64?, bytes?, error? }
1806
+ */
1807
+ getAttachmentBase64ById(noteId, attachmentId) {
1808
+ const dir = makeTempDir();
1809
+ try {
1810
+ const dest = `${dir}/attachment.bin`;
1811
+ const saved = this.saveAttachmentById(noteId, attachmentId, dest);
1812
+ if (!saved.success || !saved.savedPath) {
1813
+ return { success: false, error: saved.error };
1814
+ }
1815
+ return {
1816
+ success: true,
1817
+ name: saved.name,
1818
+ contentType: saved.contentType,
1819
+ base64: readFileBase64(saved.savedPath),
1820
+ bytes: fileSize(saved.savedPath),
1821
+ };
1822
+ }
1823
+ catch (e) {
1824
+ return { success: false, error: e instanceof Error ? e.message : String(e) };
1825
+ }
1826
+ finally {
1827
+ cleanupTempDir(dir);
1828
+ }
1829
+ }
1700
1830
  // ===========================================================================
1701
1831
  // Batch Operations
1702
1832
  // ===========================================================================