apple-notes-mcp 1.4.4 → 2.0.1

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
  /**
@@ -208,8 +225,20 @@ export function generateFallbackId() {
208
225
  * // Returns: Date object for Dec 27, 2025 3:44:02 PM
209
226
  */
210
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".
211
240
  // Remove the "date " prefix if present
212
- const withoutPrefix = appleScriptDate.replace(/^date\s+/, "");
241
+ const withoutPrefix = s.replace(/^date\s+/, "");
213
242
  // Replace " at " with a space for standard date parsing
214
243
  // "Saturday, December 27, 2025 at 3:44:02 PM" ->
215
244
  // "Saturday, December 27, 2025 3:44:02 PM"
@@ -246,6 +275,19 @@ export function buildAppleScriptDateVar(date, varName = "thresholdDate") {
246
275
  `set time of ${varName} to ${timeInSeconds}`,
247
276
  ].join("\n");
248
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
+ }
249
291
  /**
250
292
  * Parses AppleScript note properties output into structured data.
251
293
  *
@@ -258,37 +300,22 @@ export function buildAppleScriptDateVar(date, varName = "thresholdDate") {
258
300
  * @returns Parsed properties, or null if format is invalid
259
301
  */
260
302
  export function parseNotePropertiesOutput(output) {
261
- // Extract dates using regex - they start with "date " and have a recognizable format
262
- // Pattern matches: "date Saturday, December 27, 2025 at 3:44:02 PM"
263
- const dateMatches = output.match(/date [A-Z][^,]*(?:, [A-Z][^,]*)* at \d+:\d+:\d+ [AP]M/g) || [];
264
- // Extract title (everything before the first comma)
265
- const firstComma = output.indexOf(",");
266
- if (firstComma === -1) {
267
- console.error("Unexpected response format: no comma found in note properties");
268
- return null;
269
- }
270
- const title = output.substring(0, firstComma).trim();
271
- // Extract ID (between first and second comma)
272
- const afterTitle = output.substring(firstComma + 1);
273
- const secondComma = afterTitle.indexOf(",");
274
- if (secondComma === -1) {
275
- 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");
276
309
  return null;
277
310
  }
278
- const id = afterTitle.substring(0, secondComma).trim();
279
- // Extract boolean values from the end (shared, passwordProtected)
280
- // They appear as ", true" or ", false" at the end
281
- const boolPattern = /, (true|false), (true|false)$/;
282
- const boolMatch = output.match(boolPattern);
283
- const shared = boolMatch ? boolMatch[1] === "true" : false;
284
- const passwordProtected = boolMatch ? boolMatch[2] === "true" : false;
311
+ const [title, id, createdStr, modifiedStr, sharedStr, ppStr] = parts;
285
312
  return {
286
- title,
287
- id,
288
- created: dateMatches[0] ? parseAppleScriptDate(dateMatches[0]) : new Date(),
289
- modified: dateMatches[1] ? parseAppleScriptDate(dateMatches[1]) : new Date(),
290
- shared,
291
- 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",
292
319
  };
293
320
  }
294
321
  /**
@@ -395,23 +422,6 @@ function buildAppLevelScript(command) {
395
422
  // =============================================================================
396
423
  // Result Parsing Utilities
397
424
  // =============================================================================
398
- /**
399
- * Parses a comma-separated list from AppleScript output.
400
- *
401
- * AppleScript often returns lists as comma-separated strings:
402
- * "Note 1, Note 2, Note 3"
403
- *
404
- * This function splits and cleans the output.
405
- *
406
- * @param output - Raw AppleScript output
407
- * @returns Array of trimmed, non-empty strings
408
- */
409
- function parseCommaSeparatedList(output) {
410
- return output
411
- .split(",")
412
- .map((item) => item.trim())
413
- .filter((item) => item.length > 0);
414
- }
415
425
  /**
416
426
  * Extracts a CoreData ID from AppleScript output.
417
427
  *
@@ -624,7 +634,7 @@ export class AppleNotesManager {
624
634
  */
625
635
  searchNotes(query, searchContent = false, account, folder, modifiedSince, limit) {
626
636
  const targetAccount = this.resolveAccount(account);
627
- const safeQuery = escapeForAppleScript(query);
637
+ const safeQuery = escapePlainStringForAppleScript(query);
628
638
  const safeLimit = limit !== undefined && limit > 0 ? Math.floor(limit) : undefined;
629
639
  // Build the where clause based on search type
630
640
  // AppleScript uses 'name' for title and 'body' for content
@@ -665,33 +675,33 @@ export class AppleNotesManager {
665
675
  set noteName to name of n
666
676
  set noteId to id of n
667
677
  set noteFolder to name of container of n
668
- set end of resultList to noteName & "|||" & noteId & "|||" & noteFolder
678
+ set end of resultList to noteName & ${AS_FIELD_SEP} & noteId & ${AS_FIELD_SEP} & noteFolder
669
679
  on error
670
680
  try
671
681
  set noteName to name of n
672
682
  set noteId to id of n
673
- set end of resultList to noteName & "|||" & noteId & "|||" & "Notes"
683
+ set end of resultList to noteName & ${AS_FIELD_SEP} & noteId & ${AS_FIELD_SEP} & "Notes"
674
684
  end try
675
685
  end try
676
686
  end repeat
677
- set AppleScript's text item delimiters to "|||ITEM|||"
687
+ set AppleScript's text item delimiters to ${AS_RECORD_SEP}
678
688
  return resultList as text
679
689
  `;
680
690
  const script = buildAccountScopedScript({ account: targetAccount }, searchCommand);
681
691
  const result = executeAppleScript(script);
682
692
  if (!result.success) {
683
- console.error(`Failed to search notes for "${query}":`, result.error);
684
- 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"}`);
685
695
  }
686
696
  // Handle empty results
687
697
  if (!result.output.trim()) {
688
698
  return [];
689
699
  }
690
- // Parse the delimited output: "name|||id|||folder|||ITEM|||name|||id|||folder..."
691
- 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);
692
702
  const notes = [];
693
703
  for (const item of items) {
694
- const [title, id, folder] = item.split("|||");
704
+ const [title, id, folder] = item.split(FIELD_SEP);
695
705
  if (!title?.trim())
696
706
  continue;
697
707
  notes.push({
@@ -728,7 +738,7 @@ export class AppleNotesManager {
728
738
  */
729
739
  getNoteContent(title, account) {
730
740
  const targetAccount = this.resolveAccount(account);
731
- const safeTitle = escapeForAppleScript(title);
741
+ const safeTitle = escapePlainStringForAppleScript(title);
732
742
  // Retrieve the body property of the note
733
743
  const getCommand = `get body of note "${safeTitle}"`;
734
744
  const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
@@ -780,8 +790,11 @@ export class AppleNotesManager {
780
790
  // Note IDs work at the application level, not scoped to account
781
791
  const getCommand = `
782
792
  set n to note id "${safeId}"
783
- set noteProps to {name of n, id of n, creation date of n, modification date of n, shared of n, password protected of n}
784
- 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
785
798
  `;
786
799
  const script = buildAppLevelScript(getCommand);
787
800
  const result = executeAppleScript(script);
@@ -817,12 +830,15 @@ export class AppleNotesManager {
817
830
  */
818
831
  getNoteDetails(title, account) {
819
832
  const targetAccount = this.resolveAccount(account);
820
- const safeTitle = escapeForAppleScript(title);
833
+ const safeTitle = escapePlainStringForAppleScript(title);
821
834
  // Fetch multiple properties at once
822
835
  const getCommand = `
823
836
  set n to note "${safeTitle}"
824
- set noteProps to {name of n, id of n, creation date of n, modification date of n, shared of n, password protected of n}
825
- 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
826
842
  `;
827
843
  const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
828
844
  const result = executeAppleScript(script);
@@ -859,7 +875,7 @@ export class AppleNotesManager {
859
875
  */
860
876
  deleteNote(title, account) {
861
877
  const targetAccount = this.resolveAccount(account);
862
- const safeTitle = escapeForAppleScript(title);
878
+ const safeTitle = escapePlainStringForAppleScript(title);
863
879
  const deleteCommand = `delete note "${safeTitle}"`;
864
880
  const script = buildAccountScopedScript({ account: targetAccount }, deleteCommand);
865
881
  const result = executeAppleScript(script);
@@ -915,7 +931,7 @@ export class AppleNotesManager {
915
931
  validateLength(newTitle, MAX_TITLE_LENGTH, "Note title");
916
932
  validateLength(newContent, MAX_CONTENT_LENGTH, "Note content");
917
933
  const targetAccount = this.resolveAccount(account);
918
- const safeCurrentTitle = escapeForAppleScript(title);
934
+ const safeCurrentTitle = escapePlainStringForAppleScript(title);
919
935
  let fullBody;
920
936
  if (format === "html") {
921
937
  // HTML mode: content is the complete body, escaped only for AppleScript string
@@ -1026,38 +1042,41 @@ export class AppleNotesManager {
1026
1042
  repeat with n in ${notesSource}${limitCheck}
1027
1043
  set end of resultList to name of n
1028
1044
  end repeat
1029
- set AppleScript's text item delimiters to "|||"
1045
+ set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1030
1046
  return resultList as text
1031
1047
  `;
1032
1048
  const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
1033
1049
  const result = executeAppleScript(script);
1034
1050
  if (!result.success) {
1035
- console.error("Failed to list notes:", result.error);
1036
- return [];
1051
+ throw new Error(`Failed to list notes: ${result.error ?? "unknown error"}`);
1037
1052
  }
1038
1053
  if (!result.output.trim()) {
1039
1054
  return [];
1040
1055
  }
1041
1056
  return result.output
1042
- .split("|||")
1057
+ .split(RECORD_SEP)
1043
1058
  .map((item) => item.trim())
1044
1059
  .filter((item) => item.length > 0);
1045
1060
  }
1046
- // Simple path: no date or limit filters
1047
- let listCommand;
1048
- if (folder) {
1049
- listCommand = `get name of notes of ${buildFolderReference(folder)}`;
1050
- }
1051
- else {
1052
- listCommand = `get name of notes`;
1053
- }
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
+ `;
1054
1069
  const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
1055
1070
  const result = executeAppleScript(script);
1056
1071
  if (!result.success) {
1057
- console.error("Failed to list notes:", result.error);
1058
- return [];
1072
+ throw new Error(`Failed to list notes: ${result.error ?? "unknown error"}`);
1059
1073
  }
1060
- 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);
1061
1080
  }
1062
1081
  /**
1063
1082
  * Lists all shared (collaborative) notes across all accounts.
@@ -1084,10 +1103,12 @@ export class AppleNotesManager {
1084
1103
  set resultList to {}
1085
1104
  repeat with n in notes
1086
1105
  if shared of n is true then
1087
- 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)
1088
1109
  end if
1089
1110
  end repeat
1090
- set AppleScript's text item delimiters to "|||ITEM|||"
1111
+ set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1091
1112
  return resultList as text
1092
1113
  `);
1093
1114
  const result = executeAppleScript(script);
@@ -1099,10 +1120,10 @@ export class AppleNotesManager {
1099
1120
  if (!output) {
1100
1121
  continue;
1101
1122
  }
1102
- // Parse delimited output: "name|||id|||created|||modified|||shared|||pp|||ITEM|||..."
1103
- 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);
1104
1125
  for (const item of items) {
1105
- const parts = item.split("|||");
1126
+ const parts = item.split(FIELD_SEP);
1106
1127
  if (parts.length >= 6) {
1107
1128
  const title = parts[0].trim();
1108
1129
  const id = parts[1].trim();
@@ -1164,8 +1185,7 @@ export class AppleNotesManager {
1164
1185
  const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
1165
1186
  const result = executeAppleScript(script);
1166
1187
  if (!result.success) {
1167
- console.error("Failed to list folders:", result.error);
1168
- return [];
1188
+ throw new Error(`Failed to list folders: ${result.error ?? "unknown error"}`);
1169
1189
  }
1170
1190
  if (!result.output.trim()) {
1171
1191
  return [];
@@ -1231,7 +1251,7 @@ export class AppleNotesManager {
1231
1251
  continue;
1232
1252
  }
1233
1253
  // Folder doesn't exist — create it
1234
- const segmentName = escapeForAppleScript(parts[i]);
1254
+ const segmentName = escapePlainStringForAppleScript(parts[i]);
1235
1255
  let createCommand;
1236
1256
  if (i === 0) {
1237
1257
  createCommand = `make new folder with properties {name:"${segmentName}"}`;
@@ -1390,15 +1410,22 @@ export class AppleNotesManager {
1390
1410
  * @returns Array of Account objects
1391
1411
  */
1392
1412
  listAccounts() {
1393
- 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
+ `;
1394
1420
  const script = buildAppLevelScript(listCommand);
1395
1421
  const result = executeAppleScript(script);
1396
1422
  if (!result.success) {
1397
- console.error("Failed to list accounts:", result.error);
1398
- return [];
1423
+ throw new Error(`Failed to list accounts: ${result.error ?? "unknown error"}`);
1399
1424
  }
1400
- // Convert names to Account objects
1401
- 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);
1402
1429
  return names.map((name) => ({ name }));
1403
1430
  }
1404
1431
  // ===========================================================================
@@ -1523,25 +1550,36 @@ export class AppleNotesManager {
1523
1550
  const accounts = this.listAccounts();
1524
1551
  const accountStats = [];
1525
1552
  let totalNotes = 0;
1526
- // 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).
1527
1556
  for (const account of accounts) {
1528
- 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
+ }
1529
1568
  const folderStats = [];
1530
1569
  let accountTotal = 0;
1531
- for (const folder of folders) {
1532
- const notes = this.listNotes(account.name, folder.name);
1533
- 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;
1534
1575
  accountTotal += noteCount;
1535
- folderStats.push({
1536
- name: folder.name,
1537
- noteCount,
1538
- });
1576
+ folderStats.push({ name: (fname ?? "").trim(), noteCount });
1539
1577
  }
1540
1578
  totalNotes += accountTotal;
1541
1579
  accountStats.push({
1542
1580
  name: account.name,
1543
1581
  totalNotes: accountTotal,
1544
- folderCount: folders.length,
1582
+ folderCount: folderStats.length,
1545
1583
  folders: folderStats,
1546
1584
  });
1547
1585
  }
@@ -1557,51 +1595,41 @@ export class AppleNotesManager {
1557
1595
  * Helper to get counts of recently modified notes.
1558
1596
  */
1559
1597
  getRecentlyModifiedCounts() {
1560
- // 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);
1561
1606
  const script = `
1562
1607
  tell application "Notes"
1563
- 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
1564
1614
  repeat with acct in accounts
1565
- repeat with n in notes of acct
1566
- set end of modDates to modification date of n
1567
- end repeat
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))
1568
1618
  end repeat
1569
- set output to ""
1570
- repeat with d in modDates
1571
- set output to output & (d as string) & "|||"
1572
- end repeat
1573
- return output
1619
+ return (c1 as text) & ${AS_FIELD_SEP} & (c7 as text) & ${AS_FIELD_SEP} & (c30 as text)
1574
1620
  end tell
1575
1621
  `;
1576
1622
  const result = executeAppleScript(script);
1577
- if (!result.success || !result.output) {
1578
- return { last24h: 0, last7d: 0, last30d: 0 };
1579
- }
1580
- const now = new Date();
1581
- const oneDayAgo = new Date(now.getTime() - 24 * 60 * 60 * 1000);
1582
- const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
1583
- const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
1584
- let last24h = 0;
1585
- let last7d = 0;
1586
- let last30d = 0;
1587
- const dateStrings = result.output.split("|||").filter((s) => s.trim());
1588
- for (const dateStr of dateStrings) {
1589
- try {
1590
- const date = new Date(dateStr.trim());
1591
- if (isNaN(date.getTime()))
1592
- continue;
1593
- if (date >= oneDayAgo)
1594
- last24h++;
1595
- if (date >= sevenDaysAgo)
1596
- last7d++;
1597
- if (date >= thirtyDaysAgo)
1598
- last30d++;
1599
- }
1600
- catch {
1601
- // Skip invalid date strings
1602
- }
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"}`);
1603
1626
  }
1604
- 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]) };
1605
1633
  }
1606
1634
  // ===========================================================================
1607
1635
  // Attachments
@@ -1631,11 +1659,11 @@ export class AppleNotesManager {
1631
1659
  set attachId to id of a
1632
1660
  set attachName to name of a
1633
1661
  set attachType to content identifier of a
1634
- set end of attachmentList to attachId & "|||" & attachName & "|||" & attachType
1662
+ set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachType
1635
1663
  end repeat
1636
1664
  set output to ""
1637
1665
  repeat with item in attachmentList
1638
- set output to output & item & "ITEM"
1666
+ set output to output & item & ${AS_RECORD_SEP}
1639
1667
  end repeat
1640
1668
  return output
1641
1669
  end tell
@@ -1649,9 +1677,9 @@ export class AppleNotesManager {
1649
1677
  }
1650
1678
  // Parse the results
1651
1679
  const attachments = [];
1652
- const items = result.output.split("ITEM").filter((s) => s.trim());
1680
+ const items = result.output.split(RECORD_SEP).filter((s) => s.trim());
1653
1681
  for (const item of items) {
1654
- const parts = item.split("|||");
1682
+ const parts = item.split(FIELD_SEP);
1655
1683
  if (parts.length >= 3) {
1656
1684
  attachments.push({
1657
1685
  id: parts[0].trim(),
@@ -1671,7 +1699,7 @@ export class AppleNotesManager {
1671
1699
  */
1672
1700
  listAttachments(title, account) {
1673
1701
  const targetAccount = this.resolveAccount(account);
1674
- const safeTitle = escapeForAppleScript(title);
1702
+ const safeTitle = escapePlainStringForAppleScript(title);
1675
1703
  const script = `
1676
1704
  tell application "Notes"
1677
1705
  tell account "${targetAccount}"
@@ -1681,11 +1709,11 @@ export class AppleNotesManager {
1681
1709
  set attachId to id of a
1682
1710
  set attachName to name of a
1683
1711
  set attachType to content identifier of a
1684
- set end of attachmentList to attachId & "|||" & attachName & "|||" & attachType
1712
+ set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachType
1685
1713
  end repeat
1686
1714
  set output to ""
1687
1715
  repeat with item in attachmentList
1688
- set output to output & item & "ITEM"
1716
+ set output to output & item & ${AS_RECORD_SEP}
1689
1717
  end repeat
1690
1718
  return output
1691
1719
  end tell
@@ -1700,9 +1728,9 @@ export class AppleNotesManager {
1700
1728
  }
1701
1729
  // Parse the results
1702
1730
  const attachments = [];
1703
- const items = result.output.split("ITEM").filter((s) => s.trim());
1731
+ const items = result.output.split(RECORD_SEP).filter((s) => s.trim());
1704
1732
  for (const item of items) {
1705
- const parts = item.split("|||");
1733
+ const parts = item.split(FIELD_SEP);
1706
1734
  if (parts.length >= 3) {
1707
1735
  attachments.push({
1708
1736
  id: parts[0].trim(),
@@ -1713,6 +1741,92 @@ export class AppleNotesManager {
1713
1741
  }
1714
1742
  return attachments;
1715
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 = escapePlainStringForAppleScript(attachmentId);
1763
+ const safePath = escapePlainStringForAppleScript(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
+ }
1716
1830
  // ===========================================================================
1717
1831
  // Batch Operations
1718
1832
  // ===========================================================================