apple-notes-mcp 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,6 +41,17 @@ Install as a Claude Code plugin for automatic configuration and enhanced AI beha
41
41
 
42
42
  This method also installs a **skill** that teaches Claude when and how to use Apple Notes effectively.
43
43
 
44
+ ### Using the Codex Marketplace
45
+
46
+ The same plugin is available for Codex. Add the marketplace and install the plugin:
47
+
48
+ ```bash
49
+ codex plugin marketplace add sweetrb/apple-notes-mcp
50
+ codex plugin add apple-notes@apple-notes-mcp
51
+ ```
52
+
53
+ The Codex plugin runs the published `apple-notes-mcp` server through `npx` and ships the same Apple Notes skill, so behavior matches the Claude Code plugin.
54
+
44
55
  ### Manual Installation
45
56
 
46
57
  **1. Install the server:**
@@ -102,6 +113,15 @@ Resources expose read-only context the client can attach without a tool call:
102
113
  `notes://note/{id}` template (returns the note as Markdown). Prompts package
103
114
  common workflows: `find-note`, `weekly-review`, `new-meeting-note`.
104
115
 
116
+ ### Known limitations
117
+
118
+ A few Notes UI features are not exposed to AppleScript and therefore cannot be
119
+ supported. See **[docs/APPLESCRIPT-LIMITATIONS.md](docs/APPLESCRIPT-LIMITATIONS.md)**
120
+ for the investigation and verification behind each:
121
+
122
+ - **Pinned notes** — Notes has no scriptable `pinned` property, so pin state can be neither read nor set.
123
+ - **Note-to-note links** — there is no `applenotes://` deep link or link property; the only stable handle is the `x-coredata://` note id.
124
+
105
125
  ---
106
126
 
107
127
  ## Tool Reference
@@ -224,7 +244,10 @@ Retrieves the full content of a specific note.
224
244
  }
225
245
  ```
226
246
 
227
- **Returns:** The HTML content of the note, or error if not found.
247
+ **Returns:** The HTML content of the note, or error if not found. The
248
+ `structuredContent` also includes `hashtags` — any inline `#hashtag` tags parsed
249
+ from the body. Apple Notes tags are inline hashtags, not a scriptable property;
250
+ see [docs/APPLESCRIPT-LIMITATIONS.md](../docs/APPLESCRIPT-LIMITATIONS.md#tags--hashtags-29). Smart Folders are not scriptable.
228
251
 
229
252
  ---
230
253
 
@@ -946,12 +969,20 @@ The `\\\\` in JSON becomes `\\` in the actual string, which represents a single
946
969
  ## Development
947
970
 
948
971
  ```bash
949
- npm install # Install dependencies
950
- npm run build # Compile TypeScript
951
- npm test # Run test suite (313 tests)
952
- npm run lint # Check code style
953
- npm run format # Format code
954
- ```
972
+ npm install # Install dependencies
973
+ npm run build # Compile TypeScript
974
+ npm test # Run unit test suite (mocked AppleScript)
975
+ npm run test:integration # Run integration tests against real Notes.app
976
+ npm run test:all # Unit + integration
977
+ npm run lint # Check code style
978
+ npm run format # Format code
979
+ ```
980
+
981
+ The integration suite (`test/integration.test.ts`) drives the real
982
+ `AppleNotesManager → AppleScript → Notes.app` stack — creating, reading,
983
+ searching, and deleting throwaway notes. Its live tests self-skip when no
984
+ writable Notes account is available (e.g. CI), so it is safe to run anywhere;
985
+ the pure path-safety and hashtag tests always run.
955
986
 
956
987
  ---
957
988
 
package/build/index.js CHANGED
@@ -27,6 +27,7 @@ import { AppleNotesManager } from "./services/appleNotesManager.js";
27
27
  import { getSyncStatus, withSyncAwarenessSync } from "./utils/syncDetection.js";
28
28
  import { getChecklistItems, hasFullDiskAccess } from "./utils/checklistParser.js";
29
29
  import { detectChecklistAttempt } from "./utils/contentWarnings.js";
30
+ import { parseHashtags } from "./utils/hashtags.js";
30
31
  import { runDoctor, formatDoctorReport } from "./tools/doctor.js";
31
32
  import { loadFileConfig } from "./services/fileConfig.js";
32
33
  import { registerResourcesAndPrompts } from "./tools/resourcesAndPrompts.js";
@@ -200,7 +201,8 @@ server.tool("get-note-content", {
200
201
  if (!content) {
201
202
  return errorResponse(`Failed to read content of note "${note.title}"`);
202
203
  }
203
- return successResponse(content, { title: note.title, content });
204
+ const hashtags = parseHashtags(content);
205
+ return successResponse(content, { title: note.title, content, hashtags });
204
206
  }
205
207
  // Fall back to title-based lookup
206
208
  if (!title) {
@@ -218,7 +220,8 @@ server.tool("get-note-content", {
218
220
  if (!content) {
219
221
  return errorResponse(`Failed to read content of note "${title}"`);
220
222
  }
221
- return successResponse(content, { title, content });
223
+ const hashtags = parseHashtags(content);
224
+ return successResponse(content, { title, content, hashtags });
222
225
  }, "Error retrieving note content"));
223
226
  // --- get-note-by-id ---
224
227
  server.tool("get-note-by-id", {
@@ -599,6 +602,15 @@ server.tool("get-notes-stats", {}, withErrorHandling(() => {
599
602
  lines.push(` Last 24 hours: ${stats.recentlyModified.last24h}`);
600
603
  lines.push(` Last 7 days: ${stats.recentlyModified.last7d}`);
601
604
  lines.push(` Last 30 days: ${stats.recentlyModified.last30d}`);
605
+ // Partial-coverage diagnostics (#19): if some scopes couldn't be read, say so
606
+ // explicitly so the numbers above aren't mistaken for a complete picture.
607
+ if (!stats.coverage.complete) {
608
+ lines.push(``);
609
+ lines.push(`⚠️ Partial results: read ${stats.coverage.covered}/${stats.coverage.scanned} scopes. Counts above exclude:`);
610
+ for (const w of stats.coverage.warnings) {
611
+ lines.push(` - ${w.scope}: ${w.reason}`);
612
+ }
613
+ }
602
614
  return successResponse(lines.join("\n"), { ...stats });
603
615
  }, "Error getting notes statistics"));
604
616
  // --- list-attachments ---
@@ -634,7 +634,7 @@ export class AppleNotesManager {
634
634
  */
635
635
  searchNotes(query, searchContent = false, account, folder, modifiedSince, limit) {
636
636
  const targetAccount = this.resolveAccount(account);
637
- const safeQuery = escapeForAppleScript(query);
637
+ const safeQuery = escapePlainStringForAppleScript(query);
638
638
  const safeLimit = limit !== undefined && limit > 0 ? Math.floor(limit) : undefined;
639
639
  // Build the where clause based on search type
640
640
  // AppleScript uses 'name' for title and 'body' for content
@@ -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: [],
@@ -738,7 +745,7 @@ export class AppleNotesManager {
738
745
  */
739
746
  getNoteContent(title, account) {
740
747
  const targetAccount = this.resolveAccount(account);
741
- const safeTitle = escapeForAppleScript(title);
748
+ const safeTitle = escapePlainStringForAppleScript(title);
742
749
  // Retrieve the body property of the note
743
750
  const getCommand = `get body of note "${safeTitle}"`;
744
751
  const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
@@ -830,7 +837,7 @@ export class AppleNotesManager {
830
837
  */
831
838
  getNoteDetails(title, account) {
832
839
  const targetAccount = this.resolveAccount(account);
833
- const safeTitle = escapeForAppleScript(title);
840
+ const safeTitle = escapePlainStringForAppleScript(title);
834
841
  // Fetch multiple properties at once
835
842
  const getCommand = `
836
843
  set n to note "${safeTitle}"
@@ -875,7 +882,7 @@ export class AppleNotesManager {
875
882
  */
876
883
  deleteNote(title, account) {
877
884
  const targetAccount = this.resolveAccount(account);
878
- const safeTitle = escapeForAppleScript(title);
885
+ const safeTitle = escapePlainStringForAppleScript(title);
879
886
  const deleteCommand = `delete note "${safeTitle}"`;
880
887
  const script = buildAccountScopedScript({ account: targetAccount }, deleteCommand);
881
888
  const result = executeAppleScript(script);
@@ -931,7 +938,7 @@ export class AppleNotesManager {
931
938
  validateLength(newTitle, MAX_TITLE_LENGTH, "Note title");
932
939
  validateLength(newContent, MAX_CONTENT_LENGTH, "Note content");
933
940
  const targetAccount = this.resolveAccount(account);
934
- const safeCurrentTitle = escapeForAppleScript(title);
941
+ const safeCurrentTitle = escapePlainStringForAppleScript(title);
935
942
  let fullBody;
936
943
  if (format === "html") {
937
944
  // HTML mode: content is the complete body, escaped only for AppleScript string
@@ -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.
@@ -1251,7 +1296,7 @@ export class AppleNotesManager {
1251
1296
  continue;
1252
1297
  }
1253
1298
  // Folder doesn't exist — create it
1254
- const segmentName = escapeForAppleScript(parts[i]);
1299
+ const segmentName = escapePlainStringForAppleScript(parts[i]);
1255
1300
  let createCommand;
1256
1301
  if (i === 0) {
1257
1302
  createCommand = `make new folder with properties {name:"${segmentName}"}`;
@@ -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
@@ -1699,7 +1779,7 @@ export class AppleNotesManager {
1699
1779
  */
1700
1780
  listAttachments(title, account) {
1701
1781
  const targetAccount = this.resolveAccount(account);
1702
- const safeTitle = escapeForAppleScript(title);
1782
+ const safeTitle = escapePlainStringForAppleScript(title);
1703
1783
  const script = `
1704
1784
  tell application "Notes"
1705
1785
  tell account "${targetAccount}"
@@ -1759,8 +1839,8 @@ export class AppleNotesManager {
1759
1839
  return { success: false, error: e instanceof Error ? e.message : String(e) };
1760
1840
  }
1761
1841
  const safeNoteId = sanitizeId(noteId);
1762
- const safeAttId = escapeForAppleScript(attachmentId);
1763
- const safePath = escapeForAppleScript(abs);
1842
+ const safeAttId = escapePlainStringForAppleScript(attachmentId);
1843
+ const safePath = escapePlainStringForAppleScript(abs);
1764
1844
  const script = `
1765
1845
  tell application "Notes"
1766
1846
  set theNote to note id "${safeNoteId}"
@@ -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;
@@ -526,6 +526,19 @@ describe("AppleNotesManager", () => {
526
526
  expect(results[1].id).toBe("x-coredata://ABC/ICNote/p2");
527
527
  expect(results[1].folder).toBe("Notes");
528
528
  });
529
+ it("deduplicates duplicate note IDs returned by Notes.app", () => {
530
+ mockExecuteAppleScript.mockReturnValue({
531
+ success: true,
532
+ output: [
533
+ ["Not uploaded", "x-coredata://ABC/ICNote/p1", "Notes"].join(F),
534
+ ["Not uploaded", "x-coredata://ABC/ICNote/p1", "Notes"].join(F),
535
+ ].join(R),
536
+ });
537
+ const results = manager.searchNotes("Not uploaded");
538
+ expect(results).toHaveLength(1);
539
+ expect(results[0].title).toBe("Not uploaded");
540
+ expect(results[0].id).toBe("x-coredata://ABC/ICNote/p1");
541
+ });
529
542
  it("scopes search to specified account", () => {
530
543
  mockExecuteAppleScript.mockReturnValue({
531
544
  success: true,
@@ -633,6 +646,15 @@ describe("AppleNotesManager", () => {
633
646
  const content = manager.getNoteContent("Missing Note");
634
647
  expect(content).toBe("");
635
648
  });
649
+ it("looks up titles containing & literally, not HTML-escaped (regression)", () => {
650
+ // Bug found in live testing: titles with "&" were HTML-escaped to "&"
651
+ // in the `note "..."` lookup, so the note could never be found.
652
+ mockExecuteAppleScript.mockReturnValue({ success: true, output: "<div>x</div>" });
653
+ manager.getNoteContent("Tom & Jerry", "iCloud");
654
+ const script = mockExecuteAppleScript.mock.calls[0][0];
655
+ expect(script).toContain("Tom & Jerry");
656
+ expect(script).not.toContain("Tom &amp; Jerry");
657
+ });
636
658
  it("uses specified account", () => {
637
659
  mockExecuteAppleScript.mockReturnValue({
638
660
  success: true,
@@ -993,7 +1015,11 @@ describe("AppleNotesManager", () => {
993
1015
  it("returns array of note titles", () => {
994
1016
  mockExecuteAppleScript.mockReturnValue({
995
1017
  success: true,
996
- output: ["Note A", "Note B", "Note C"].join(R),
1018
+ output: [
1019
+ ["Note A", "x-coredata://ABC/ICNote/p1"].join(F),
1020
+ ["Note B", "x-coredata://ABC/ICNote/p2"].join(F),
1021
+ ["Note C", "x-coredata://ABC/ICNote/p3"].join(F),
1022
+ ].join(R),
997
1023
  });
998
1024
  const titles = manager.listNotes();
999
1025
  expect(titles).toEqual(["Note A", "Note B", "Note C"]);
@@ -1001,11 +1027,29 @@ describe("AppleNotesManager", () => {
1001
1027
  it("filters out empty entries", () => {
1002
1028
  mockExecuteAppleScript.mockReturnValue({
1003
1029
  success: true,
1004
- output: ["Note A", "", "Note B", "", ""].join(R),
1030
+ output: [
1031
+ ["Note A", "x-coredata://ABC/ICNote/p1"].join(F),
1032
+ "",
1033
+ ["Note B", "x-coredata://ABC/ICNote/p2"].join(F),
1034
+ "",
1035
+ "",
1036
+ ].join(R),
1005
1037
  });
1006
1038
  const titles = manager.listNotes();
1007
1039
  expect(titles).toEqual(["Note A", "Note B"]);
1008
1040
  });
1041
+ it("deduplicates duplicate note IDs while preserving separate notes with the same title", () => {
1042
+ mockExecuteAppleScript.mockReturnValue({
1043
+ success: true,
1044
+ output: [
1045
+ ["Same Title", "x-coredata://ABC/ICNote/p1"].join(F),
1046
+ ["Same Title", "x-coredata://ABC/ICNote/p1"].join(F),
1047
+ ["Same Title", "x-coredata://ABC/ICNote/p2"].join(F),
1048
+ ].join(R),
1049
+ });
1050
+ const titles = manager.listNotes();
1051
+ expect(titles).toEqual(["Same Title", "Same Title"]);
1052
+ });
1009
1053
  it("throws on failure rather than returning empty (#19)", () => {
1010
1054
  mockExecuteAppleScript.mockReturnValue({
1011
1055
  success: false,
@@ -1017,7 +1061,10 @@ describe("AppleNotesManager", () => {
1017
1061
  it("filters by folder when specified", () => {
1018
1062
  mockExecuteAppleScript.mockReturnValue({
1019
1063
  success: true,
1020
- output: ["Work Note 1", "Work Note 2"].join(R),
1064
+ output: [
1065
+ ["Work Note 1", "x-coredata://ABC/ICNote/p1"].join(F),
1066
+ ["Work Note 2", "x-coredata://ABC/ICNote/p2"].join(F),
1067
+ ].join(R),
1021
1068
  });
1022
1069
  manager.listNotes("iCloud", "Work");
1023
1070
  expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('notes of folder "Work"'));
@@ -1025,7 +1072,10 @@ describe("AppleNotesManager", () => {
1025
1072
  it("uses whose clause when modifiedSince is provided", () => {
1026
1073
  mockExecuteAppleScript.mockReturnValue({
1027
1074
  success: true,
1028
- output: ["Recent Note 1", "Recent Note 2"].join(R),
1075
+ output: [
1076
+ ["Recent Note 1", "x-coredata://ABC/ICNote/p1"].join(F),
1077
+ ["Recent Note 2", "x-coredata://ABC/ICNote/p2"].join(F),
1078
+ ].join(R),
1029
1079
  });
1030
1080
  const results = manager.listNotes(undefined, undefined, "2025-06-15T00:00:00");
1031
1081
  const script = mockExecuteAppleScript.mock.calls[0][0];
@@ -1040,7 +1090,11 @@ describe("AppleNotesManager", () => {
1040
1090
  it("uses repeat loop when limit is provided", () => {
1041
1091
  mockExecuteAppleScript.mockReturnValue({
1042
1092
  success: true,
1043
- output: ["Note 1", "Note 2", "Note 3"].join(R),
1093
+ output: [
1094
+ ["Note 1", "x-coredata://ABC/ICNote/p1"].join(F),
1095
+ ["Note 2", "x-coredata://ABC/ICNote/p2"].join(F),
1096
+ ["Note 3", "x-coredata://ABC/ICNote/p3"].join(F),
1097
+ ].join(R),
1044
1098
  });
1045
1099
  const results = manager.listNotes(undefined, undefined, undefined, 3);
1046
1100
  const script = mockExecuteAppleScript.mock.calls[0][0];
@@ -1050,7 +1104,10 @@ describe("AppleNotesManager", () => {
1050
1104
  it("combines folder, modifiedSince, and limit", () => {
1051
1105
  mockExecuteAppleScript.mockReturnValue({
1052
1106
  success: true,
1053
- output: ["Work Note", "Another Work Note"].join(R),
1107
+ output: [
1108
+ ["Work Note", "x-coredata://ABC/ICNote/p1"].join(F),
1109
+ ["Another Work Note", "x-coredata://ABC/ICNote/p2"].join(F),
1110
+ ].join(R),
1054
1111
  });
1055
1112
  manager.listNotes("iCloud", "Work", "2025-01-01", 10);
1056
1113
  const script = mockExecuteAppleScript.mock.calls[0][0];
@@ -1069,7 +1126,10 @@ describe("AppleNotesManager", () => {
1069
1126
  it("ignores invalid modifiedSince date and falls back to limit-only", () => {
1070
1127
  mockExecuteAppleScript.mockReturnValue({
1071
1128
  success: true,
1072
- output: ["Note 1", "Note 2"].join(R),
1129
+ output: [
1130
+ ["Note 1", "x-coredata://ABC/ICNote/p1"].join(F),
1131
+ ["Note 2", "x-coredata://ABC/ICNote/p2"].join(F),
1132
+ ].join(R),
1073
1133
  });
1074
1134
  const results = manager.listNotes(undefined, undefined, "not-a-date", 5);
1075
1135
  const script = mockExecuteAppleScript.mock.calls[0][0];
@@ -1390,7 +1450,13 @@ describe("AppleNotesManager", () => {
1390
1450
  // Check 3: listAccounts
1391
1451
  .mockReturnValueOnce({ success: true, output: "iCloud" })
1392
1452
  // Check 4: listNotes
1393
- .mockReturnValueOnce({ success: true, output: ["Note 1", "Note 2"].join(R) });
1453
+ .mockReturnValueOnce({
1454
+ success: true,
1455
+ output: [
1456
+ ["Note 1", "x-coredata://ABC/ICNote/p1"].join(F),
1457
+ ["Note 2", "x-coredata://ABC/ICNote/p2"].join(F),
1458
+ ].join(R),
1459
+ });
1394
1460
  const result = manager.healthCheck();
1395
1461
  expect(result.healthy).toBe(true);
1396
1462
  expect(result.checks).toHaveLength(4);
@@ -1489,6 +1555,56 @@ describe("AppleNotesManager", () => {
1489
1555
  expect(stats.accounts[0].name).toBe("iCloud");
1490
1556
  expect(stats.accounts[1].name).toBe("Gmail");
1491
1557
  });
1558
+ it("reports complete coverage when every scope succeeds (#19)", () => {
1559
+ mockExecuteAppleScript
1560
+ .mockReturnValueOnce({ success: true, output: "iCloud" })
1561
+ .mockReturnValueOnce({ success: true, output: ["Notes", "3"].join(F) + R })
1562
+ .mockReturnValueOnce({ success: true, output: ["1", "2", "3"].join(F) });
1563
+ const stats = manager.getNotesStats();
1564
+ expect(stats.coverage.complete).toBe(true);
1565
+ expect(stats.coverage.warnings).toEqual([]);
1566
+ expect(stats.coverage.covered).toBe(stats.coverage.scanned);
1567
+ });
1568
+ it("degrades gracefully when one account fails, with a coverage warning (#19)", () => {
1569
+ mockExecuteAppleScript
1570
+ // listAccounts
1571
+ .mockReturnValueOnce({ success: true, output: ["iCloud", "Gmail"].join(R) })
1572
+ // iCloud folder counts succeed
1573
+ .mockReturnValueOnce({ success: true, output: ["Notes", "4"].join(F) + R })
1574
+ // Gmail folder counts FAIL
1575
+ .mockReturnValueOnce({ success: false, output: "", error: "Gmail account is locked" })
1576
+ // getRecentlyModifiedCounts succeed
1577
+ .mockReturnValueOnce({ success: true, output: ["0", "0", "0"].join(F) });
1578
+ const stats = manager.getNotesStats();
1579
+ // Healthy account's data is preserved, not discarded
1580
+ expect(stats.totalNotes).toBe(4);
1581
+ expect(stats.accounts).toHaveLength(1);
1582
+ expect(stats.accounts[0].name).toBe("iCloud");
1583
+ // Failure surfaced as a coverage warning
1584
+ expect(stats.coverage.complete).toBe(false);
1585
+ expect(stats.coverage.warnings).toHaveLength(1);
1586
+ expect(stats.coverage.warnings[0].scope).toBe("Gmail");
1587
+ expect(stats.coverage.warnings[0].reason).toContain("locked");
1588
+ });
1589
+ it("flags recent-activity failure as a coverage warning, not fake zeros (#19)", () => {
1590
+ mockExecuteAppleScript
1591
+ .mockReturnValueOnce({ success: true, output: "iCloud" })
1592
+ .mockReturnValueOnce({ success: true, output: ["Notes", "5"].join(F) + R })
1593
+ // getRecentlyModifiedCounts FAILS
1594
+ .mockReturnValueOnce({ success: false, output: "", error: "timed out" });
1595
+ const stats = manager.getNotesStats();
1596
+ expect(stats.totalNotes).toBe(5);
1597
+ expect(stats.recentlyModified.last24h).toBe(0);
1598
+ expect(stats.coverage.complete).toBe(false);
1599
+ expect(stats.coverage.warnings.some((w) => w.scope === "recent-activity")).toBe(true);
1600
+ });
1601
+ it("throws when no account can be read at all (#19)", () => {
1602
+ mockExecuteAppleScript
1603
+ .mockReturnValueOnce({ success: true, output: ["iCloud", "Gmail"].join(R) })
1604
+ .mockReturnValueOnce({ success: false, output: "", error: "iCloud unreachable" })
1605
+ .mockReturnValueOnce({ success: false, output: "", error: "Gmail unreachable" });
1606
+ expect(() => manager.getNotesStats()).toThrow(/Failed to read folder stats for any/);
1607
+ });
1492
1608
  });
1493
1609
  // ---------------------------------------------------------------------------
1494
1610
  // Attachment Listing
@@ -1569,50 +1685,23 @@ describe("AppleNotesManager", () => {
1569
1685
  // Batch Operations
1570
1686
  // ---------------------------------------------------------------------------
1571
1687
  describe("batchDeleteNotes", () => {
1572
- // Helper to create getNoteById mock output (matches AppleScript format)
1573
- const noteByIdOutput = (title, passwordProtected = false) => [
1574
- title,
1575
- "x-coredata://ABC/ICNote/p1",
1576
- "Sunday, January 1, 2025 at 1:00:00 PM",
1577
- "Sunday, January 1, 2025 at 1:00:00 PM",
1578
- "false",
1579
- String(passwordProtected),
1580
- ].join(F);
1581
- it("deletes multiple notes successfully", () => {
1582
- // For each note: getNoteById (which isNotePasswordProtectedById also calls), deleteNoteById
1583
- mockExecuteAppleScript
1584
- // First note: getNoteById for existence check
1585
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1586
- // First note: getNoteById for password check (isNotePasswordProtectedById calls getNoteById)
1587
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1588
- // First note: deleteNoteById
1589
- .mockReturnValueOnce({ success: true, output: "" })
1590
- // Second note: getNoteById for existence check
1591
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
1592
- // Second note: getNoteById for password check
1593
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
1594
- // Second note: deleteNoteById
1595
- .mockReturnValueOnce({ success: true, output: "" });
1596
- const results = manager.batchDeleteNotes([
1597
- "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1598
- "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1599
- ]);
1600
- expect(results).toHaveLength(2);
1601
- expect(results[0]).toEqual({
1602
- id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1603
- success: true,
1604
- });
1605
- expect(results[1]).toEqual({
1606
- id: "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1688
+ const ID1 = "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1";
1689
+ const ID2 = "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2";
1690
+ it("deletes the whole batch in a single osascript spawn (#26)", () => {
1691
+ // One script handles all ids; per-id status tokens joined by RECORD_SEP.
1692
+ mockExecuteAppleScript.mockReturnValueOnce({
1607
1693
  success: true,
1694
+ output: ["ok", "ok"].join(R) + R,
1608
1695
  });
1696
+ const results = manager.batchDeleteNotes([ID1, ID2]);
1697
+ // Exactly one spawn for N notes, not 3N.
1698
+ expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1);
1699
+ expect(results).toHaveLength(2);
1700
+ expect(results[0]).toEqual({ id: ID1, success: true });
1701
+ expect(results[1]).toEqual({ id: ID2, success: true });
1609
1702
  });
1610
1703
  it("returns error for non-existent note", () => {
1611
- mockExecuteAppleScript.mockReturnValueOnce({
1612
- success: false,
1613
- output: "",
1614
- error: "Not found",
1615
- });
1704
+ mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "missing" + R });
1616
1705
  const results = manager.batchDeleteNotes([
1617
1706
  "x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
1618
1707
  ]);
@@ -1623,97 +1712,58 @@ describe("AppleNotesManager", () => {
1623
1712
  });
1624
1713
  });
1625
1714
  it("returns error for password-protected note", () => {
1626
- mockExecuteAppleScript
1627
- // getNoteById for existence check
1628
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) })
1629
- // getNoteById for password check (returns true for passwordProtected)
1630
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) });
1631
- const results = manager.batchDeleteNotes([
1632
- "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1633
- ]);
1634
- expect(results[0]).toEqual({
1635
- id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1636
- success: false,
1637
- error: "Note is password-protected",
1638
- });
1715
+ mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "pw" + R });
1716
+ const results = manager.batchDeleteNotes([ID1]);
1717
+ expect(results[0]).toEqual({ id: ID1, success: false, error: "Note is password-protected" });
1639
1718
  });
1640
- it("handles mixed success and failure", () => {
1641
- mockExecuteAppleScript
1642
- // First note: success
1643
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1644
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1645
- .mockReturnValueOnce({ success: true, output: "" })
1646
- // Second note: not found
1647
- .mockReturnValueOnce({ success: false, output: "", error: "Not found" });
1648
- const results = manager.batchDeleteNotes([
1649
- "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1650
- "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1651
- ]);
1652
- expect(results[0]).toEqual({
1653
- id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1719
+ it("handles mixed success and failure, preserving order", () => {
1720
+ mockExecuteAppleScript.mockReturnValueOnce({
1654
1721
  success: true,
1722
+ output: ["ok", "missing"].join(R) + R,
1655
1723
  });
1656
- expect(results[1]).toEqual({
1657
- id: "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1724
+ const results = manager.batchDeleteNotes([ID1, ID2]);
1725
+ expect(results[0]).toEqual({ id: ID1, success: true });
1726
+ expect(results[1]).toEqual({ id: ID2, success: false, error: "Note not found" });
1727
+ });
1728
+ it("fails an invalid id without spawning, isolating it from valid ids", () => {
1729
+ mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "ok" + R });
1730
+ const results = manager.batchDeleteNotes(["not-a-valid-id", ID1]);
1731
+ expect(results[0].success).toBe(false);
1732
+ expect(results[0].error).toMatch(/Invalid note ID/);
1733
+ expect(results[1]).toEqual({ id: ID1, success: true });
1734
+ });
1735
+ it("fails the whole batch when the single script errors", () => {
1736
+ mockExecuteAppleScript.mockReturnValueOnce({
1658
1737
  success: false,
1659
- error: "Note not found",
1738
+ output: "",
1739
+ error: "Notes.app not responding",
1660
1740
  });
1741
+ const results = manager.batchDeleteNotes([ID1, ID2]);
1742
+ expect(results.every((r) => r.success === false)).toBe(true);
1743
+ expect(results[0].error).toContain("Notes.app not responding");
1744
+ });
1745
+ it("returns [] for an empty batch without spawning", () => {
1746
+ const results = manager.batchDeleteNotes([]);
1747
+ expect(results).toEqual([]);
1748
+ expect(mockExecuteAppleScript).not.toHaveBeenCalled();
1661
1749
  });
1662
1750
  });
1663
1751
  describe("batchMoveNotes", () => {
1664
- // Helper to create getNoteById mock output (matches AppleScript format)
1665
- const noteByIdOutput = (title, passwordProtected = false) => [
1666
- title,
1667
- "x-coredata://ABC/ICNote/p1",
1668
- "Sunday, January 1, 2025 at 1:00:00 PM",
1669
- "Sunday, January 1, 2025 at 1:00:00 PM",
1670
- "false",
1671
- String(passwordProtected),
1672
- ].join(F);
1673
- it("moves multiple notes successfully", () => {
1674
- // For each note: getNoteById, getNoteById (password check), getNoteContentById, create, delete
1675
- mockExecuteAppleScript
1676
- // First note: getNoteById for existence check
1677
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1678
- // First note: getNoteById for password check
1679
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1680
- // First note: moveNoteById calls getNoteContentById
1681
- .mockReturnValueOnce({ success: true, output: "<div>Note 1</div><div>Content</div>" })
1682
- // First note: createNote in destination
1683
- .mockReturnValueOnce({ success: true, output: "" })
1684
- // First note: deleteNoteById (original)
1685
- .mockReturnValueOnce({ success: true, output: "" })
1686
- // Second note: getNoteById for existence check
1687
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
1688
- // Second note: getNoteById for password check
1689
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
1690
- // Second note: moveNoteById calls getNoteContentById
1691
- .mockReturnValueOnce({ success: true, output: "<div>Note 2</div><div>Content</div>" })
1692
- // Second note: createNote in destination
1693
- .mockReturnValueOnce({ success: true, output: "" })
1694
- // Second note: deleteNoteById (original)
1695
- .mockReturnValueOnce({ success: true, output: "" });
1696
- const results = manager.batchMoveNotes([
1697
- "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1698
- "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1699
- ], "Archive");
1700
- expect(results).toHaveLength(2);
1701
- expect(results[0]).toEqual({
1702
- id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1703
- success: true,
1704
- });
1705
- expect(results[1]).toEqual({
1706
- id: "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1752
+ const ID1 = "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1";
1753
+ const ID2 = "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2";
1754
+ it("moves the whole batch in a single osascript spawn (#26)", () => {
1755
+ mockExecuteAppleScript.mockReturnValueOnce({
1707
1756
  success: true,
1757
+ output: ["ok", "ok"].join(R) + R,
1708
1758
  });
1759
+ const results = manager.batchMoveNotes([ID1, ID2], "Archive");
1760
+ expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1);
1761
+ expect(results).toHaveLength(2);
1762
+ expect(results[0]).toEqual({ id: ID1, success: true });
1763
+ expect(results[1]).toEqual({ id: ID2, success: true });
1709
1764
  });
1710
1765
  it("returns error for non-existent note", () => {
1711
- // getNoteById fails for existence check
1712
- mockExecuteAppleScript.mockReturnValueOnce({
1713
- success: false,
1714
- output: "",
1715
- error: "Not found",
1716
- });
1766
+ mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "missing" + R });
1717
1767
  const results = manager.batchMoveNotes(["x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404"], "Archive");
1718
1768
  expect(results[0]).toEqual({
1719
1769
  id: "x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
@@ -1722,17 +1772,18 @@ describe("AppleNotesManager", () => {
1722
1772
  });
1723
1773
  });
1724
1774
  it("returns error for password-protected note", () => {
1725
- mockExecuteAppleScript
1726
- // getNoteById for existence check
1727
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) })
1728
- // getNoteById for password check (returns true for passwordProtected)
1729
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) });
1730
- const results = manager.batchMoveNotes(["x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1"], "Archive");
1731
- expect(results[0]).toEqual({
1732
- id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1733
- success: false,
1734
- error: "Note is password-protected",
1775
+ mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "pw" + R });
1776
+ const results = manager.batchMoveNotes([ID1], "Archive");
1777
+ expect(results[0]).toEqual({ id: ID1, success: false, error: "Note is password-protected" });
1778
+ });
1779
+ it("maps a per-item move failure to 'Move failed'", () => {
1780
+ mockExecuteAppleScript.mockReturnValueOnce({
1781
+ success: true,
1782
+ output: ["ok", "fail"].join(R) + R,
1735
1783
  });
1784
+ const results = manager.batchMoveNotes([ID1, ID2], "Archive");
1785
+ expect(results[0]).toEqual({ id: ID1, success: true });
1786
+ expect(results[1]).toEqual({ id: ID2, success: false, error: "Move failed" });
1736
1787
  });
1737
1788
  });
1738
1789
  // ---------------------------------------------------------------------------
@@ -1755,7 +1806,10 @@ describe("AppleNotesManager", () => {
1755
1806
  // listFolders for iCloud
1756
1807
  .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
1757
1808
  // listNotes for Notes folder
1758
- .mockReturnValueOnce({ success: true, output: "Test Note" })
1809
+ .mockReturnValueOnce({
1810
+ success: true,
1811
+ output: ["Test Note", "x-coredata://ABC/ICNote/p1"].join(F),
1812
+ })
1759
1813
  // getNoteDetails
1760
1814
  .mockReturnValueOnce({ success: true, output: noteDetailsOutput("Test Note", false) })
1761
1815
  // getNoteContent
@@ -1780,7 +1834,10 @@ describe("AppleNotesManager", () => {
1780
1834
  // listFolders for iCloud
1781
1835
  .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
1782
1836
  // listNotes for Notes folder
1783
- .mockReturnValueOnce({ success: true, output: "Locked Note" })
1837
+ .mockReturnValueOnce({
1838
+ success: true,
1839
+ output: ["Locked Note", "x-coredata://ABC/ICNote/p1"].join(F),
1840
+ })
1784
1841
  // getNoteDetails (passwordProtected = true)
1785
1842
  .mockReturnValueOnce({ success: true, output: noteDetailsOutput("Locked Note", true) });
1786
1843
  // No getNoteContent call because note is password-protected
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Inline hashtag extraction for Apple Notes.
3
+ *
4
+ * Apple Notes "tags" are not a first-class AppleScript property — they are
5
+ * inline `#hashtag` tokens typed into the note body. Notes stores the tag
6
+ * relationship in its private Core Data store, which AppleScript does not
7
+ * expose, so the only way to surface a note's tags is to parse them back out
8
+ * of the body text. This module does exactly that.
9
+ *
10
+ * See docs/APPLESCRIPT-LIMITATIONS.md and issue #29.
11
+ */
12
+ /**
13
+ * Strip HTML tags from a Notes body and neutralise numeric character
14
+ * references so they can't masquerade as hashtags (e.g. `&#8217;`).
15
+ */
16
+ function htmlToText(html) {
17
+ return html
18
+ .replace(/<[^>]*>/g, " ") // drop tags
19
+ .replace(/&#x?[0-9a-f]+;/gi, " ") // neutralise numeric entities (&#8217;)
20
+ .replace(/&[a-z]+;/gi, " "); // neutralise named entities (&amp; &nbsp;)
21
+ }
22
+ /**
23
+ * A hashtag token: `#` followed by a run of letters/digits/underscores that
24
+ * contains at least one letter. This matches Apple Notes' own rule — a purely
25
+ * numeric token like `#123` is NOT treated as a tag. The token must not be
26
+ * preceded by a word character, so `foo#bar` and URL fragments like
27
+ * `page.html#section` (preceded by a letter) are ignored.
28
+ */
29
+ const HASHTAG_RE = /(?<![\p{L}\p{N}_])#([\p{L}\p{N}_]*\p{L}[\p{L}\p{N}_]*)/gu;
30
+ /**
31
+ * Extract inline `#hashtag` tokens from a note body (HTML or plain text).
32
+ *
33
+ * - HTML is stripped first, so tags inside `<div>#work</div>` are found.
34
+ * - Pure-number tokens (`#123`) are ignored, matching Notes' behaviour.
35
+ * - Results are de-duplicated case-insensitively, preserving the first-seen
36
+ * casing and document order. The leading `#` is not included.
37
+ *
38
+ * @param body - The note body, as HTML or plain text.
39
+ * @returns Ordered, de-duplicated tag names without the leading `#`.
40
+ */
41
+ export function parseHashtags(body) {
42
+ if (!body)
43
+ return [];
44
+ const text = htmlToText(body);
45
+ const seen = new Set();
46
+ const result = [];
47
+ for (const match of text.matchAll(HASHTAG_RE)) {
48
+ const tag = match[1];
49
+ const key = tag.toLowerCase();
50
+ if (!seen.has(key)) {
51
+ seen.add(key);
52
+ result.push(tag);
53
+ }
54
+ }
55
+ return result;
56
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseHashtags } from "../utils/hashtags.js";
3
+ describe("parseHashtags", () => {
4
+ it("returns [] for empty / nullish input", () => {
5
+ expect(parseHashtags("")).toEqual([]);
6
+ expect(parseHashtags(null)).toEqual([]);
7
+ expect(parseHashtags(undefined)).toEqual([]);
8
+ expect(parseHashtags("no tags here")).toEqual([]);
9
+ });
10
+ it("extracts a single tag", () => {
11
+ expect(parseHashtags("Buy milk #groceries")).toEqual(["groceries"]);
12
+ });
13
+ it("extracts multiple tags in document order", () => {
14
+ expect(parseHashtags("#work then #home then #travel")).toEqual(["work", "home", "travel"]);
15
+ });
16
+ it("strips the leading # and not the rest", () => {
17
+ expect(parseHashtags("#project_alpha")).toEqual(["project_alpha"]);
18
+ });
19
+ it("finds tags inside HTML bodies", () => {
20
+ expect(parseHashtags("<div>Plan <b>#q3</b> launch</div>")).toEqual(["q3"]);
21
+ });
22
+ it("de-duplicates case-insensitively, keeping first-seen casing", () => {
23
+ expect(parseHashtags("#Work and #work and #WORK")).toEqual(["Work"]);
24
+ });
25
+ it("ignores purely numeric tokens (matches Notes behaviour)", () => {
26
+ expect(parseHashtags("ticket #123 and #4you")).toEqual(["4you"]);
27
+ });
28
+ it("does not match mid-word or URL fragments", () => {
29
+ expect(parseHashtags("foo#bar")).toEqual([]);
30
+ expect(parseHashtags("see page.html#section for details")).toEqual([]);
31
+ });
32
+ it("does not treat numeric HTML entities as tags", () => {
33
+ // &#8217; is a right single quote entity, not a #8217 tag
34
+ expect(parseHashtags("It&#8217;s a #plan")).toEqual(["plan"]);
35
+ });
36
+ it("matches a tag at the very start of the body", () => {
37
+ expect(parseHashtags("#start of note")).toEqual(["start"]);
38
+ });
39
+ it("handles tags terminated by punctuation", () => {
40
+ expect(parseHashtags("done: #alpha, #beta; #gamma.")).toEqual(["alpha", "beta", "gamma"]);
41
+ });
42
+ it("supports unicode letters in tags", () => {
43
+ expect(parseHashtags("café trip #café")).toEqual(["café"]);
44
+ });
45
+ });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "2.0.0",
4
- "description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude",
3
+ "version": "2.1.0",
4
+ "description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude and Codex",
5
5
  "type": "module",
6
6
  "main": "build/index.js",
7
7
  "types": "build/index.d.ts",
@@ -20,19 +20,22 @@
20
20
  "test": "vitest run",
21
21
  "test:watch": "vitest",
22
22
  "test:coverage": "vitest run --coverage",
23
+ "test:integration": "vitest run --config vitest.integration.config.ts",
24
+ "test:all": "vitest run && vitest run --config vitest.integration.config.ts",
23
25
  "lint": "eslint src",
24
26
  "lint:fix": "eslint src --fix",
25
27
  "format": "prettier --write src",
26
28
  "format:check": "prettier --check src",
27
29
  "typecheck": "tsc --noEmit",
28
- "version": "node -e \"const p=require('./package.json'); const f='.claude-plugin/plugin.json'; const c=JSON.parse(require('fs').readFileSync(f,'utf8')); c.version=p.version; require('fs').writeFileSync(f,JSON.stringify(c,null,2)+'\\n')\" && git add .claude-plugin/plugin.json",
30
+ "version": "node scripts/sync-plugin-version.mjs && git add .claude-plugin/plugin.json .claude-plugin/marketplace.json .agents/plugins/marketplace.json codex/.codex-plugin/plugin.json",
29
31
  "prepublishOnly": "npm run lint && npm run test && npm run build",
30
- "prepare": "husky && npm run build"
32
+ "prepare": "husky; npm run build"
31
33
  },
32
34
  "keywords": [
33
35
  "mcp",
34
36
  "apple-notes",
35
37
  "claude",
38
+ "codex",
36
39
  "ai",
37
40
  "applescript",
38
41
  "macos",