apple-notes-mcp 2.1.1 → 2.1.2
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 +38 -4
- package/build/index.js +40 -1
- package/build/services/appleNotesManager.js +187 -28
- package/build/services/appleNotesManager.test.js +217 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -103,11 +103,12 @@ On first use, macOS will ask for permission to automate Notes.app. Click "OK" to
|
|
|
103
103
|
| **Delete Notes** | Remove notes (moves to Recently Deleted) |
|
|
104
104
|
| **Move Notes** | Organize notes into folders (supports nested paths) |
|
|
105
105
|
| **Folder Management** | Create, list, and delete folders with full hierarchical path support |
|
|
106
|
-
| **Multi-Account** | Work with iCloud, Gmail, Exchange, or any configured account |
|
|
106
|
+
| **Multi-Account** | Work with iCloud, Gmail, Exchange, or any configured account, including account IDs and default folders |
|
|
107
107
|
| **Batch Operations** | Delete or move multiple notes at once |
|
|
108
108
|
| **Checklist State** | Read checklist done/undone state directly from the Notes database (requires Full Disk Access) |
|
|
109
109
|
| **Export** | Export all notes as JSON or get individual notes as Markdown |
|
|
110
110
|
| **Attachments** | List attachments, save them to disk, or fetch their bytes as base64 |
|
|
111
|
+
| **Notes.app UI State** | Reveal a note in Notes.app or read the current Notes.app selection |
|
|
111
112
|
| **Sync Awareness** | Detect iCloud sync in progress, warn about incomplete results |
|
|
112
113
|
| **Collaboration** | Detect shared notes, warn before modifying |
|
|
113
114
|
| **Diagnostics** | `health-check` plus a richer `doctor` (reachability, automation permission, accounts, Full Disk Access), sync status, and statistics |
|
|
@@ -302,6 +303,19 @@ Retrieves a note using its unique CoreData identifier.
|
|
|
302
303
|
|
|
303
304
|
---
|
|
304
305
|
|
|
306
|
+
#### `show-note`
|
|
307
|
+
|
|
308
|
+
Reveals a note in Notes.app using its unique CoreData identifier.
|
|
309
|
+
|
|
310
|
+
| Parameter | Type | Required | Description |
|
|
311
|
+
|-----------|------|----------|-------------|
|
|
312
|
+
| `id` | string | Yes | The CoreData URL identifier (e.g., `x-coredata://...`) |
|
|
313
|
+
| `separately` | boolean | No | Open in a separate note window when supported by Notes.app |
|
|
314
|
+
|
|
315
|
+
**Returns:** Confirmation that Notes.app accepted the show command.
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
305
319
|
#### `update-note`
|
|
306
320
|
|
|
307
321
|
Updates an existing note's content and/or title.
|
|
@@ -459,6 +473,16 @@ Lists all notes, optionally filtered by folder, date, and limit.
|
|
|
459
473
|
|
|
460
474
|
---
|
|
461
475
|
|
|
476
|
+
#### `get-selected-notes`
|
|
477
|
+
|
|
478
|
+
Reads the currently selected note(s) from the Notes.app UI.
|
|
479
|
+
|
|
480
|
+
**Parameters:** None
|
|
481
|
+
|
|
482
|
+
**Returns:** Selected note metadata, including IDs for follow-up operations. Returns an empty list when Notes.app has no selected note.
|
|
483
|
+
|
|
484
|
+
---
|
|
485
|
+
|
|
462
486
|
### Folder Operations
|
|
463
487
|
|
|
464
488
|
#### `list-folders`
|
|
@@ -474,7 +498,7 @@ Lists all folders in an account with full hierarchical paths.
|
|
|
474
498
|
{}
|
|
475
499
|
```
|
|
476
500
|
|
|
477
|
-
**Returns:** List of
|
|
501
|
+
**Returns:** List of folders with IDs, paths, account names, and shared state. Nested folders are shown as full paths (e.g., `Work/Clients/Omnia`). Duplicate folder names are disambiguated by their full path. Literal slashes in folder names are escaped as `\/` (e.g., `Spain\/Portugal 2023`).
|
|
478
502
|
|
|
479
503
|
---
|
|
480
504
|
|
|
@@ -533,7 +557,17 @@ Lists all configured Notes accounts.
|
|
|
533
557
|
{}
|
|
534
558
|
```
|
|
535
559
|
|
|
536
|
-
**Returns:** List of
|
|
560
|
+
**Returns:** List of accounts with names, IDs, upgraded state, and default folder metadata.
|
|
561
|
+
|
|
562
|
+
---
|
|
563
|
+
|
|
564
|
+
#### `get-default-location`
|
|
565
|
+
|
|
566
|
+
Returns the default account and folder Notes.app uses for newly created notes.
|
|
567
|
+
|
|
568
|
+
**Parameters:** None
|
|
569
|
+
|
|
570
|
+
**Returns:** Default account and folder metadata, including IDs and shared state.
|
|
537
571
|
|
|
538
572
|
---
|
|
539
573
|
|
|
@@ -631,7 +665,7 @@ Lists attachments in a note.
|
|
|
631
665
|
| `title` | string | No | Note title |
|
|
632
666
|
| `account` | string | No | Account containing the note |
|
|
633
667
|
|
|
634
|
-
**Returns:** List of attachments with names and
|
|
668
|
+
**Returns:** List of attachments with IDs, names, content identifiers, URLs when available, created/modified dates, and shared state.
|
|
635
669
|
|
|
636
670
|
---
|
|
637
671
|
|
package/build/index.js
CHANGED
|
@@ -260,6 +260,20 @@ server.tool("get-note-details", "Use when: you have a note title (not an id) and
|
|
|
260
260
|
};
|
|
261
261
|
return successResponse(JSON.stringify(metadata, null, 2), metadata);
|
|
262
262
|
}, "Error retrieving note details"));
|
|
263
|
+
// --- show-note ---
|
|
264
|
+
server.tool("show-note", "Use when: the user wants to reveal a known note in Notes.app by id.\nReturns: confirmation that Notes.app accepted the show command.\nDo not use when: you only need note content (get-note-content) or metadata (get-note-by-id).\nNote: this opens or focuses the Notes UI.", {
|
|
265
|
+
id: z.string().min(1, "Note ID is required"),
|
|
266
|
+
separately: z
|
|
267
|
+
.boolean()
|
|
268
|
+
.optional()
|
|
269
|
+
.describe("Open in a separate note window when supported by Notes.app"),
|
|
270
|
+
}, withErrorHandling(({ id, separately = false }) => {
|
|
271
|
+
const success = notesManager.showNoteById(id, separately);
|
|
272
|
+
if (!success) {
|
|
273
|
+
return errorResponse(`Failed to show note with ID "${id}"`);
|
|
274
|
+
}
|
|
275
|
+
return successResponse(`Shown note with ID "${id}" in Notes.app`, { id, separately });
|
|
276
|
+
}, "Error showing note"));
|
|
263
277
|
// --- update-note ---
|
|
264
278
|
server.tool("update-note", "Use when: changing the title and/or replacing the body of an existing note, by id (preferred) or title.\nReturns: confirmation; warns when the note is shared.\nDo not use when: creating a new note (create-note).\nSafety: newContent REPLACES the entire body — it does not append. Read the note first if you need to preserve existing text. Edits to shared notes are immediately visible to all collaborators.", {
|
|
265
279
|
id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
|
|
@@ -440,6 +454,18 @@ server.tool("list-notes", "Use when: enumerating notes in an account or folder;
|
|
|
440
454
|
const noteList = notes.map((t) => ` - ${t}`).join("\n");
|
|
441
455
|
return successResponse(`Found ${notes.length} notes${location}${acct}${dateInfo}${limitInfo}:\n${noteList}${syncNote}`, { notes, count: notes.length });
|
|
442
456
|
}, "Error listing notes"));
|
|
457
|
+
// --- get-selected-notes ---
|
|
458
|
+
server.tool("get-selected-notes", "Use when: the user asks what note(s) are currently selected in Notes.app.\nReturns: selected note metadata with ids for follow-up operations.\nDo not use when: searching all notes (search-notes) or listing a folder (list-notes).\nNote: reads Notes.app UI selection; it may be empty if Notes is closed or no note is selected.", {}, withErrorHandling(() => {
|
|
459
|
+
const notes = notesManager.getSelectedNotes();
|
|
460
|
+
if (notes.length === 0) {
|
|
461
|
+
return successResponse("No notes are currently selected in Notes.app", {
|
|
462
|
+
notes: [],
|
|
463
|
+
count: 0,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
const noteList = notes.map((n) => ` - ${n.title} [id: ${n.id}]`).join("\n");
|
|
467
|
+
return successResponse(`Selected note(s):\n${noteList}`, { notes, count: notes.length });
|
|
468
|
+
}, "Error getting selected notes"));
|
|
443
469
|
// =============================================================================
|
|
444
470
|
// Folder Tools
|
|
445
471
|
// =============================================================================
|
|
@@ -499,12 +525,25 @@ server.tool("list-accounts", "Use when: discovering which Notes accounts exist (
|
|
|
499
525
|
if (accounts.length === 0) {
|
|
500
526
|
return successResponse("No Notes accounts found", { accounts: [], count: 0 });
|
|
501
527
|
}
|
|
502
|
-
const accountList = accounts
|
|
528
|
+
const accountList = accounts
|
|
529
|
+
.map((a) => {
|
|
530
|
+
const defaultFolder = a.defaultFolder ? ` (default folder: ${a.defaultFolder})` : "";
|
|
531
|
+
const upgraded = a.upgraded === undefined ? "" : `, upgraded: ${a.upgraded ? "yes" : "no"}`;
|
|
532
|
+
return ` - ${a.name}${defaultFolder}${upgraded}`;
|
|
533
|
+
})
|
|
534
|
+
.join("\n");
|
|
503
535
|
return successResponse(`Found ${accounts.length} accounts:\n${accountList}`, {
|
|
504
536
|
accounts,
|
|
505
537
|
count: accounts.length,
|
|
506
538
|
});
|
|
507
539
|
}, "Error listing accounts"));
|
|
540
|
+
// --- get-default-location ---
|
|
541
|
+
server.tool("get-default-location", "Use when: discovering where Notes.app will create new notes by default.\nReturns: default account and default folder metadata.\nDo not use when: you already have an explicit account/folder target.", {}, withErrorHandling(() => {
|
|
542
|
+
const location = notesManager.getDefaultLocation();
|
|
543
|
+
const message = `Default account: ${location.account.name} [id: ${location.account.id}]\n` +
|
|
544
|
+
`Default folder: ${location.folder.name} [id: ${location.folder.id}]`;
|
|
545
|
+
return successResponse(message, { ...location });
|
|
546
|
+
}, "Error getting default Notes location"));
|
|
508
547
|
// =============================================================================
|
|
509
548
|
// Collaboration Tools
|
|
510
549
|
// =============================================================================
|
|
@@ -1207,25 +1207,23 @@ export class AppleNotesManager {
|
|
|
1207
1207
|
*/
|
|
1208
1208
|
listFolders(account) {
|
|
1209
1209
|
const targetAccount = this.resolveAccount(account);
|
|
1210
|
-
// Get each folder's ID, name,
|
|
1211
|
-
// Each line: "id\tname\tparentId" for subfolders, "id\tname" for top-level.
|
|
1210
|
+
// Get each folder's ID, name, parent ID, and shared state in a single AppleScript call.
|
|
1212
1211
|
// Using IDs enables correct tree building even with duplicate folder names.
|
|
1213
1212
|
const listCommand = `
|
|
1214
|
-
set folderList to
|
|
1213
|
+
set folderList to {}
|
|
1215
1214
|
set allFolders to every folder
|
|
1216
1215
|
repeat with f in allFolders
|
|
1217
1216
|
set fRef to contents of f
|
|
1218
1217
|
set cRef to container of fRef
|
|
1219
|
-
|
|
1220
|
-
set folderList to folderList & linefeed
|
|
1221
|
-
end if
|
|
1218
|
+
set parentId to ""
|
|
1222
1219
|
if class of cRef is folder then
|
|
1223
|
-
set
|
|
1224
|
-
else
|
|
1225
|
-
set folderList to folderList & (id of fRef) & tab & (name of fRef)
|
|
1220
|
+
set parentId to id of cRef
|
|
1226
1221
|
end if
|
|
1222
|
+
set sharedFlag to shared of fRef as text
|
|
1223
|
+
set end of folderList to (id of fRef) & ${AS_FIELD_SEP} & (name of fRef) & ${AS_FIELD_SEP} & parentId & ${AS_FIELD_SEP} & sharedFlag
|
|
1227
1224
|
end repeat
|
|
1228
|
-
|
|
1225
|
+
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
1226
|
+
return folderList as text
|
|
1229
1227
|
`;
|
|
1230
1228
|
const script = buildAccountScopedScript({ account: targetAccount }, listCommand);
|
|
1231
1229
|
const result = executeAppleScript(script);
|
|
@@ -1235,13 +1233,14 @@ export class AppleNotesManager {
|
|
|
1235
1233
|
if (!result.output.trim()) {
|
|
1236
1234
|
return [];
|
|
1237
1235
|
}
|
|
1238
|
-
|
|
1239
|
-
const entries = result.output.split(
|
|
1240
|
-
const parts = line.split("\t");
|
|
1236
|
+
const recordSeparator = result.output.includes(RECORD_SEP) ? RECORD_SEP : "\n";
|
|
1237
|
+
const entries = result.output.split(recordSeparator).map((line) => {
|
|
1238
|
+
const parts = line.includes(FIELD_SEP) ? line.split(FIELD_SEP) : line.split("\t");
|
|
1241
1239
|
return {
|
|
1242
1240
|
id: (parts[0] || "").trim(),
|
|
1243
1241
|
name: (parts[1] || "").trim(),
|
|
1244
1242
|
parentId: (parts[2] || "").trim(),
|
|
1243
|
+
shared: (parts[3] || "").trim().toLowerCase() === "true",
|
|
1245
1244
|
};
|
|
1246
1245
|
});
|
|
1247
1246
|
// Build an ID-to-entry map for efficient parent lookups
|
|
@@ -1264,6 +1263,7 @@ export class AppleNotesManager {
|
|
|
1264
1263
|
id: entry.id,
|
|
1265
1264
|
name: buildPath(entry),
|
|
1266
1265
|
account: targetAccount,
|
|
1266
|
+
shared: entry.shared,
|
|
1267
1267
|
}));
|
|
1268
1268
|
}
|
|
1269
1269
|
/**
|
|
@@ -1455,10 +1455,22 @@ export class AppleNotesManager {
|
|
|
1455
1455
|
* @returns Array of Account objects
|
|
1456
1456
|
*/
|
|
1457
1457
|
listAccounts() {
|
|
1458
|
-
// Coerce
|
|
1459
|
-
//
|
|
1458
|
+
// Coerce account records to text with control-char delimiters so names
|
|
1459
|
+
// containing commas or tabs can't split into phantom accounts (#18).
|
|
1460
1460
|
const listCommand = `
|
|
1461
|
-
set resultList to
|
|
1461
|
+
set resultList to {}
|
|
1462
|
+
repeat with a in accounts
|
|
1463
|
+
set aRef to contents of a
|
|
1464
|
+
set defaultFolderId to ""
|
|
1465
|
+
set defaultFolderName to ""
|
|
1466
|
+
try
|
|
1467
|
+
set fRef to default folder of aRef
|
|
1468
|
+
set defaultFolderId to id of fRef
|
|
1469
|
+
set defaultFolderName to name of fRef
|
|
1470
|
+
end try
|
|
1471
|
+
set upgradedFlag to upgraded of aRef as text
|
|
1472
|
+
set end of resultList to (id of aRef) & ${AS_FIELD_SEP} & (name of aRef) & ${AS_FIELD_SEP} & upgradedFlag & ${AS_FIELD_SEP} & defaultFolderId & ${AS_FIELD_SEP} & defaultFolderName
|
|
1473
|
+
end repeat
|
|
1462
1474
|
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
1463
1475
|
return resultList as text
|
|
1464
1476
|
`;
|
|
@@ -1467,11 +1479,130 @@ export class AppleNotesManager {
|
|
|
1467
1479
|
if (!result.success) {
|
|
1468
1480
|
throw new Error(`Failed to list accounts: ${result.error ?? "unknown error"}`);
|
|
1469
1481
|
}
|
|
1470
|
-
|
|
1482
|
+
return result.output
|
|
1471
1483
|
.split(RECORD_SEP)
|
|
1472
1484
|
.map((s) => s.trim())
|
|
1473
|
-
.filter((s) => s.length > 0)
|
|
1474
|
-
|
|
1485
|
+
.filter((s) => s.length > 0)
|
|
1486
|
+
.map((item) => {
|
|
1487
|
+
const parts = item.split(FIELD_SEP);
|
|
1488
|
+
if (parts.length === 1) {
|
|
1489
|
+
return { name: parts[0].trim() };
|
|
1490
|
+
}
|
|
1491
|
+
return {
|
|
1492
|
+
id: (parts[0] || "").trim(),
|
|
1493
|
+
name: (parts[1] || "").trim(),
|
|
1494
|
+
upgraded: (parts[2] || "").trim().toLowerCase() === "true",
|
|
1495
|
+
defaultFolderId: (parts[3] || "").trim() || undefined,
|
|
1496
|
+
defaultFolder: (parts[4] || "").trim() || undefined,
|
|
1497
|
+
};
|
|
1498
|
+
});
|
|
1499
|
+
}
|
|
1500
|
+
/**
|
|
1501
|
+
* Gets the default account and folder used by Notes.app for new notes.
|
|
1502
|
+
*
|
|
1503
|
+
* @returns Default account and folder metadata
|
|
1504
|
+
*/
|
|
1505
|
+
getDefaultLocation() {
|
|
1506
|
+
const command = `
|
|
1507
|
+
set aRef to default account
|
|
1508
|
+
set fRef to default folder of aRef
|
|
1509
|
+
return (id of aRef) & ${AS_FIELD_SEP} & (name of aRef) & ${AS_FIELD_SEP} & (upgraded of aRef as text) & ${AS_FIELD_SEP} & (id of fRef) & ${AS_FIELD_SEP} & (name of fRef) & ${AS_FIELD_SEP} & (shared of fRef as text)
|
|
1510
|
+
`;
|
|
1511
|
+
const result = executeAppleScript(buildAppLevelScript(command));
|
|
1512
|
+
if (!result.success) {
|
|
1513
|
+
throw new Error(`Failed to get default Notes location: ${result.error ?? "unknown error"}`);
|
|
1514
|
+
}
|
|
1515
|
+
const parts = result.output.split(FIELD_SEP);
|
|
1516
|
+
if (parts.length < 6) {
|
|
1517
|
+
throw new Error(`Failed to parse default Notes location: ${result.output}`);
|
|
1518
|
+
}
|
|
1519
|
+
const accountName = (parts[1] || "").trim();
|
|
1520
|
+
return {
|
|
1521
|
+
account: {
|
|
1522
|
+
id: (parts[0] || "").trim(),
|
|
1523
|
+
name: accountName,
|
|
1524
|
+
upgraded: (parts[2] || "").trim().toLowerCase() === "true",
|
|
1525
|
+
defaultFolderId: (parts[3] || "").trim(),
|
|
1526
|
+
defaultFolder: (parts[4] || "").trim(),
|
|
1527
|
+
},
|
|
1528
|
+
folder: {
|
|
1529
|
+
id: (parts[3] || "").trim(),
|
|
1530
|
+
name: (parts[4] || "").trim(),
|
|
1531
|
+
account: accountName,
|
|
1532
|
+
shared: (parts[5] || "").trim().toLowerCase() === "true",
|
|
1533
|
+
},
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
/**
|
|
1537
|
+
* Lists the currently selected Notes in the Notes.app UI.
|
|
1538
|
+
*
|
|
1539
|
+
* @returns Array of selected notes, or an empty array when nothing is selected
|
|
1540
|
+
*/
|
|
1541
|
+
getSelectedNotes() {
|
|
1542
|
+
const command = `
|
|
1543
|
+
set selectedNotes to selection
|
|
1544
|
+
set noteList to {}
|
|
1545
|
+
repeat with n in selectedNotes
|
|
1546
|
+
set nRef to contents of n
|
|
1547
|
+
set createdDate to creation date of nRef
|
|
1548
|
+
set modifiedDate to modification date of nRef
|
|
1549
|
+
set createdParts to ${asDatePartsExpr("createdDate")}
|
|
1550
|
+
set modifiedParts to ${asDatePartsExpr("modifiedDate")}
|
|
1551
|
+
set folderName to ""
|
|
1552
|
+
set accountName to ""
|
|
1553
|
+
try
|
|
1554
|
+
set fRef to container of nRef
|
|
1555
|
+
set folderName to name of fRef
|
|
1556
|
+
set aRef to container of fRef
|
|
1557
|
+
set accountName to name of aRef
|
|
1558
|
+
end try
|
|
1559
|
+
set end of noteList to (id of nRef) & ${AS_FIELD_SEP} & (name of nRef) & ${AS_FIELD_SEP} & createdParts & ${AS_FIELD_SEP} & modifiedParts & ${AS_FIELD_SEP} & (shared of nRef as text) & ${AS_FIELD_SEP} & (password protected of nRef as text) & ${AS_FIELD_SEP} & folderName & ${AS_FIELD_SEP} & accountName
|
|
1560
|
+
end repeat
|
|
1561
|
+
set AppleScript's text item delimiters to ${AS_RECORD_SEP}
|
|
1562
|
+
return noteList as text
|
|
1563
|
+
`;
|
|
1564
|
+
const result = executeAppleScript(buildAppLevelScript(command));
|
|
1565
|
+
if (!result.success) {
|
|
1566
|
+
throw new Error(`Failed to get selected notes: ${result.error ?? "unknown error"}`);
|
|
1567
|
+
}
|
|
1568
|
+
if (!result.output.trim()) {
|
|
1569
|
+
return [];
|
|
1570
|
+
}
|
|
1571
|
+
return result.output
|
|
1572
|
+
.split(RECORD_SEP)
|
|
1573
|
+
.filter((s) => s.trim())
|
|
1574
|
+
.map((item) => {
|
|
1575
|
+
const parts = item.split(FIELD_SEP);
|
|
1576
|
+
return {
|
|
1577
|
+
id: (parts[0] || "").trim(),
|
|
1578
|
+
title: (parts[1] || "").trim(),
|
|
1579
|
+
content: "",
|
|
1580
|
+
tags: [],
|
|
1581
|
+
created: parseAppleScriptDate((parts[2] || "").trim()),
|
|
1582
|
+
modified: parseAppleScriptDate((parts[3] || "").trim()),
|
|
1583
|
+
shared: (parts[4] || "").trim().toLowerCase() === "true",
|
|
1584
|
+
passwordProtected: (parts[5] || "").trim().toLowerCase() === "true",
|
|
1585
|
+
folder: (parts[6] || "").trim() || undefined,
|
|
1586
|
+
account: (parts[7] || "").trim() || undefined,
|
|
1587
|
+
};
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
/**
|
|
1591
|
+
* Reveals a note in the Notes.app UI by ID.
|
|
1592
|
+
*
|
|
1593
|
+
* @param id - CoreData URL identifier for the note
|
|
1594
|
+
* @param separately - Whether to open the note in a separate window
|
|
1595
|
+
* @returns true if Notes.app accepted the show command
|
|
1596
|
+
*/
|
|
1597
|
+
showNoteById(id, separately = false) {
|
|
1598
|
+
const safeId = sanitizeId(id);
|
|
1599
|
+
const separatelyClause = separately ? " separately true" : "";
|
|
1600
|
+
const result = executeAppleScript(buildAppLevelScript(`show note id "${safeId}"${separatelyClause}`));
|
|
1601
|
+
if (!result.success) {
|
|
1602
|
+
console.error(`Failed to show note with ID "${id}":`, result.error);
|
|
1603
|
+
return false;
|
|
1604
|
+
}
|
|
1605
|
+
return true;
|
|
1475
1606
|
}
|
|
1476
1607
|
// ===========================================================================
|
|
1477
1608
|
// Health Check
|
|
@@ -1738,8 +1869,17 @@ export class AppleNotesManager {
|
|
|
1738
1869
|
repeat with a in attachments of theNote
|
|
1739
1870
|
set attachId to id of a
|
|
1740
1871
|
set attachName to name of a
|
|
1741
|
-
set
|
|
1742
|
-
set
|
|
1872
|
+
set attachContentId to content identifier of a
|
|
1873
|
+
set attachUrl to ""
|
|
1874
|
+
try
|
|
1875
|
+
set attachUrl to URL of a as text
|
|
1876
|
+
end try
|
|
1877
|
+
set createdDate to creation date of a
|
|
1878
|
+
set modifiedDate to modification date of a
|
|
1879
|
+
set createdParts to ${asDatePartsExpr("createdDate")}
|
|
1880
|
+
set modifiedParts to ${asDatePartsExpr("modifiedDate")}
|
|
1881
|
+
set sharedFlag to shared of a as text
|
|
1882
|
+
set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachContentId & ${AS_FIELD_SEP} & attachUrl & ${AS_FIELD_SEP} & createdParts & ${AS_FIELD_SEP} & modifiedParts & ${AS_FIELD_SEP} & sharedFlag
|
|
1743
1883
|
end repeat
|
|
1744
1884
|
set output to ""
|
|
1745
1885
|
repeat with item in attachmentList
|
|
@@ -1765,6 +1905,11 @@ export class AppleNotesManager {
|
|
|
1765
1905
|
id: parts[0].trim(),
|
|
1766
1906
|
name: parts[1].trim(),
|
|
1767
1907
|
contentType: parts[2].trim(),
|
|
1908
|
+
contentId: parts[2].trim() || undefined,
|
|
1909
|
+
url: parts[3]?.trim() || undefined,
|
|
1910
|
+
created: parts[4] ? parseAppleScriptDate(parts[4].trim()) : undefined,
|
|
1911
|
+
modified: parts[5] ? parseAppleScriptDate(parts[5].trim()) : undefined,
|
|
1912
|
+
shared: parts[6] ? parts[6].trim().toLowerCase() === "true" : undefined,
|
|
1768
1913
|
});
|
|
1769
1914
|
}
|
|
1770
1915
|
}
|
|
@@ -1784,13 +1929,22 @@ export class AppleNotesManager {
|
|
|
1784
1929
|
tell application "Notes"
|
|
1785
1930
|
tell account "${targetAccount}"
|
|
1786
1931
|
set theNote to note "${safeTitle}"
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1932
|
+
set attachmentList to {}
|
|
1933
|
+
repeat with a in attachments of theNote
|
|
1934
|
+
set attachId to id of a
|
|
1935
|
+
set attachName to name of a
|
|
1936
|
+
set attachContentId to content identifier of a
|
|
1937
|
+
set attachUrl to ""
|
|
1938
|
+
try
|
|
1939
|
+
set attachUrl to URL of a as text
|
|
1940
|
+
end try
|
|
1941
|
+
set createdDate to creation date of a
|
|
1942
|
+
set modifiedDate to modification date of a
|
|
1943
|
+
set createdParts to ${asDatePartsExpr("createdDate")}
|
|
1944
|
+
set modifiedParts to ${asDatePartsExpr("modifiedDate")}
|
|
1945
|
+
set sharedFlag to shared of a as text
|
|
1946
|
+
set end of attachmentList to attachId & ${AS_FIELD_SEP} & attachName & ${AS_FIELD_SEP} & attachContentId & ${AS_FIELD_SEP} & attachUrl & ${AS_FIELD_SEP} & createdParts & ${AS_FIELD_SEP} & modifiedParts & ${AS_FIELD_SEP} & sharedFlag
|
|
1947
|
+
end repeat
|
|
1794
1948
|
set output to ""
|
|
1795
1949
|
repeat with item in attachmentList
|
|
1796
1950
|
set output to output & item & ${AS_RECORD_SEP}
|
|
@@ -1816,6 +1970,11 @@ export class AppleNotesManager {
|
|
|
1816
1970
|
id: parts[0].trim(),
|
|
1817
1971
|
name: parts[1].trim(),
|
|
1818
1972
|
contentType: parts[2].trim(),
|
|
1973
|
+
contentId: parts[2].trim() || undefined,
|
|
1974
|
+
url: parts[3]?.trim() || undefined,
|
|
1975
|
+
created: parts[4] ? parseAppleScriptDate(parts[4].trim()) : undefined,
|
|
1976
|
+
modified: parts[5] ? parseAppleScriptDate(parts[5].trim()) : undefined,
|
|
1977
|
+
shared: parts[6] ? parts[6].trim().toLowerCase() === "true" : undefined,
|
|
1819
1978
|
});
|
|
1820
1979
|
}
|
|
1821
1980
|
}
|
|
@@ -1145,7 +1145,11 @@ describe("AppleNotesManager", () => {
|
|
|
1145
1145
|
it("returns array of Folder objects with paths", () => {
|
|
1146
1146
|
mockExecuteAppleScript.mockReturnValue({
|
|
1147
1147
|
success: true,
|
|
1148
|
-
output:
|
|
1148
|
+
output: [
|
|
1149
|
+
["id1", "Notes", "", "false"].join(F),
|
|
1150
|
+
["id2", "Archive", "", "false"].join(F),
|
|
1151
|
+
["id3", "Work", "", "true"].join(F),
|
|
1152
|
+
].join(R),
|
|
1149
1153
|
});
|
|
1150
1154
|
const folders = manager.listFolders();
|
|
1151
1155
|
expect(folders).toHaveLength(3);
|
|
@@ -1153,11 +1157,17 @@ describe("AppleNotesManager", () => {
|
|
|
1153
1157
|
expect(folders[1].name).toBe("Archive");
|
|
1154
1158
|
expect(folders[2].name).toBe("Work");
|
|
1155
1159
|
expect(folders[0].id).toBe("id1");
|
|
1160
|
+
expect(folders[2].shared).toBe(true);
|
|
1156
1161
|
});
|
|
1157
1162
|
it("includes parent folder in path", () => {
|
|
1158
1163
|
mockExecuteAppleScript.mockReturnValue({
|
|
1159
1164
|
success: true,
|
|
1160
|
-
output:
|
|
1165
|
+
output: [
|
|
1166
|
+
["id1", "Dev", "", "false"].join(F),
|
|
1167
|
+
["id2", "Accessibility", "id1", "false"].join(F),
|
|
1168
|
+
["id3", "Work", "", "false"].join(F),
|
|
1169
|
+
["id4", "Clients", "id3", "false"].join(F),
|
|
1170
|
+
].join(R),
|
|
1161
1171
|
});
|
|
1162
1172
|
const folders = manager.listFolders();
|
|
1163
1173
|
expect(folders).toHaveLength(4);
|
|
@@ -1169,7 +1179,13 @@ describe("AppleNotesManager", () => {
|
|
|
1169
1179
|
it("disambiguates duplicate folder names using IDs", () => {
|
|
1170
1180
|
mockExecuteAppleScript.mockReturnValue({
|
|
1171
1181
|
success: true,
|
|
1172
|
-
output:
|
|
1182
|
+
output: [
|
|
1183
|
+
["id1", "Finance", "", "false"].join(F),
|
|
1184
|
+
["id2", "Archive", "id1", "false"].join(F),
|
|
1185
|
+
["id3", "Travel", "", "false"].join(F),
|
|
1186
|
+
["id4", "Trips", "id3", "false"].join(F),
|
|
1187
|
+
["id5", "Archive", "id4", "false"].join(F),
|
|
1188
|
+
].join(R),
|
|
1173
1189
|
});
|
|
1174
1190
|
const folders = manager.listFolders();
|
|
1175
1191
|
expect(folders).toHaveLength(5);
|
|
@@ -1179,17 +1195,31 @@ describe("AppleNotesManager", () => {
|
|
|
1179
1195
|
it("escapes slashes in folder names", () => {
|
|
1180
1196
|
mockExecuteAppleScript.mockReturnValue({
|
|
1181
1197
|
success: true,
|
|
1182
|
-
output:
|
|
1198
|
+
output: [
|
|
1199
|
+
["id1", "Travel", "", "false"].join(F),
|
|
1200
|
+
["id2", "Spain/Portugal 2023", "id1", "false"].join(F),
|
|
1201
|
+
].join(R),
|
|
1183
1202
|
});
|
|
1184
1203
|
const folders = manager.listFolders();
|
|
1185
1204
|
expect(folders).toHaveLength(2);
|
|
1186
1205
|
expect(folders[0].name).toBe("Travel");
|
|
1187
1206
|
expect(folders[1].name).toBe("Travel/Spain\\/Portugal 2023");
|
|
1188
1207
|
});
|
|
1208
|
+
it("parses legacy tab/newline output (backward compat)", () => {
|
|
1209
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1210
|
+
success: true,
|
|
1211
|
+
output: "id1\tNotes\nid2\tArchive\tid1",
|
|
1212
|
+
});
|
|
1213
|
+
const folders = manager.listFolders();
|
|
1214
|
+
expect(folders).toHaveLength(2);
|
|
1215
|
+
expect(folders[0].name).toBe("Notes");
|
|
1216
|
+
expect(folders[1].name).toBe("Notes/Archive");
|
|
1217
|
+
expect(folders[1].shared).toBe(false);
|
|
1218
|
+
});
|
|
1189
1219
|
it("includes account in Folder objects", () => {
|
|
1190
1220
|
mockExecuteAppleScript.mockReturnValue({
|
|
1191
1221
|
success: true,
|
|
1192
|
-
output: "id1
|
|
1222
|
+
output: ["id1", "Notes", "", "false"].join(F),
|
|
1193
1223
|
});
|
|
1194
1224
|
const folders = manager.listFolders("Gmail");
|
|
1195
1225
|
expect(folders[0].account).toBe("Gmail");
|
|
@@ -1420,13 +1450,41 @@ describe("AppleNotesManager", () => {
|
|
|
1420
1450
|
it("returns array of Account objects", () => {
|
|
1421
1451
|
mockExecuteAppleScript.mockReturnValue({
|
|
1422
1452
|
success: true,
|
|
1423
|
-
output: [
|
|
1453
|
+
output: [
|
|
1454
|
+
["acc1", "iCloud", "true", "folder1", "Notes"].join(F),
|
|
1455
|
+
["acc2", "Gmail", "false", "folder2", "Inbox"].join(F),
|
|
1456
|
+
["acc3", "Exchange", "false", "", ""].join(F),
|
|
1457
|
+
].join(R),
|
|
1424
1458
|
});
|
|
1425
1459
|
const accounts = manager.listAccounts();
|
|
1426
1460
|
expect(accounts).toHaveLength(3);
|
|
1427
1461
|
expect(accounts[0].name).toBe("iCloud");
|
|
1428
1462
|
expect(accounts[1].name).toBe("Gmail");
|
|
1429
1463
|
expect(accounts[2].name).toBe("Exchange");
|
|
1464
|
+
expect(accounts[0].id).toBe("acc1");
|
|
1465
|
+
expect(accounts[0].upgraded).toBe(true);
|
|
1466
|
+
expect(accounts[0].defaultFolder).toBe("Notes");
|
|
1467
|
+
});
|
|
1468
|
+
it("parses legacy plain-name output (backward compat)", () => {
|
|
1469
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1470
|
+
success: true,
|
|
1471
|
+
output: ["iCloud", "Gmail"].join(R),
|
|
1472
|
+
});
|
|
1473
|
+
const accounts = manager.listAccounts();
|
|
1474
|
+
expect(accounts).toHaveLength(2);
|
|
1475
|
+
expect(accounts[0]).toEqual({ name: "iCloud" });
|
|
1476
|
+
expect(accounts[1]).toEqual({ name: "Gmail" });
|
|
1477
|
+
});
|
|
1478
|
+
it("handles account records with empty fields", () => {
|
|
1479
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1480
|
+
success: true,
|
|
1481
|
+
output: ["", "", "", "", ""].join(F),
|
|
1482
|
+
});
|
|
1483
|
+
const accounts = manager.listAccounts();
|
|
1484
|
+
expect(accounts).toHaveLength(1);
|
|
1485
|
+
expect(accounts[0].name).toBe("");
|
|
1486
|
+
expect(accounts[0].upgraded).toBe(false);
|
|
1487
|
+
expect(accounts[0].defaultFolderId).toBeUndefined();
|
|
1430
1488
|
});
|
|
1431
1489
|
it("throws on failure rather than returning empty (#19)", () => {
|
|
1432
1490
|
mockExecuteAppleScript.mockReturnValue({
|
|
@@ -1437,6 +1495,126 @@ describe("AppleNotesManager", () => {
|
|
|
1437
1495
|
expect(() => manager.listAccounts()).toThrow(/Notes.app not available/);
|
|
1438
1496
|
});
|
|
1439
1497
|
});
|
|
1498
|
+
describe("getDefaultLocation", () => {
|
|
1499
|
+
it("returns default account and folder metadata", () => {
|
|
1500
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1501
|
+
success: true,
|
|
1502
|
+
output: ["acc1", "iCloud", "true", "folder1", "Notes", "false"].join(F),
|
|
1503
|
+
});
|
|
1504
|
+
const location = manager.getDefaultLocation();
|
|
1505
|
+
expect(location.account).toMatchObject({
|
|
1506
|
+
id: "acc1",
|
|
1507
|
+
name: "iCloud",
|
|
1508
|
+
upgraded: true,
|
|
1509
|
+
defaultFolderId: "folder1",
|
|
1510
|
+
defaultFolder: "Notes",
|
|
1511
|
+
});
|
|
1512
|
+
expect(location.folder).toMatchObject({
|
|
1513
|
+
id: "folder1",
|
|
1514
|
+
name: "Notes",
|
|
1515
|
+
account: "iCloud",
|
|
1516
|
+
shared: false,
|
|
1517
|
+
});
|
|
1518
|
+
});
|
|
1519
|
+
it("throws when default location output cannot be parsed", () => {
|
|
1520
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1521
|
+
success: true,
|
|
1522
|
+
output: "bad-output",
|
|
1523
|
+
});
|
|
1524
|
+
expect(() => manager.getDefaultLocation()).toThrow(/parse default Notes location/);
|
|
1525
|
+
});
|
|
1526
|
+
it("throws when AppleScript fails", () => {
|
|
1527
|
+
mockExecuteAppleScript.mockReturnValue({ success: false, output: "", error: "boom" });
|
|
1528
|
+
expect(() => manager.getDefaultLocation()).toThrow(/Failed to get default Notes location/);
|
|
1529
|
+
});
|
|
1530
|
+
it("handles empty fields in default location output", () => {
|
|
1531
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1532
|
+
success: true,
|
|
1533
|
+
output: ["", "", "", "", "", ""].join(F),
|
|
1534
|
+
});
|
|
1535
|
+
const location = manager.getDefaultLocation();
|
|
1536
|
+
expect(location.account.name).toBe("");
|
|
1537
|
+
expect(location.account.upgraded).toBe(false);
|
|
1538
|
+
expect(location.folder.id).toBe("");
|
|
1539
|
+
expect(location.folder.shared).toBe(false);
|
|
1540
|
+
});
|
|
1541
|
+
});
|
|
1542
|
+
describe("getSelectedNotes", () => {
|
|
1543
|
+
it("returns selected note metadata", () => {
|
|
1544
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1545
|
+
success: true,
|
|
1546
|
+
output: [
|
|
1547
|
+
[
|
|
1548
|
+
"x-coredata://ABC/ICNote/p1",
|
|
1549
|
+
"Selected Note",
|
|
1550
|
+
"2026-6-22-14-30-0",
|
|
1551
|
+
"2026-6-22-14-35-0",
|
|
1552
|
+
"false",
|
|
1553
|
+
"false",
|
|
1554
|
+
"Notes",
|
|
1555
|
+
"iCloud",
|
|
1556
|
+
].join(F),
|
|
1557
|
+
].join(R),
|
|
1558
|
+
});
|
|
1559
|
+
const notes = manager.getSelectedNotes();
|
|
1560
|
+
expect(notes).toHaveLength(1);
|
|
1561
|
+
expect(notes[0]).toMatchObject({
|
|
1562
|
+
id: "x-coredata://ABC/ICNote/p1",
|
|
1563
|
+
title: "Selected Note",
|
|
1564
|
+
shared: false,
|
|
1565
|
+
passwordProtected: false,
|
|
1566
|
+
folder: "Notes",
|
|
1567
|
+
account: "iCloud",
|
|
1568
|
+
});
|
|
1569
|
+
expect(notes[0].created.getFullYear()).toBe(2026);
|
|
1570
|
+
});
|
|
1571
|
+
it("returns an empty array when no note is selected", () => {
|
|
1572
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1573
|
+
success: true,
|
|
1574
|
+
output: "",
|
|
1575
|
+
});
|
|
1576
|
+
expect(manager.getSelectedNotes()).toEqual([]);
|
|
1577
|
+
});
|
|
1578
|
+
it("throws when AppleScript fails", () => {
|
|
1579
|
+
mockExecuteAppleScript.mockReturnValue({ success: false, output: "", error: "boom" });
|
|
1580
|
+
expect(() => manager.getSelectedNotes()).toThrow(/Failed to get selected notes/);
|
|
1581
|
+
});
|
|
1582
|
+
it("handles selected notes with empty optional fields", () => {
|
|
1583
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1584
|
+
success: true,
|
|
1585
|
+
output: ["", "", "", "", "", "", "", ""].join(F),
|
|
1586
|
+
});
|
|
1587
|
+
const notes = manager.getSelectedNotes();
|
|
1588
|
+
expect(notes).toHaveLength(1);
|
|
1589
|
+
expect(notes[0].id).toBe("");
|
|
1590
|
+
expect(notes[0].shared).toBe(false);
|
|
1591
|
+
expect(notes[0].passwordProtected).toBe(false);
|
|
1592
|
+
expect(notes[0].folder).toBeUndefined();
|
|
1593
|
+
expect(notes[0].account).toBeUndefined();
|
|
1594
|
+
});
|
|
1595
|
+
});
|
|
1596
|
+
describe("showNoteById", () => {
|
|
1597
|
+
it("shows a note by id", () => {
|
|
1598
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1599
|
+
success: true,
|
|
1600
|
+
output: "",
|
|
1601
|
+
});
|
|
1602
|
+
expect(manager.showNoteById("x-coredata://ABC/ICNote/p1")).toBe(true);
|
|
1603
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('show note id "x-coredata://ABC/ICNote/p1"'));
|
|
1604
|
+
});
|
|
1605
|
+
it("can request a separate window", () => {
|
|
1606
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1607
|
+
success: true,
|
|
1608
|
+
output: "",
|
|
1609
|
+
});
|
|
1610
|
+
manager.showNoteById("x-coredata://ABC/ICNote/p1", true);
|
|
1611
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("separately true"));
|
|
1612
|
+
});
|
|
1613
|
+
it("returns false when Notes.app rejects the show command", () => {
|
|
1614
|
+
mockExecuteAppleScript.mockReturnValue({ success: false, output: "", error: "no such note" });
|
|
1615
|
+
expect(manager.showNoteById("x-coredata://ABC/ICNote/p1")).toBe(false);
|
|
1616
|
+
});
|
|
1617
|
+
});
|
|
1440
1618
|
// ---------------------------------------------------------------------------
|
|
1441
1619
|
// Health Check
|
|
1442
1620
|
// ---------------------------------------------------------------------------
|
|
@@ -1620,16 +1798,45 @@ describe("AppleNotesManager", () => {
|
|
|
1620
1798
|
});
|
|
1621
1799
|
const attachments = manager.listAttachmentsById("x-coredata://ABC/ICNote/p123");
|
|
1622
1800
|
expect(attachments).toHaveLength(2);
|
|
1623
|
-
expect(attachments[0]).
|
|
1801
|
+
expect(attachments[0]).toMatchObject({
|
|
1624
1802
|
id: "x-coredata://ABC/ICAttachment/p1",
|
|
1625
1803
|
name: "photo.jpg",
|
|
1626
1804
|
contentType: "public.jpeg",
|
|
1805
|
+
contentId: "public.jpeg",
|
|
1627
1806
|
});
|
|
1628
|
-
expect(attachments[1]).
|
|
1807
|
+
expect(attachments[1]).toMatchObject({
|
|
1629
1808
|
id: "x-coredata://ABC/ICAttachment/p2",
|
|
1630
1809
|
name: "document.pdf",
|
|
1631
1810
|
contentType: "com.adobe.pdf",
|
|
1811
|
+
contentId: "com.adobe.pdf",
|
|
1812
|
+
});
|
|
1813
|
+
});
|
|
1814
|
+
it("parses richer attachment metadata when present", () => {
|
|
1815
|
+
mockExecuteAppleScript.mockReturnValueOnce({
|
|
1816
|
+
success: true,
|
|
1817
|
+
output: [
|
|
1818
|
+
[
|
|
1819
|
+
"x-coredata://ABC/ICAttachment/p1",
|
|
1820
|
+
"site.webloc",
|
|
1821
|
+
"cid:123",
|
|
1822
|
+
"https://example.com",
|
|
1823
|
+
"2026-6-22-10-0-0",
|
|
1824
|
+
"2026-6-22-11-0-0",
|
|
1825
|
+
"true",
|
|
1826
|
+
].join(F),
|
|
1827
|
+
].join(R),
|
|
1828
|
+
});
|
|
1829
|
+
const attachments = manager.listAttachmentsById("x-coredata://ABC/ICNote/p123");
|
|
1830
|
+
expect(attachments[0]).toMatchObject({
|
|
1831
|
+
id: "x-coredata://ABC/ICAttachment/p1",
|
|
1832
|
+
name: "site.webloc",
|
|
1833
|
+
contentType: "cid:123",
|
|
1834
|
+
contentId: "cid:123",
|
|
1835
|
+
url: "https://example.com",
|
|
1836
|
+
shared: true,
|
|
1632
1837
|
});
|
|
1838
|
+
expect(attachments[0].created?.getFullYear()).toBe(2026);
|
|
1839
|
+
expect(attachments[0].modified?.getHours()).toBe(11);
|
|
1633
1840
|
});
|
|
1634
1841
|
it("returns empty array when note has no attachments", () => {
|
|
1635
1842
|
mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "" });
|
|
@@ -1659,10 +1866,11 @@ describe("AppleNotesManager", () => {
|
|
|
1659
1866
|
});
|
|
1660
1867
|
const attachments = manager.listAttachments("My Note");
|
|
1661
1868
|
expect(attachments).toHaveLength(1);
|
|
1662
|
-
expect(attachments[0]).
|
|
1869
|
+
expect(attachments[0]).toMatchObject({
|
|
1663
1870
|
id: "attach-id",
|
|
1664
1871
|
name: "image.png",
|
|
1665
1872
|
contentType: "public.png",
|
|
1873
|
+
contentId: "public.png",
|
|
1666
1874
|
});
|
|
1667
1875
|
});
|
|
1668
1876
|
it("uses specified account", () => {
|
package/package.json
CHANGED