apple-notes-mcp 2.0.1 → 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 ---
@@ -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;
@@ -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,
@@ -1002,7 +1015,11 @@ describe("AppleNotesManager", () => {
1002
1015
  it("returns array of note titles", () => {
1003
1016
  mockExecuteAppleScript.mockReturnValue({
1004
1017
  success: true,
1005
- 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),
1006
1023
  });
1007
1024
  const titles = manager.listNotes();
1008
1025
  expect(titles).toEqual(["Note A", "Note B", "Note C"]);
@@ -1010,11 +1027,29 @@ describe("AppleNotesManager", () => {
1010
1027
  it("filters out empty entries", () => {
1011
1028
  mockExecuteAppleScript.mockReturnValue({
1012
1029
  success: true,
1013
- 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),
1014
1037
  });
1015
1038
  const titles = manager.listNotes();
1016
1039
  expect(titles).toEqual(["Note A", "Note B"]);
1017
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
+ });
1018
1053
  it("throws on failure rather than returning empty (#19)", () => {
1019
1054
  mockExecuteAppleScript.mockReturnValue({
1020
1055
  success: false,
@@ -1026,7 +1061,10 @@ describe("AppleNotesManager", () => {
1026
1061
  it("filters by folder when specified", () => {
1027
1062
  mockExecuteAppleScript.mockReturnValue({
1028
1063
  success: true,
1029
- 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),
1030
1068
  });
1031
1069
  manager.listNotes("iCloud", "Work");
1032
1070
  expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('notes of folder "Work"'));
@@ -1034,7 +1072,10 @@ describe("AppleNotesManager", () => {
1034
1072
  it("uses whose clause when modifiedSince is provided", () => {
1035
1073
  mockExecuteAppleScript.mockReturnValue({
1036
1074
  success: true,
1037
- 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),
1038
1079
  });
1039
1080
  const results = manager.listNotes(undefined, undefined, "2025-06-15T00:00:00");
1040
1081
  const script = mockExecuteAppleScript.mock.calls[0][0];
@@ -1049,7 +1090,11 @@ describe("AppleNotesManager", () => {
1049
1090
  it("uses repeat loop when limit is provided", () => {
1050
1091
  mockExecuteAppleScript.mockReturnValue({
1051
1092
  success: true,
1052
- 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),
1053
1098
  });
1054
1099
  const results = manager.listNotes(undefined, undefined, undefined, 3);
1055
1100
  const script = mockExecuteAppleScript.mock.calls[0][0];
@@ -1059,7 +1104,10 @@ describe("AppleNotesManager", () => {
1059
1104
  it("combines folder, modifiedSince, and limit", () => {
1060
1105
  mockExecuteAppleScript.mockReturnValue({
1061
1106
  success: true,
1062
- 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),
1063
1111
  });
1064
1112
  manager.listNotes("iCloud", "Work", "2025-01-01", 10);
1065
1113
  const script = mockExecuteAppleScript.mock.calls[0][0];
@@ -1078,7 +1126,10 @@ describe("AppleNotesManager", () => {
1078
1126
  it("ignores invalid modifiedSince date and falls back to limit-only", () => {
1079
1127
  mockExecuteAppleScript.mockReturnValue({
1080
1128
  success: true,
1081
- 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),
1082
1133
  });
1083
1134
  const results = manager.listNotes(undefined, undefined, "not-a-date", 5);
1084
1135
  const script = mockExecuteAppleScript.mock.calls[0][0];
@@ -1399,7 +1450,13 @@ describe("AppleNotesManager", () => {
1399
1450
  // Check 3: listAccounts
1400
1451
  .mockReturnValueOnce({ success: true, output: "iCloud" })
1401
1452
  // Check 4: listNotes
1402
- .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
+ });
1403
1460
  const result = manager.healthCheck();
1404
1461
  expect(result.healthy).toBe(true);
1405
1462
  expect(result.checks).toHaveLength(4);
@@ -1498,6 +1555,56 @@ describe("AppleNotesManager", () => {
1498
1555
  expect(stats.accounts[0].name).toBe("iCloud");
1499
1556
  expect(stats.accounts[1].name).toBe("Gmail");
1500
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
+ });
1501
1608
  });
1502
1609
  // ---------------------------------------------------------------------------
1503
1610
  // Attachment Listing
@@ -1578,50 +1685,23 @@ describe("AppleNotesManager", () => {
1578
1685
  // Batch Operations
1579
1686
  // ---------------------------------------------------------------------------
1580
1687
  describe("batchDeleteNotes", () => {
1581
- // Helper to create getNoteById mock output (matches AppleScript format)
1582
- const noteByIdOutput = (title, passwordProtected = false) => [
1583
- title,
1584
- "x-coredata://ABC/ICNote/p1",
1585
- "Sunday, January 1, 2025 at 1:00:00 PM",
1586
- "Sunday, January 1, 2025 at 1:00:00 PM",
1587
- "false",
1588
- String(passwordProtected),
1589
- ].join(F);
1590
- it("deletes multiple notes successfully", () => {
1591
- // For each note: getNoteById (which isNotePasswordProtectedById also calls), deleteNoteById
1592
- mockExecuteAppleScript
1593
- // First note: getNoteById for existence check
1594
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1595
- // First note: getNoteById for password check (isNotePasswordProtectedById calls getNoteById)
1596
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1597
- // First note: deleteNoteById
1598
- .mockReturnValueOnce({ success: true, output: "" })
1599
- // Second note: getNoteById for existence check
1600
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
1601
- // Second note: getNoteById for password check
1602
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
1603
- // Second note: deleteNoteById
1604
- .mockReturnValueOnce({ success: true, output: "" });
1605
- const results = manager.batchDeleteNotes([
1606
- "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1607
- "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1608
- ]);
1609
- expect(results).toHaveLength(2);
1610
- expect(results[0]).toEqual({
1611
- id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1612
- success: true,
1613
- });
1614
- expect(results[1]).toEqual({
1615
- 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({
1616
1693
  success: true,
1694
+ output: ["ok", "ok"].join(R) + R,
1617
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 });
1618
1702
  });
1619
1703
  it("returns error for non-existent note", () => {
1620
- mockExecuteAppleScript.mockReturnValueOnce({
1621
- success: false,
1622
- output: "",
1623
- error: "Not found",
1624
- });
1704
+ mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "missing" + R });
1625
1705
  const results = manager.batchDeleteNotes([
1626
1706
  "x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
1627
1707
  ]);
@@ -1632,97 +1712,58 @@ describe("AppleNotesManager", () => {
1632
1712
  });
1633
1713
  });
1634
1714
  it("returns error for password-protected note", () => {
1635
- mockExecuteAppleScript
1636
- // getNoteById for existence check
1637
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) })
1638
- // getNoteById for password check (returns true for passwordProtected)
1639
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) });
1640
- const results = manager.batchDeleteNotes([
1641
- "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1642
- ]);
1643
- expect(results[0]).toEqual({
1644
- id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1645
- success: false,
1646
- error: "Note is password-protected",
1647
- });
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" });
1648
1718
  });
1649
- it("handles mixed success and failure", () => {
1650
- mockExecuteAppleScript
1651
- // First note: success
1652
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1653
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1654
- .mockReturnValueOnce({ success: true, output: "" })
1655
- // Second note: not found
1656
- .mockReturnValueOnce({ success: false, output: "", error: "Not found" });
1657
- const results = manager.batchDeleteNotes([
1658
- "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1659
- "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1660
- ]);
1661
- expect(results[0]).toEqual({
1662
- id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1719
+ it("handles mixed success and failure, preserving order", () => {
1720
+ mockExecuteAppleScript.mockReturnValueOnce({
1663
1721
  success: true,
1722
+ output: ["ok", "missing"].join(R) + R,
1664
1723
  });
1665
- expect(results[1]).toEqual({
1666
- 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({
1667
1737
  success: false,
1668
- error: "Note not found",
1738
+ output: "",
1739
+ error: "Notes.app not responding",
1669
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();
1670
1749
  });
1671
1750
  });
1672
1751
  describe("batchMoveNotes", () => {
1673
- // Helper to create getNoteById mock output (matches AppleScript format)
1674
- const noteByIdOutput = (title, passwordProtected = false) => [
1675
- title,
1676
- "x-coredata://ABC/ICNote/p1",
1677
- "Sunday, January 1, 2025 at 1:00:00 PM",
1678
- "Sunday, January 1, 2025 at 1:00:00 PM",
1679
- "false",
1680
- String(passwordProtected),
1681
- ].join(F);
1682
- it("moves multiple notes successfully", () => {
1683
- // For each note: getNoteById, getNoteById (password check), getNoteContentById, create, delete
1684
- mockExecuteAppleScript
1685
- // First note: getNoteById for existence check
1686
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1687
- // First note: getNoteById for password check
1688
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 1", false) })
1689
- // First note: moveNoteById calls getNoteContentById
1690
- .mockReturnValueOnce({ success: true, output: "<div>Note 1</div><div>Content</div>" })
1691
- // First note: createNote in destination
1692
- .mockReturnValueOnce({ success: true, output: "" })
1693
- // First note: deleteNoteById (original)
1694
- .mockReturnValueOnce({ success: true, output: "" })
1695
- // Second note: getNoteById for existence check
1696
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
1697
- // Second note: getNoteById for password check
1698
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Note 2", false) })
1699
- // Second note: moveNoteById calls getNoteContentById
1700
- .mockReturnValueOnce({ success: true, output: "<div>Note 2</div><div>Content</div>" })
1701
- // Second note: createNote in destination
1702
- .mockReturnValueOnce({ success: true, output: "" })
1703
- // Second note: deleteNoteById (original)
1704
- .mockReturnValueOnce({ success: true, output: "" });
1705
- const results = manager.batchMoveNotes([
1706
- "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1707
- "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2",
1708
- ], "Archive");
1709
- expect(results).toHaveLength(2);
1710
- expect(results[0]).toEqual({
1711
- id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1712
- success: true,
1713
- });
1714
- expect(results[1]).toEqual({
1715
- 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({
1716
1756
  success: true,
1757
+ output: ["ok", "ok"].join(R) + R,
1717
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 });
1718
1764
  });
1719
1765
  it("returns error for non-existent note", () => {
1720
- // getNoteById fails for existence check
1721
- mockExecuteAppleScript.mockReturnValueOnce({
1722
- success: false,
1723
- output: "",
1724
- error: "Not found",
1725
- });
1766
+ mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "missing" + R });
1726
1767
  const results = manager.batchMoveNotes(["x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404"], "Archive");
1727
1768
  expect(results[0]).toEqual({
1728
1769
  id: "x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
@@ -1731,17 +1772,18 @@ describe("AppleNotesManager", () => {
1731
1772
  });
1732
1773
  });
1733
1774
  it("returns error for password-protected note", () => {
1734
- mockExecuteAppleScript
1735
- // getNoteById for existence check
1736
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) })
1737
- // getNoteById for password check (returns true for passwordProtected)
1738
- .mockReturnValueOnce({ success: true, output: noteByIdOutput("Locked Note", true) });
1739
- const results = manager.batchMoveNotes(["x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1"], "Archive");
1740
- expect(results[0]).toEqual({
1741
- id: "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1",
1742
- success: false,
1743
- 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,
1744
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" });
1745
1787
  });
1746
1788
  });
1747
1789
  // ---------------------------------------------------------------------------
@@ -1764,7 +1806,10 @@ describe("AppleNotesManager", () => {
1764
1806
  // listFolders for iCloud
1765
1807
  .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
1766
1808
  // listNotes for Notes folder
1767
- .mockReturnValueOnce({ success: true, output: "Test Note" })
1809
+ .mockReturnValueOnce({
1810
+ success: true,
1811
+ output: ["Test Note", "x-coredata://ABC/ICNote/p1"].join(F),
1812
+ })
1768
1813
  // getNoteDetails
1769
1814
  .mockReturnValueOnce({ success: true, output: noteDetailsOutput("Test Note", false) })
1770
1815
  // getNoteContent
@@ -1789,7 +1834,10 @@ describe("AppleNotesManager", () => {
1789
1834
  // listFolders for iCloud
1790
1835
  .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
1791
1836
  // listNotes for Notes folder
1792
- .mockReturnValueOnce({ success: true, output: "Locked Note" })
1837
+ .mockReturnValueOnce({
1838
+ success: true,
1839
+ output: ["Locked Note", "x-coredata://ABC/ICNote/p1"].join(F),
1840
+ })
1793
1841
  // getNoteDetails (passwordProtected = true)
1794
1842
  .mockReturnValueOnce({ success: true, output: noteDetailsOutput("Locked Note", true) });
1795
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.1",
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",