apple-notes-mcp 1.2.19 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -107,10 +107,10 @@ Creates a new note in Apple Notes.
107
107
 
108
108
  | Parameter | Type | Required | Description |
109
109
  |-----------|------|----------|-------------|
110
- | `title` | string | Yes | The title of the note (becomes first line) |
111
- | `content` | string | Yes | The body content of the note |
110
+ | `title` | string | Yes | The title of the note. Automatically prepended as `<h1>` — do NOT include the title in `content` |
111
+ | `content` | string | Yes | The body content of the note (do not repeat the title here) |
112
112
  | `tags` | string[] | No | Tags for organization (stored in metadata) |
113
- | `format` | string | No | Content format: `"plaintext"` (default) or `"html"`. When `"html"`, content is used as raw HTML for rich formatting |
113
+ | `format` | string | No | Content format: `"plaintext"` (default) or `"html"`. In both formats, the title is automatically prepended as `<h1>`. In plaintext mode, newlines become `<br>`, tabs become `<br>`, and backslashes are preserved as HTML entities |
114
114
 
115
115
  **Example:**
116
116
  ```json
@@ -125,11 +125,13 @@ Creates a new note in Apple Notes.
125
125
  ```json
126
126
  {
127
127
  "title": "Status Report",
128
- "content": "<h1>Status Report</h1><h2>Summary</h2><p>All tasks <b>on track</b>.</p><ul><li>Feature A: complete</li><li>Feature B: in progress</li></ul>",
128
+ "content": "<h2>Summary</h2><p>All tasks <b>on track</b>.</p><ul><li>Feature A: complete</li><li>Feature B: in progress</li></ul>",
129
129
  "format": "html"
130
130
  }
131
131
  ```
132
132
 
133
+ > **Note:** The title is automatically prepended as `<h1>` in both plaintext and HTML formats. Do not include a `<h1>` title tag in the `content` parameter, or the title will appear twice.
134
+
133
135
  **Returns:** Confirmation message with note title and ID. Save the ID for subsequent operations like `update-note`, `delete-note`, etc.
134
136
 
135
137
  ---
@@ -143,6 +145,9 @@ Searches for notes by title or content.
143
145
  | `query` | string | Yes | Text to search for |
144
146
  | `searchContent` | boolean | No | If `true`, searches note body; if `false` (default), searches titles only |
145
147
  | `account` | string | No | Account to search in (defaults to iCloud) |
148
+ | `folder` | string | No | Limit search to a specific folder |
149
+ | `modifiedSince` | string | No | ISO 8601 date string to filter notes modified on or after this date (e.g., `"2025-01-01"`) |
150
+ | `limit` | number | No | Maximum number of results to return |
146
151
 
147
152
  **Example - Search titles:**
148
153
  ```json
@@ -159,6 +164,16 @@ Searches for notes by title or content.
159
164
  }
160
165
  ```
161
166
 
167
+ **Example - Search recent notes with limit:**
168
+ ```json
169
+ {
170
+ "query": "todo",
171
+ "searchContent": true,
172
+ "modifiedSince": "2025-01-01",
173
+ "limit": 10
174
+ }
175
+ ```
176
+
162
177
  **Returns:** List of matching notes with titles, folder names, and IDs. Use the returned ID for subsequent operations like `get-note-content`, `update-note`, etc.
163
178
 
164
179
  ---
@@ -280,7 +295,7 @@ Updates an existing note's content and/or title.
280
295
  ```json
281
296
  {
282
297
  "id": "x-coredata://ABC123/ICNote/p456",
283
- "newContent": "<h1>Updated Report</h1><p>New findings with <b>bold</b> emphasis.</p><pre><code>console.log('hello');</code></pre>",
298
+ "newContent": "<p>New findings with <b>bold</b> emphasis.</p><pre><code>console.log('hello');</code></pre>",
284
299
  "format": "html"
285
300
  }
286
301
  ```
@@ -356,12 +371,14 @@ Moves a note to a different folder.
356
371
 
357
372
  #### `list-notes`
358
373
 
359
- Lists all notes, optionally filtered by folder.
374
+ Lists all notes, optionally filtered by folder, date, and limit.
360
375
 
361
376
  | Parameter | Type | Required | Description |
362
377
  |-----------|------|----------|-------------|
363
378
  | `account` | string | No | Account to list notes from (defaults to iCloud) |
364
379
  | `folder` | string | No | Filter to notes in this folder only |
380
+ | `modifiedSince` | string | No | ISO 8601 date string to filter notes modified on or after this date (e.g., `"2025-01-01"`) |
381
+ | `limit` | number | No | Maximum number of notes to return |
365
382
 
366
383
  **Example - All notes:**
367
384
  ```json
@@ -375,6 +392,14 @@ Lists all notes, optionally filtered by folder.
375
392
  }
376
393
  ```
377
394
 
395
+ **Example - Recent notes with limit:**
396
+ ```json
397
+ {
398
+ "modifiedSince": "2025-06-01",
399
+ "limit": 20
400
+ }
401
+ ```
402
+
378
403
  **Returns:** List of note titles.
379
404
 
380
405
  ---
package/build/index.js CHANGED
@@ -132,11 +132,18 @@ server.tool("search-notes", {
132
132
  searchContent: z.boolean().optional().describe("Search note content instead of titles"),
133
133
  account: z.string().optional().describe("Account to search in"),
134
134
  folder: z.string().optional().describe("Limit search to a specific folder"),
135
- }, withErrorHandling(({ query, searchContent = false, account, folder }) => {
135
+ modifiedSince: z
136
+ .string()
137
+ .optional()
138
+ .describe("ISO 8601 date string to filter notes modified on or after this date (e.g., '2025-01-01'). Useful for searching only recent notes in large collections."),
139
+ limit: z.number().int().positive().optional().describe("Maximum number of results to return"),
140
+ }, withErrorHandling(({ query, searchContent = false, account, folder, modifiedSince, limit }) => {
136
141
  // Use sync-aware wrapper for this read operation
137
- const { result: notes, syncBefore, syncInterference, } = withSyncAwarenessSync("search-notes", () => notesManager.searchNotes(query, searchContent, account, folder));
142
+ const { result: notes, syncBefore, syncInterference, } = withSyncAwarenessSync("search-notes", () => notesManager.searchNotes(query, searchContent, account, folder, modifiedSince, limit));
138
143
  const searchType = searchContent ? "content" : "titles";
139
144
  const folderInfo = folder ? ` in folder "${folder}"` : "";
145
+ const dateInfo = modifiedSince ? ` modified since ${modifiedSince}` : "";
146
+ const limitInfo = limit ? ` (limit: ${limit})` : "";
140
147
  // Build sync warning if needed
141
148
  const syncWarnings = [];
142
149
  if (syncBefore.syncDetected) {
@@ -147,7 +154,7 @@ server.tool("search-notes", {
147
154
  }
148
155
  const syncNote = syncWarnings.length > 0 ? `\n\n${syncWarnings.join(" ")}` : "";
149
156
  if (notes.length === 0) {
150
- return successResponse(`No notes found matching "${query}" in ${searchType}${folderInfo}${syncNote}`);
157
+ return successResponse(`No notes found matching "${query}" in ${searchType}${folderInfo}${dateInfo}${syncNote}`);
151
158
  }
152
159
  // Format each note with ID and folder info, highlighting Recently Deleted
153
160
  const noteList = notes
@@ -162,7 +169,7 @@ server.tool("search-notes", {
162
169
  return ` - ${n.title}${idSuffix}`;
163
170
  })
164
171
  .join("\n");
165
- return successResponse(`Found ${notes.length} notes (searched ${searchType}${folderInfo}):\n${noteList}${syncNote}`);
172
+ return successResponse(`Found ${notes.length} notes (searched ${searchType}${folderInfo}${dateInfo}${limitInfo}):\n${noteList}${syncNote}`);
166
173
  }, "Error searching notes"));
167
174
  // --- get-note-content ---
168
175
  server.tool("get-note-content", {
@@ -388,12 +395,19 @@ server.tool("move-note", {
388
395
  server.tool("list-notes", {
389
396
  account: z.string().optional().describe("Account to list notes from"),
390
397
  folder: z.string().optional().describe("Filter to specific folder"),
391
- }, withErrorHandling(({ account, folder }) => {
398
+ modifiedSince: z
399
+ .string()
400
+ .optional()
401
+ .describe("ISO 8601 date string to filter notes modified on or after this date (e.g., '2025-01-01'). Useful for listing only recent notes in large collections."),
402
+ limit: z.number().int().positive().optional().describe("Maximum number of notes to return"),
403
+ }, withErrorHandling(({ account, folder, modifiedSince, limit }) => {
392
404
  // Use sync-aware wrapper for this read operation
393
- const { result: notes, syncBefore, syncInterference, } = withSyncAwarenessSync("list-notes", () => notesManager.listNotes(account, folder));
405
+ const { result: notes, syncBefore, syncInterference, } = withSyncAwarenessSync("list-notes", () => notesManager.listNotes(account, folder, modifiedSince, limit));
394
406
  // Build context string for the response
395
407
  const location = folder ? ` in folder "${folder}"` : "";
396
408
  const acct = account ? ` (${account})` : "";
409
+ const dateInfo = modifiedSince ? ` modified since ${modifiedSince}` : "";
410
+ const limitInfo = limit ? ` (limit: ${limit})` : "";
397
411
  // Build sync warning if needed
398
412
  const syncWarnings = [];
399
413
  if (syncBefore.syncDetected) {
@@ -404,10 +418,10 @@ server.tool("list-notes", {
404
418
  }
405
419
  const syncNote = syncWarnings.length > 0 ? `\n\n${syncWarnings.join(" ")}` : "";
406
420
  if (notes.length === 0) {
407
- return successResponse(`No notes found${location}${acct}${syncNote}`);
421
+ return successResponse(`No notes found${location}${acct}${dateInfo}${syncNote}`);
408
422
  }
409
423
  const noteList = notes.map((t) => ` - ${t}`).join("\n");
410
- return successResponse(`Found ${notes.length} notes${location}${acct}:\n${noteList}${syncNote}`);
424
+ return successResponse(`Found ${notes.length} notes${location}${acct}${dateInfo}${limitInfo}:\n${noteList}${syncNote}`);
411
425
  }, "Error listing notes"));
412
426
  // =============================================================================
413
427
  // Folder Tools
@@ -144,6 +144,33 @@ export function parseAppleScriptDate(appleScriptDate) {
144
144
  // Return parsed date if valid, otherwise current date as fallback
145
145
  return isNaN(parsed.getTime()) ? new Date() : parsed;
146
146
  }
147
+ /**
148
+ * Generates AppleScript code that creates a date variable with the given values.
149
+ *
150
+ * This approach is locale-independent, unlike `date "M/D/YYYY"` coercion which
151
+ * depends on the system's date format settings and would fail on non-US locales.
152
+ *
153
+ * @param date - JavaScript Date object
154
+ * @param varName - AppleScript variable name to assign (default: "thresholdDate")
155
+ * @returns AppleScript code that sets up the date variable
156
+ *
157
+ * @example
158
+ * buildAppleScriptDateVar(new Date("2025-06-15T00:00:00"))
159
+ * // Returns multi-line AppleScript that sets thresholdDate to June 15, 2025 midnight
160
+ */
161
+ export function buildAppleScriptDateVar(date, varName = "thresholdDate") {
162
+ const year = date.getFullYear();
163
+ const month = date.getMonth() + 1;
164
+ const day = date.getDate();
165
+ const timeInSeconds = date.getHours() * 3600 + date.getMinutes() * 60 + date.getSeconds();
166
+ return [
167
+ `set ${varName} to current date`,
168
+ `set year of ${varName} to ${year}`,
169
+ `set month of ${varName} to ${month}`,
170
+ `set day of ${varName} to ${day}`,
171
+ `set time of ${varName} to ${timeInSeconds}`,
172
+ ].join("\n");
173
+ }
147
174
  /**
148
175
  * Parses AppleScript note properties output into structured data.
149
176
  *
@@ -372,32 +399,41 @@ export class AppleNotesManager {
372
399
  * // Create in a different account
373
400
  * const gmail = manager.createNote("Draft", "...", [], undefined, "Gmail");
374
401
  *
375
- * // Create with HTML formatting
376
- * const html = manager.createNote("Report", "<h1>Report</h1><p>Details here</p>",
402
+ * // Create with HTML formatting (no need for <h1> — title is auto-prepended)
403
+ * const html = manager.createNote("Report", "<p>Details here</p>",
377
404
  * [], undefined, undefined, "html");
378
405
  * ```
379
406
  */
380
407
  createNote(title, content, tags = [], folder, account, format = "plaintext") {
381
408
  const targetAccount = this.resolveAccount(account);
382
- // Escape content for AppleScript embedding
383
- const safeTitle = escapeForAppleScript(title);
384
- const safeContent = format === "html" ? escapeHtmlForAppleScript(content) : escapeForAppleScript(content);
409
+ // Build body HTML: title as <h1>, content follows.
410
+ // We set only 'body' (not 'name') to avoid title duplication —
411
+ // Notes.app auto-uses the first line of body as the note's display title.
412
+ const htmlTitle = title.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
413
+ const bodyContent = format === "html"
414
+ ? content
415
+ : content
416
+ .replace(/&/g, "&amp;")
417
+ .replace(/\\/g, "&#92;")
418
+ .replace(/</g, "&lt;")
419
+ .replace(/>/g, "&gt;")
420
+ .replace(/\n/g, "<br>")
421
+ .replace(/\t/g, "<br>");
422
+ const safeBody = escapeHtmlForAppleScript(`<h1>${htmlTitle}</h1>${bodyContent}`);
385
423
  // Build the AppleScript command
386
- // Notes.app uses 'name' for the title and 'body' for content
387
- // We capture the ID of the newly created note
388
424
  let createCommand;
389
425
  if (folder) {
390
426
  // Create note in specific folder
391
427
  const safeFolder = escapeForAppleScript(folder);
392
428
  createCommand = `
393
- set newNote to make new note at folder "${safeFolder}" with properties {name:"${safeTitle}", body:"${safeContent}"}
429
+ set newNote to make new note at folder "${safeFolder}" with properties {body:"${safeBody}"}
394
430
  return id of newNote
395
431
  `;
396
432
  }
397
433
  else {
398
434
  // Create note in default location
399
435
  createCommand = `
400
- set newNote to make new note with properties {name:"${safeTitle}", body:"${safeContent}"}
436
+ set newNote to make new note with properties {body:"${safeBody}"}
401
437
  return id of newNote
402
438
  `;
403
439
  }
@@ -433,6 +469,8 @@ export class AppleNotesManager {
433
469
  * @param searchContent - If true, search note bodies; if false, search titles
434
470
  * @param account - Account to search in (defaults to iCloud)
435
471
  * @param folder - Optional folder to limit search to
472
+ * @param modifiedSince - Optional ISO 8601 date string to filter notes modified on or after this date
473
+ * @param limit - Optional maximum number of results to return (default: no limit)
436
474
  * @returns Array of matching notes (with minimal metadata)
437
475
  *
438
476
  * @example
@@ -445,25 +483,53 @@ export class AppleNotesManager {
445
483
  *
446
484
  * // Search within a specific folder
447
485
  * const workNotes = manager.searchNotes("deadline", false, "iCloud", "Work");
486
+ *
487
+ * // Search only recently modified notes
488
+ * const recentNotes = manager.searchNotes("todo", true, undefined, undefined, "2025-01-01");
489
+ *
490
+ * // Search with a result limit
491
+ * const topResults = manager.searchNotes("project", false, undefined, undefined, undefined, 10);
448
492
  * ```
449
493
  */
450
- searchNotes(query, searchContent = false, account, folder) {
494
+ searchNotes(query, searchContent = false, account, folder, modifiedSince, limit) {
451
495
  const targetAccount = this.resolveAccount(account);
452
496
  const safeQuery = escapeForAppleScript(query);
497
+ const safeLimit = limit !== undefined && limit > 0 ? Math.floor(limit) : undefined;
453
498
  // Build the where clause based on search type
454
499
  // AppleScript uses 'name' for title and 'body' for content
455
- const whereClause = searchContent
456
- ? `body contains "${safeQuery}"`
457
- : `name contains "${safeQuery}"`;
500
+ const whereParts = [];
501
+ if (searchContent) {
502
+ whereParts.push(`body contains "${safeQuery}"`);
503
+ }
504
+ else {
505
+ whereParts.push(`name contains "${safeQuery}"`);
506
+ }
507
+ // Add date filter if specified (uses locale-safe date variable)
508
+ let dateSetup = "";
509
+ if (modifiedSince) {
510
+ const date = new Date(modifiedSince);
511
+ if (!isNaN(date.getTime())) {
512
+ dateSetup = buildAppleScriptDateVar(date) + "\n";
513
+ whereParts.push(`modification date >= thresholdDate`);
514
+ }
515
+ }
516
+ const whereClause = whereParts.join(" and ");
458
517
  // Build the notes source - either all notes or notes in a specific folder
459
518
  const notesSource = folder ? `notes of folder "${escapeForAppleScript(folder)}"` : "notes";
519
+ // Build the limit logic for the repeat loop
520
+ // Note: The limit only reduces iteration over already-matched results from the whose clause,
521
+ // not the query itself. It controls output size, not AppleScript query performance.
522
+ const limitCheck = safeLimit !== undefined
523
+ ? `
524
+ if (count of resultList) >= ${safeLimit} then exit repeat`
525
+ : "";
460
526
  // Get names, IDs, and folder for each matching note
461
527
  // We use a repeat loop to get all properties, separated by a delimiter
462
528
  // Note: Some notes may have inaccessible containers, so we wrap in try/on error
463
529
  const searchCommand = `
464
- set matchingNotes to ${notesSource} where ${whereClause}
530
+ ${dateSetup}set matchingNotes to ${notesSource} where ${whereClause}
465
531
  set resultList to {}
466
- repeat with n in matchingNotes
532
+ repeat with n in matchingNotes${limitCheck}
467
533
  try
468
534
  set noteName to name of n
469
535
  set noteId to id of n
@@ -785,15 +851,60 @@ export class AppleNotesManager {
785
851
  return true;
786
852
  }
787
853
  /**
788
- * Lists all notes in an account, optionally filtered by folder.
854
+ * Lists all notes in an account, optionally filtered by folder, date, and limit.
789
855
  *
790
856
  * @param account - Account to list notes from (defaults to iCloud)
791
857
  * @param folder - Optional folder to filter by
858
+ * @param modifiedSince - Optional ISO 8601 date string to filter notes modified on or after this date
859
+ * @param limit - Optional maximum number of results to return (default: no limit)
792
860
  * @returns Array of note titles
793
861
  */
794
- listNotes(account, folder) {
862
+ listNotes(account, folder, modifiedSince, limit) {
795
863
  const targetAccount = this.resolveAccount(account);
796
- // Build command based on whether folder filter is specified
864
+ const safeLimit = limit !== undefined && limit > 0 ? Math.floor(limit) : undefined;
865
+ // When date or limit filters are needed, use a repeat loop for fine-grained control
866
+ if (modifiedSince || safeLimit !== undefined) {
867
+ const baseNotesSource = folder
868
+ ? `notes of folder "${escapeForAppleScript(folder)}"`
869
+ : "notes";
870
+ // Use whose clause for date filtering (locale-safe, no sort order assumption)
871
+ let dateSetup = "";
872
+ let notesSource = baseNotesSource;
873
+ if (modifiedSince) {
874
+ const date = new Date(modifiedSince);
875
+ if (!isNaN(date.getTime())) {
876
+ dateSetup = buildAppleScriptDateVar(date) + "\n";
877
+ notesSource = `(${baseNotesSource} whose modification date >= thresholdDate)`;
878
+ }
879
+ }
880
+ // Build the limit check
881
+ const limitCheck = safeLimit !== undefined
882
+ ? `
883
+ if (count of resultList) >= ${safeLimit} then exit repeat`
884
+ : "";
885
+ const listCommand = `
886
+ ${dateSetup}set resultList to {}
887
+ repeat with n in ${notesSource}${limitCheck}
888
+ set end of resultList to name of n
889
+ end repeat
890
+ set AppleScript's text item delimiters to "|||"
891
+ return resultList as text
892
+ `;
893
+ const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
894
+ const result = executeAppleScript(script);
895
+ if (!result.success) {
896
+ console.error("Failed to list notes:", result.error);
897
+ return [];
898
+ }
899
+ if (!result.output.trim()) {
900
+ return [];
901
+ }
902
+ return result.output
903
+ .split("|||")
904
+ .map((item) => item.trim())
905
+ .filter((item) => item.length > 0);
906
+ }
907
+ // Simple path: no date or limit filters
797
908
  let listCommand;
798
909
  if (folder) {
799
910
  const safeFolder = escapeForAppleScript(folder);
@@ -11,7 +11,7 @@
11
11
  * - Script generation is verified by checking for expected AppleScript patterns
12
12
  */
13
13
  import { describe, it, expect, vi, beforeEach } from "vitest";
14
- import { AppleNotesManager, escapeForAppleScript, escapeHtmlForAppleScript, parseAppleScriptDate, } from "./appleNotesManager.js";
14
+ import { AppleNotesManager, escapeForAppleScript, escapeHtmlForAppleScript, buildAppleScriptDateVar, parseAppleScriptDate, } from "./appleNotesManager.js";
15
15
  // Mock the AppleScript execution module
16
16
  // This prevents actual osascript calls during testing
17
17
  vi.mock("@/utils/applescript.js", () => ({
@@ -210,6 +210,41 @@ describe("parseAppleScriptDate", () => {
210
210
  });
211
211
  });
212
212
  // =============================================================================
213
+ // buildAppleScriptDateVar Tests
214
+ // =============================================================================
215
+ describe("buildAppleScriptDateVar", () => {
216
+ it("generates locale-safe AppleScript date setup code", () => {
217
+ const date = new Date(2025, 5, 15, 14, 30, 0); // June 15, 2025 2:30 PM
218
+ const result = buildAppleScriptDateVar(date);
219
+ expect(result).toContain("set thresholdDate to current date");
220
+ expect(result).toContain("set year of thresholdDate to 2025");
221
+ expect(result).toContain("set month of thresholdDate to 6");
222
+ expect(result).toContain("set day of thresholdDate to 15");
223
+ // 14*3600 + 30*60 = 52200
224
+ expect(result).toContain("set time of thresholdDate to 52200");
225
+ });
226
+ it("handles midnight (time = 0)", () => {
227
+ const date = new Date(2025, 0, 1, 0, 0, 0); // Jan 1, 2025 midnight
228
+ const result = buildAppleScriptDateVar(date);
229
+ expect(result).toContain("set month of thresholdDate to 1");
230
+ expect(result).toContain("set day of thresholdDate to 1");
231
+ expect(result).toContain("set time of thresholdDate to 0");
232
+ });
233
+ it("uses custom variable name", () => {
234
+ const date = new Date(2025, 0, 1, 0, 0, 0);
235
+ const result = buildAppleScriptDateVar(date, "myDate");
236
+ expect(result).toContain("set myDate to current date");
237
+ expect(result).toContain("set year of myDate to 2025");
238
+ expect(result).toContain("set month of myDate to 1");
239
+ });
240
+ it("calculates time in seconds correctly", () => {
241
+ const date = new Date(2025, 11, 25, 9, 5, 3); // 9:05:03 AM
242
+ const result = buildAppleScriptDateVar(date);
243
+ // 9*3600 + 5*60 + 3 = 32703
244
+ expect(result).toContain("set time of thresholdDate to 32703");
245
+ });
246
+ });
247
+ // =============================================================================
213
248
  // AppleNotesManager Tests
214
249
  // =============================================================================
215
250
  describe("AppleNotesManager", () => {
@@ -298,6 +333,62 @@ describe("AppleNotesManager", () => {
298
333
  // Double quotes must be escaped for AppleScript string embedding
299
334
  expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('<div class=\\"test\\">Content</div>'));
300
335
  });
336
+ it("sets title as h1 in body, not as name property", () => {
337
+ mockExecuteAppleScript.mockReturnValue({
338
+ success: true,
339
+ output: "note id x-coredata://12345/ICNote/p203",
340
+ });
341
+ manager.createNote("My Title", "Body content");
342
+ const script = mockExecuteAppleScript.mock.calls[0][0];
343
+ // Title must appear as h1 in body
344
+ expect(script).toContain("<h1>My Title</h1>");
345
+ // name property must NOT be set (causes title duplication in Notes.app)
346
+ expect(script).not.toContain('name:"My Title"');
347
+ });
348
+ it("HTML-encodes special chars in title for h1 tag", () => {
349
+ mockExecuteAppleScript.mockReturnValue({
350
+ success: true,
351
+ output: "note id x-coredata://12345/ICNote/p204",
352
+ });
353
+ manager.createNote("Q&A: <Hello> World", "Content");
354
+ expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("<h1>Q&amp;A: &lt;Hello&gt; World</h1>"));
355
+ });
356
+ it("HTML-encodes special chars in plaintext content", () => {
357
+ mockExecuteAppleScript.mockReturnValue({
358
+ success: true,
359
+ output: "note id x-coredata://12345/ICNote/p205",
360
+ });
361
+ manager.createNote("Title", "Price: <10 & >5\nNext line");
362
+ const script = mockExecuteAppleScript.mock.calls[0][0];
363
+ expect(script).toContain("Price: &lt;10 &amp; &gt;5<br>Next line");
364
+ });
365
+ it("prepends h1 title before html content", () => {
366
+ mockExecuteAppleScript.mockReturnValue({
367
+ success: true,
368
+ output: "note id x-coredata://12345/ICNote/p206",
369
+ });
370
+ manager.createNote("Report", "<h2>Section</h2><div>Details</div>", [], undefined, undefined, "html");
371
+ const script = mockExecuteAppleScript.mock.calls[0][0];
372
+ expect(script).toContain("<h1>Report</h1><h2>Section</h2><div>Details</div>");
373
+ });
374
+ it("encodes backslashes as HTML entities in plaintext content", () => {
375
+ mockExecuteAppleScript.mockReturnValue({
376
+ success: true,
377
+ output: "note id x-coredata://12345/ICNote/p207",
378
+ });
379
+ manager.createNote("Title", "path\\to\\file");
380
+ const script = mockExecuteAppleScript.mock.calls[0][0];
381
+ expect(script).toContain("path&#92;to&#92;file");
382
+ });
383
+ it("converts tabs to br in plaintext content", () => {
384
+ mockExecuteAppleScript.mockReturnValue({
385
+ success: true,
386
+ output: "note id x-coredata://12345/ICNote/p208",
387
+ });
388
+ manager.createNote("Title", "col1\tcol2\tcol3");
389
+ const script = mockExecuteAppleScript.mock.calls[0][0];
390
+ expect(script).toContain("col1<br>col2<br>col3");
391
+ });
301
392
  });
302
393
  // ---------------------------------------------------------------------------
303
394
  // Note Search
@@ -393,6 +484,65 @@ describe("AppleNotesManager", () => {
393
484
  expect(script).toContain('tell account "Exchange"');
394
485
  expect(script).toContain('notes of folder "Projects"');
395
486
  });
487
+ it("adds date filter when modifiedSince is provided", () => {
488
+ mockExecuteAppleScript.mockReturnValue({
489
+ success: true,
490
+ output: "Recent Note|||x-coredata://ABC/ICNote/p1|||Notes",
491
+ });
492
+ manager.searchNotes("note", false, undefined, undefined, "2025-06-15T00:00:00");
493
+ const script = mockExecuteAppleScript.mock.calls[0][0];
494
+ // Locale-safe: uses variable setup instead of date "string"
495
+ expect(script).toContain("set thresholdDate to current date");
496
+ expect(script).toContain("set year of thresholdDate to 2025");
497
+ expect(script).toContain("set month of thresholdDate to 6");
498
+ expect(script).toContain("set day of thresholdDate to 15");
499
+ expect(script).toContain("modification date >= thresholdDate");
500
+ expect(script).toContain('name contains "note"');
501
+ });
502
+ it("combines date filter with content search", () => {
503
+ mockExecuteAppleScript.mockReturnValue({
504
+ success: true,
505
+ output: "Note|||x-coredata://ABC/ICNote/p1|||Notes",
506
+ });
507
+ manager.searchNotes("keyword", true, undefined, undefined, "2025-01-01");
508
+ const script = mockExecuteAppleScript.mock.calls[0][0];
509
+ expect(script).toContain('body contains "keyword"');
510
+ expect(script).toContain("set thresholdDate to current date");
511
+ expect(script).toContain("modification date >= thresholdDate");
512
+ });
513
+ it("ignores invalid modifiedSince date", () => {
514
+ mockExecuteAppleScript.mockReturnValue({
515
+ success: true,
516
+ output: "Note|||x-coredata://ABC/ICNote/p1|||Notes",
517
+ });
518
+ manager.searchNotes("note", false, undefined, undefined, "not-a-date");
519
+ const script = mockExecuteAppleScript.mock.calls[0][0];
520
+ expect(script).not.toContain("modification date");
521
+ });
522
+ it("applies limit to search results", () => {
523
+ mockExecuteAppleScript.mockReturnValue({
524
+ success: true,
525
+ output: "Note 1|||x-coredata://ABC/ICNote/p1|||Notes",
526
+ });
527
+ manager.searchNotes("note", false, undefined, undefined, undefined, 5);
528
+ const script = mockExecuteAppleScript.mock.calls[0][0];
529
+ expect(script).toContain("(count of resultList) >= 5");
530
+ expect(script).toContain("exit repeat");
531
+ });
532
+ it("combines modifiedSince, limit, folder, and content search", () => {
533
+ mockExecuteAppleScript.mockReturnValue({
534
+ success: true,
535
+ output: "Note|||x-coredata://ABC/ICNote/p1|||Work",
536
+ });
537
+ manager.searchNotes("project", true, "iCloud", "Work", "2025-03-01", 10);
538
+ const script = mockExecuteAppleScript.mock.calls[0][0];
539
+ expect(script).toContain('body contains "project"');
540
+ expect(script).toContain("set thresholdDate to current date");
541
+ expect(script).toContain("modification date >= thresholdDate");
542
+ expect(script).toContain('notes of folder "Work"');
543
+ expect(script).toContain("(count of resultList) >= 10");
544
+ expect(script).toContain('tell account "iCloud"');
545
+ });
396
546
  });
397
547
  // ---------------------------------------------------------------------------
398
548
  // Note Content Retrieval
@@ -742,6 +892,61 @@ describe("AppleNotesManager", () => {
742
892
  manager.listNotes("iCloud", "Work");
743
893
  expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('notes of folder "Work"'));
744
894
  });
895
+ it("uses whose clause when modifiedSince is provided", () => {
896
+ mockExecuteAppleScript.mockReturnValue({
897
+ success: true,
898
+ output: "Recent Note 1|||Recent Note 2",
899
+ });
900
+ const results = manager.listNotes(undefined, undefined, "2025-06-15T00:00:00");
901
+ const script = mockExecuteAppleScript.mock.calls[0][0];
902
+ // Locale-safe: uses variable setup + whose clause (no sort order assumption)
903
+ expect(script).toContain("set thresholdDate to current date");
904
+ expect(script).toContain("set year of thresholdDate to 2025");
905
+ expect(script).toContain("set month of thresholdDate to 6");
906
+ expect(script).toContain("set day of thresholdDate to 15");
907
+ expect(script).toContain("whose modification date >= thresholdDate");
908
+ expect(results).toEqual(["Recent Note 1", "Recent Note 2"]);
909
+ });
910
+ it("uses repeat loop when limit is provided", () => {
911
+ mockExecuteAppleScript.mockReturnValue({
912
+ success: true,
913
+ output: "Note 1|||Note 2|||Note 3",
914
+ });
915
+ const results = manager.listNotes(undefined, undefined, undefined, 3);
916
+ const script = mockExecuteAppleScript.mock.calls[0][0];
917
+ expect(script).toContain("(count of resultList) >= 3");
918
+ expect(results).toEqual(["Note 1", "Note 2", "Note 3"]);
919
+ });
920
+ it("combines folder, modifiedSince, and limit", () => {
921
+ mockExecuteAppleScript.mockReturnValue({
922
+ success: true,
923
+ output: "Work Note|||Another Work Note",
924
+ });
925
+ manager.listNotes("iCloud", "Work", "2025-01-01", 10);
926
+ const script = mockExecuteAppleScript.mock.calls[0][0];
927
+ expect(script).toContain("whose modification date >= thresholdDate");
928
+ expect(script).toContain('notes of folder "Work"');
929
+ expect(script).toContain("(count of resultList) >= 10");
930
+ });
931
+ it("returns empty array when modifiedSince yields no results", () => {
932
+ mockExecuteAppleScript.mockReturnValue({
933
+ success: true,
934
+ output: "",
935
+ });
936
+ const results = manager.listNotes(undefined, undefined, "2099-01-01");
937
+ expect(results).toEqual([]);
938
+ });
939
+ it("ignores invalid modifiedSince date and falls back to limit-only", () => {
940
+ mockExecuteAppleScript.mockReturnValue({
941
+ success: true,
942
+ output: "Note 1|||Note 2",
943
+ });
944
+ const results = manager.listNotes(undefined, undefined, "not-a-date", 5);
945
+ const script = mockExecuteAppleScript.mock.calls[0][0];
946
+ expect(script).not.toContain("thresholdDate");
947
+ expect(script).toContain("(count of resultList) >= 5");
948
+ expect(results).toEqual(["Note 1", "Note 2"]);
949
+ });
745
950
  });
746
951
  // ---------------------------------------------------------------------------
747
952
  // Folder Operations
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "apple-notes-mcp",
3
- "version": "1.2.19",
3
+ "version": "1.3.1",
4
4
  "description": "MCP server for Apple Notes - create, search, update, and manage notes via Claude",
5
5
  "type": "module",
6
6
  "main": "build/index.js",