apple-notes-mcp 2.0.1 → 2.1.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.
@@ -664,24 +664,26 @@ export class AppleNotesManager {
664
664
  ? `
665
665
  if (count of resultList) >= ${safeLimit} then exit repeat`
666
666
  : "";
667
- // Get names, IDs, and folder for each matching note
668
- // We use a repeat loop to get all properties, separated by a delimiter
669
- // Note: Some notes may have inaccessible containers, so we wrap in try/on error
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
670
  const searchCommand = `
671
671
  ${dateSetup}set matchingNotes to ${notesSource} where ${whereClause}
672
672
  set resultList to {}
673
- repeat with n in matchingNotes${limitCheck}
673
+ set seenIds to {}
674
+ repeat with n in matchingNotes
674
675
  try
675
676
  set noteName to name of n
676
677
  set noteId to id of n
677
- set noteFolder to name of container of n
678
- set end of resultList to noteName & ${AS_FIELD_SEP} & noteId & ${AS_FIELD_SEP} & noteFolder
679
- on error
680
- try
681
- set noteName to name of n
682
- set noteId to id of n
683
- set end of resultList to noteName & ${AS_FIELD_SEP} & noteId & ${AS_FIELD_SEP} & "Notes"
684
- end try
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
685
687
  end try
686
688
  end repeat
687
689
  set AppleScript's text item delimiters to ${AS_RECORD_SEP}
@@ -700,12 +702,17 @@ export class AppleNotesManager {
700
702
  // Parse the control-char-delimited output (#18): fields by FIELD_SEP, records by RECORD_SEP.
701
703
  const items = result.output.split(RECORD_SEP);
702
704
  const notes = [];
705
+ const seenIds = new Set();
703
706
  for (const item of items) {
704
707
  const [title, id, folder] = item.split(FIELD_SEP);
705
708
  if (!title?.trim())
706
709
  continue;
710
+ const noteId = id?.trim() || generateFallbackId();
711
+ if (seenIds.has(noteId))
712
+ continue;
713
+ seenIds.add(noteId);
707
714
  notes.push({
708
- id: id?.trim() || generateFallbackId(), // Use real ID, fallback to unique temp ID
715
+ id: noteId,
709
716
  title: title.trim(),
710
717
  content: "", // Not fetched in search
711
718
  tags: [],
@@ -1032,15 +1039,24 @@ export class AppleNotesManager {
1032
1039
  notesSource = `(${baseNotesSource} whose modification date >= thresholdDate)`;
1033
1040
  }
1034
1041
  }
1035
- // Build the limit check
1042
+ // Build the limit check. Check after appending so deduped results,
1043
+ // rather than duplicate AppleScript references, determine the limit.
1036
1044
  const limitCheck = safeLimit !== undefined
1037
1045
  ? `
1038
1046
  if (count of resultList) >= ${safeLimit} then exit repeat`
1039
1047
  : "";
1040
1048
  const listCommand = `
1041
1049
  ${dateSetup}set resultList to {}
1042
- repeat with n in ${notesSource}${limitCheck}
1043
- set end of resultList to name of n
1050
+ set seenIds to {}
1051
+ repeat with n in ${notesSource}
1052
+ try
1053
+ set noteName to name of n
1054
+ set noteId to id of n
1055
+ if seenIds does not contain noteId then
1056
+ set end of seenIds to noteId
1057
+ set end of resultList to noteName & ${AS_FIELD_SEP} & noteId${limitCheck}
1058
+ end if
1059
+ end try
1044
1060
  end repeat
1045
1061
  set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1046
1062
  return resultList as text
@@ -1053,16 +1069,36 @@ export class AppleNotesManager {
1053
1069
  if (!result.output.trim()) {
1054
1070
  return [];
1055
1071
  }
1056
- return result.output
1057
- .split(RECORD_SEP)
1058
- .map((item) => item.trim())
1059
- .filter((item) => item.length > 0);
1072
+ const seenIds = new Set();
1073
+ const titles = [];
1074
+ for (const item of result.output.split(RECORD_SEP)) {
1075
+ const [title, id] = item.split(FIELD_SEP);
1076
+ if (!title?.trim())
1077
+ continue;
1078
+ const noteId = id?.trim() || generateFallbackId();
1079
+ if (seenIds.has(noteId))
1080
+ continue;
1081
+ seenIds.add(noteId);
1082
+ titles.push(title.trim());
1083
+ }
1084
+ return titles;
1060
1085
  }
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).
1086
+ // Simple path: no date or limit filters. Use a repeat loop so duplicate
1087
+ // CoreData note references can be deduped by ID before returning titles.
1063
1088
  const notesRef = folder ? `notes of ${buildFolderReference(folder)}` : `notes`;
1064
1089
  const listCommand = `
1065
- set resultList to name of ${notesRef}
1090
+ set resultList to {}
1091
+ set seenIds to {}
1092
+ repeat with n in ${notesRef}
1093
+ try
1094
+ set noteName to name of n
1095
+ set noteId to id of n
1096
+ if seenIds does not contain noteId then
1097
+ set end of seenIds to noteId
1098
+ set end of resultList to noteName & ${AS_FIELD_SEP} & noteId
1099
+ end if
1100
+ end try
1101
+ end repeat
1066
1102
  set AppleScript's text item delimiters to ${AS_RECORD_SEP}
1067
1103
  return resultList as text
1068
1104
  `;
@@ -1073,10 +1109,19 @@ export class AppleNotesManager {
1073
1109
  }
1074
1110
  if (!result.output.trim())
1075
1111
  return [];
1076
- return result.output
1077
- .split(RECORD_SEP)
1078
- .map((item) => item.trim())
1079
- .filter((item) => item.length > 0);
1112
+ const seenIds = new Set();
1113
+ const titles = [];
1114
+ for (const item of result.output.split(RECORD_SEP)) {
1115
+ const [title, id] = item.split(FIELD_SEP);
1116
+ if (!title?.trim())
1117
+ continue;
1118
+ const noteId = id?.trim() || generateFallbackId();
1119
+ if (seenIds.has(noteId))
1120
+ continue;
1121
+ seenIds.add(noteId);
1122
+ titles.push(title.trim());
1123
+ }
1124
+ return titles;
1080
1125
  }
1081
1126
  /**
1082
1127
  * Lists all shared (collaborative) notes across all accounts.
@@ -1549,10 +1594,16 @@ export class AppleNotesManager {
1549
1594
  getNotesStats() {
1550
1595
  const accounts = this.listAccounts();
1551
1596
  const accountStats = [];
1597
+ const warnings = [];
1552
1598
  let totalNotes = 0;
1553
1599
  // Collect stats per account with ONE bounded script per account (#20/#26):
1554
1600
  // count notes server-side per folder instead of fetching every note's name
1555
1601
  // (unbounded) via a listNotes call per folder (N+1 osascript spawns).
1602
+ //
1603
+ // Per-account failures degrade gracefully (#19): a single unreachable or
1604
+ // locked account is recorded as a coverage warning and skipped, rather than
1605
+ // discarding the stats for every healthy account. Only a total wipeout
1606
+ // (no account readable) is escalated to a thrown error below.
1556
1607
  for (const account of accounts) {
1557
1608
  const countScript = buildAccountScopedScript({ account: account.name }, `
1558
1609
  set out to ""
@@ -1563,7 +1614,8 @@ export class AppleNotesManager {
1563
1614
  `);
1564
1615
  const res = executeAppleScript(countScript);
1565
1616
  if (!res.success) {
1566
- throw new Error(`Failed to read folder stats for "${account.name}": ${res.error ?? "unknown error"}`);
1617
+ warnings.push({ scope: account.name, reason: res.error ?? "unknown error" });
1618
+ continue;
1567
1619
  }
1568
1620
  const folderStats = [];
1569
1621
  let accountTotal = 0;
@@ -1583,12 +1635,33 @@ export class AppleNotesManager {
1583
1635
  folders: folderStats,
1584
1636
  });
1585
1637
  }
1586
- // Get recently modified notes counts
1587
- const recentlyModified = this.getRecentlyModifiedCounts();
1638
+ // If every account failed, there is no data to report — surface the error
1639
+ // (#19) rather than returning a deceptively empty stats object.
1640
+ if (accounts.length > 0 && accountStats.length === 0) {
1641
+ throw new Error(`Failed to read folder stats for any of ${accounts.length} account(s): ${warnings
1642
+ .map((w) => `${w.scope} (${w.reason})`)
1643
+ .join("; ")}`);
1644
+ }
1645
+ // Get recently modified notes counts. A failure here is non-fatal — record a
1646
+ // coverage warning and report zeros, flagged as not-covered (#19), instead of
1647
+ // passing off fake zero activity as real.
1648
+ const recent = this.getRecentlyModifiedCounts();
1649
+ if (recent.error) {
1650
+ warnings.push({ scope: "recent-activity", reason: recent.error });
1651
+ }
1652
+ // scopes = each account + the recent-activity scan
1653
+ const scanned = accounts.length + 1;
1654
+ const covered = scanned - warnings.length;
1588
1655
  return {
1589
1656
  totalNotes,
1590
1657
  accounts: accountStats,
1591
- recentlyModified,
1658
+ recentlyModified: recent.counts,
1659
+ coverage: {
1660
+ complete: warnings.length === 0,
1661
+ scanned,
1662
+ covered,
1663
+ warnings,
1664
+ },
1592
1665
  };
1593
1666
  }
1594
1667
  /**
@@ -1621,15 +1694,22 @@ export class AppleNotesManager {
1621
1694
  `;
1622
1695
  const result = executeAppleScript(script);
1623
1696
  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"}`);
1697
+ // Non-fatal (#19): report the error to the caller so it becomes a coverage
1698
+ // warning, with zeroed counts, instead of throwing away the whole stats
1699
+ // result or passing off fake zero activity as real.
1700
+ return {
1701
+ counts: { last24h: 0, last7d: 0, last30d: 0 },
1702
+ error: result.error ?? "unknown error",
1703
+ };
1626
1704
  }
1627
1705
  const parts = result.output.trim().split(FIELD_SEP);
1628
1706
  const toInt = (s) => {
1629
1707
  const n = parseInt((s ?? "").trim(), 10);
1630
1708
  return Number.isFinite(n) ? n : 0;
1631
1709
  };
1632
- return { last24h: toInt(parts[0]), last7d: toInt(parts[1]), last30d: toInt(parts[2]) };
1710
+ return {
1711
+ counts: { last24h: toInt(parts[0]), last7d: toInt(parts[1]), last30d: toInt(parts[2]) },
1712
+ };
1633
1713
  }
1634
1714
  // ===========================================================================
1635
1715
  // Attachments
@@ -1858,29 +1938,94 @@ export class AppleNotesManager {
1858
1938
  * ```
1859
1939
  */
1860
1940
  batchDeleteNotes(ids) {
1861
- const results = [];
1862
- for (const id of ids) {
1863
- // First verify the note exists and isn't password protected
1864
- const note = this.getNoteById(id);
1865
- if (!note) {
1866
- results.push(this.createBatchResult(id, false, "Note not found"));
1867
- continue;
1941
+ if (ids.length === 0)
1942
+ return [];
1943
+ // Collapse the whole batch into ONE osascript spawn (#26): a single
1944
+ // app-level script loops over every id, with a per-id `try` so one bad note
1945
+ // can't abort the rest. The old path spawned 3 processes per note
1946
+ // (getNoteById + isNotePasswordProtectedById + deleteNoteById) — i.e. 3N
1947
+ // spawns for N notes. This is one spawn total, with the same per-item
1948
+ // isolation and result semantics.
1949
+ const results = new Array(ids.length);
1950
+ const runnable = [];
1951
+ ids.forEach((id, i) => {
1952
+ try {
1953
+ runnable.push({ index: i, safe: sanitizeId(id) });
1868
1954
  }
1869
- if (this.isNotePasswordProtectedById(id)) {
1870
- results.push(this.createBatchResult(id, false, "Note is password-protected"));
1871
- continue;
1955
+ catch (e) {
1956
+ results[i] = this.createBatchResult(id, false, e instanceof Error ? e.message : "Invalid note ID");
1872
1957
  }
1873
- // Attempt deletion
1874
- const success = this.deleteNoteById(id);
1875
- if (success) {
1876
- results.push(this.createBatchResult(id, true));
1958
+ });
1959
+ if (runnable.length > 0) {
1960
+ const idList = runnable.map((r) => `"${r.safe}"`).join(", ");
1961
+ const script = buildAppLevelScript(`
1962
+ set out to ""
1963
+ repeat with rawId in {${idList}}
1964
+ set theId to (rawId as text)
1965
+ set noteRef to missing value
1966
+ try
1967
+ set noteRef to note id theId
1968
+ end try
1969
+ if noteRef is missing value then
1970
+ set out to out & "missing" & ${AS_RECORD_SEP}
1971
+ else
1972
+ set isPw to false
1973
+ try
1974
+ set isPw to (password protected of noteRef)
1975
+ end try
1976
+ if isPw then
1977
+ set out to out & "pw" & ${AS_RECORD_SEP}
1978
+ else
1979
+ try
1980
+ delete noteRef
1981
+ set out to out & "ok" & ${AS_RECORD_SEP}
1982
+ on error
1983
+ set out to out & "fail" & ${AS_RECORD_SEP}
1984
+ end try
1985
+ end if
1986
+ end if
1987
+ end repeat
1988
+ return out
1989
+ `);
1990
+ const res = executeAppleScript(script);
1991
+ if (!res.success) {
1992
+ // Whole-batch failure (e.g. Notes.app not responding): can't isolate,
1993
+ // so mark every runnable note as failed with the underlying error.
1994
+ for (const r of runnable) {
1995
+ results[r.index] = this.createBatchResult(ids[r.index], false, res.error ?? "Batch delete failed");
1996
+ }
1877
1997
  }
1878
1998
  else {
1879
- results.push(this.createBatchResult(id, false, "Deletion failed"));
1999
+ const statuses = res.output
2000
+ .split(RECORD_SEP)
2001
+ .map((s) => s.trim())
2002
+ .filter((s) => s.length > 0);
2003
+ runnable.forEach((r, k) => {
2004
+ results[r.index] = this.mapBatchStatus(ids[r.index], statuses[k], "delete");
2005
+ });
1880
2006
  }
1881
2007
  }
1882
2008
  return results;
1883
2009
  }
2010
+ /**
2011
+ * Maps a per-item status token emitted by a batch AppleScript loop to a
2012
+ * BatchResult, preserving the human-readable error messages of the original
2013
+ * per-note implementation. See {@link batchDeleteNotes} / {@link batchMoveNotes}.
2014
+ */
2015
+ mapBatchStatus(id, status, op) {
2016
+ switch (status) {
2017
+ case "ok":
2018
+ return this.createBatchResult(id, true);
2019
+ case "pw":
2020
+ return this.createBatchResult(id, false, "Note is password-protected");
2021
+ case "missing":
2022
+ return this.createBatchResult(id, false, "Note not found");
2023
+ case "fail":
2024
+ return this.createBatchResult(id, false, op === "delete" ? "Deletion failed" : "Move failed");
2025
+ default:
2026
+ return this.createBatchResult(id, false, "Unknown error");
2027
+ }
2028
+ }
1884
2029
  /**
1885
2030
  * Moves multiple notes to a folder by their IDs.
1886
2031
  *
@@ -1901,25 +2046,76 @@ export class AppleNotesManager {
1901
2046
  * ```
1902
2047
  */
1903
2048
  batchMoveNotes(ids, folder, account) {
1904
- const results = [];
1905
- for (const id of ids) {
1906
- // First verify the note exists
1907
- const note = this.getNoteById(id);
1908
- if (!note) {
1909
- results.push(this.createBatchResult(id, false, "Note not found"));
1910
- continue;
2049
+ if (ids.length === 0)
2050
+ return [];
2051
+ // Collapse the whole batch into ONE osascript spawn (#26). The old path
2052
+ // spawned 5+ processes per note (getNoteById + isNotePasswordProtectedById +
2053
+ // moveNoteById's copy-then-delete fan-out). This uses the native `move`
2054
+ // command which preserves the note's identity and metadata rather than
2055
+ // copy+delete — inside a single app-level loop with per-id `try` isolation.
2056
+ const targetAccount = this.resolveAccount(account);
2057
+ const safeAccount = sanitizeAccountName(targetAccount);
2058
+ // buildFolderReference validates the (single, shared) destination path; a
2059
+ // malformed folder is a precondition error for the whole call, so let it throw.
2060
+ const destFolderRef = `${buildFolderReference(folder)} of account "${safeAccount}"`;
2061
+ const results = new Array(ids.length);
2062
+ const runnable = [];
2063
+ ids.forEach((id, i) => {
2064
+ try {
2065
+ runnable.push({ index: i, safe: sanitizeId(id) });
1911
2066
  }
1912
- if (this.isNotePasswordProtectedById(id)) {
1913
- results.push(this.createBatchResult(id, false, "Note is password-protected"));
1914
- continue;
2067
+ catch (e) {
2068
+ results[i] = this.createBatchResult(id, false, e instanceof Error ? e.message : "Invalid note ID");
1915
2069
  }
1916
- // Attempt move using the ID-based method
1917
- const success = this.moveNoteById(id, folder, account);
1918
- if (success) {
1919
- results.push(this.createBatchResult(id, true));
2070
+ });
2071
+ if (runnable.length > 0) {
2072
+ const idList = runnable.map((r) => `"${r.safe}"`).join(", ");
2073
+ const script = buildAppLevelScript(`
2074
+ set destFolder to ${destFolderRef}
2075
+ set out to ""
2076
+ repeat with rawId in {${idList}}
2077
+ set theId to (rawId as text)
2078
+ set noteRef to missing value
2079
+ try
2080
+ set noteRef to note id theId
2081
+ end try
2082
+ if noteRef is missing value then
2083
+ set out to out & "missing" & ${AS_RECORD_SEP}
2084
+ else
2085
+ set isPw to false
2086
+ try
2087
+ set isPw to (password protected of noteRef)
2088
+ end try
2089
+ if isPw then
2090
+ set out to out & "pw" & ${AS_RECORD_SEP}
2091
+ else
2092
+ try
2093
+ move noteRef to destFolder
2094
+ set out to out & "ok" & ${AS_RECORD_SEP}
2095
+ on error
2096
+ set out to out & "fail" & ${AS_RECORD_SEP}
2097
+ end try
2098
+ end if
2099
+ end if
2100
+ end repeat
2101
+ return out
2102
+ `);
2103
+ const res = executeAppleScript(script);
2104
+ if (!res.success) {
2105
+ // Whole-batch failure (e.g. destination folder unresolved, Notes not
2106
+ // responding): can't isolate, so fail every runnable note.
2107
+ for (const r of runnable) {
2108
+ results[r.index] = this.createBatchResult(ids[r.index], false, res.error ?? "Batch move failed");
2109
+ }
1920
2110
  }
1921
2111
  else {
1922
- results.push(this.createBatchResult(id, false, "Move failed"));
2112
+ const statuses = res.output
2113
+ .split(RECORD_SEP)
2114
+ .map((s) => s.trim())
2115
+ .filter((s) => s.length > 0);
2116
+ runnable.forEach((r, k) => {
2117
+ results[r.index] = this.mapBatchStatus(ids[r.index], statuses[k], "move");
2118
+ });
1923
2119
  }
1924
2120
  }
1925
2121
  return results;