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 +31 -6
- package/build/index.js +22 -8
- package/build/services/appleNotesManager.js +129 -18
- package/build/services/appleNotesManager.test.js +206 -1
- package/package.json +1 -1
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
|
|
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"`.
|
|
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": "<
|
|
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": "<
|
|
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
|
-
|
|
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
|
-
|
|
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", "<
|
|
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
|
-
//
|
|
383
|
-
|
|
384
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
413
|
+
const bodyContent = format === "html"
|
|
414
|
+
? content
|
|
415
|
+
: content
|
|
416
|
+
.replace(/&/g, "&")
|
|
417
|
+
.replace(/\\/g, "\")
|
|
418
|
+
.replace(/</g, "<")
|
|
419
|
+
.replace(/>/g, ">")
|
|
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 {
|
|
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 {
|
|
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
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
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&A: <Hello> 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: <10 & >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\to\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
|