apple-notes-mcp 2.3.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -1
- package/build/index.js +155 -1
- package/build/services/__fixtures__/notesNormalizedHtml.js +32 -0
- package/build/services/appleNotesManager.js +132 -0
- package/build/services/appleNotesManager.test.js +109 -0
- package/build/services/notesHtmlMarkdown.test.js +55 -0
- package/build/utils/noteMetadata.js +135 -0
- package/build/utils/noteMetadata.test.js +106 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -135,7 +135,7 @@ A few Notes UI features are not exposed to AppleScript and therefore cannot be
|
|
|
135
135
|
supported. See **[docs/APPLESCRIPT-LIMITATIONS.md](docs/APPLESCRIPT-LIMITATIONS.md)**
|
|
136
136
|
for the investigation and verification behind each:
|
|
137
137
|
|
|
138
|
-
- **Pinned notes** — Notes has no scriptable `pinned` property
|
|
138
|
+
- **Pinned notes** — Notes has no scriptable `pinned` property via AppleScript. Pin state can now be **read** with the BETA `get-note-metadata` tool (from the NoteStore database), but it still cannot be **set** programmatically.
|
|
139
139
|
- **Note-to-note links** — there is no `applenotes://` deep link or link property; the only stable handle is the `x-coredata://` note id.
|
|
140
140
|
|
|
141
141
|
---
|
|
@@ -267,6 +267,22 @@ see [docs/APPLESCRIPT-LIMITATIONS.md](../docs/APPLESCRIPT-LIMITATIONS.md#tags--h
|
|
|
267
267
|
|
|
268
268
|
---
|
|
269
269
|
|
|
270
|
+
#### `get-note-plaintext`
|
|
271
|
+
|
|
272
|
+
Retrieves a note's body as plain text, with no HTML markup.
|
|
273
|
+
|
|
274
|
+
| Parameter | Type | Required | Description |
|
|
275
|
+
|-----------|------|----------|-------------|
|
|
276
|
+
| `id` | string | No | Note ID (preferred - more reliable than title) |
|
|
277
|
+
| `title` | string | No | Note title (use `id` instead when available) |
|
|
278
|
+
| `account` | string | No | Account containing the note (defaults to iCloud, ignored if `id` is provided) |
|
|
279
|
+
|
|
280
|
+
**Note:** Either `id` or `title` must be provided. This reads the note's native `plaintext` property, so it skips the HTML-to-text conversion that `get-note-content` plus a Markdown pass would do. Use `get-note-content` when you need the HTML, or `get-note-markdown` when you want Markdown with checklist state.
|
|
281
|
+
|
|
282
|
+
**Returns:** The plain-text content of the note in `structuredContent.plaintext`, or error if not found.
|
|
283
|
+
|
|
284
|
+
---
|
|
285
|
+
|
|
270
286
|
#### `get-note-details`
|
|
271
287
|
|
|
272
288
|
Retrieves metadata about a note (without full content).
|
|
@@ -376,6 +392,8 @@ Updates an existing note's content and/or title.
|
|
|
376
392
|
|
|
377
393
|
**Note:** `newContent` **replaces the entire note body** — it is not appended. To preserve existing content, read it first (e.g. with `get-note-content`) and include it in `newContent`.
|
|
378
394
|
|
|
395
|
+
**Attachments:** A full-body replace can drop embedded files, images, scans, PDFs, or audio. When a note may hold attachments, run [`list-attachments`](#list-attachments) first, and either save them with `save-attachment` or build a new note rather than overwriting. See the skill's [Attachment-Safe Updates](skills/apple-notes/SKILL.md#attachment-safe-updates) guidance.
|
|
396
|
+
|
|
379
397
|
---
|
|
380
398
|
|
|
381
399
|
#### `delete-note`
|
|
@@ -551,6 +569,19 @@ Deletes a folder.
|
|
|
551
569
|
|
|
552
570
|
---
|
|
553
571
|
|
|
572
|
+
#### `show-folder`
|
|
573
|
+
|
|
574
|
+
Reveals a folder in Notes.app using its unique CoreData identifier.
|
|
575
|
+
|
|
576
|
+
| Parameter | Type | Required | Description |
|
|
577
|
+
|-----------|------|----------|-------------|
|
|
578
|
+
| `id` | string | Yes | The folder's CoreData identifier (from `list-folders`) |
|
|
579
|
+
| `separately` | boolean | No | Open in a separate window when supported by Notes.app |
|
|
580
|
+
|
|
581
|
+
**Returns:** Confirmation that Notes.app accepted the show command.
|
|
582
|
+
|
|
583
|
+
---
|
|
584
|
+
|
|
554
585
|
### Account Operations
|
|
555
586
|
|
|
556
587
|
#### `list-accounts`
|
|
@@ -578,6 +609,19 @@ Returns the default account and folder Notes.app uses for newly created notes.
|
|
|
578
609
|
|
|
579
610
|
---
|
|
580
611
|
|
|
612
|
+
#### `show-account`
|
|
613
|
+
|
|
614
|
+
Reveals an account in Notes.app using its unique CoreData identifier.
|
|
615
|
+
|
|
616
|
+
| Parameter | Type | Required | Description |
|
|
617
|
+
|-----------|------|----------|-------------|
|
|
618
|
+
| `id` | string | Yes | The account's CoreData identifier (from `list-accounts`) |
|
|
619
|
+
| `separately` | boolean | No | Open in a separate window when supported by Notes.app |
|
|
620
|
+
|
|
621
|
+
**Returns:** Confirmation that Notes.app accepted the show command.
|
|
622
|
+
|
|
623
|
+
---
|
|
624
|
+
|
|
581
625
|
### Batch Operations
|
|
582
626
|
|
|
583
627
|
#### `batch-delete-notes`
|
|
@@ -662,6 +706,22 @@ Checklist for "Shopping List" (2/4 done):
|
|
|
662
706
|
|
|
663
707
|
---
|
|
664
708
|
|
|
709
|
+
#### `get-note-metadata` (BETA)
|
|
710
|
+
|
|
711
|
+
Reads note metadata that AppleScript cannot expose, by querying the NoteStore SQLite database directly: pinned state, checklist flags, trash/recovery state, the preview snippet, and the password hint. The available fields vary by macOS version.
|
|
712
|
+
|
|
713
|
+
**Requires:** Full Disk Access for the MCP host process (see [Full Disk Access Setup](#full-disk-access-for-checklist-features)).
|
|
714
|
+
|
|
715
|
+
**BETA:** the NoteStore schema changes between macOS releases, so some fields can be absent on older or newer systems. The database is only ever read, never written.
|
|
716
|
+
|
|
717
|
+
| Parameter | Type | Required | Description |
|
|
718
|
+
|-----------|------|----------|-------------|
|
|
719
|
+
| `id` | string | Yes | Note ID (use `search-notes` to find it first) |
|
|
720
|
+
|
|
721
|
+
**Returns:** A metadata object in `structuredContent` holding any of `pinned`, `hasChecklist`, `hasChecklistInProgress`, `recoveringFromTrash`, `passwordProtected`, `passwordHint`, `snippet`, `widgetSnippet`, and `smartFolderQuery`. Unlike most read tools, it also resolves trashed notes that AppleScript can no longer find.
|
|
722
|
+
|
|
723
|
+
---
|
|
724
|
+
|
|
665
725
|
#### `list-attachments`
|
|
666
726
|
|
|
667
727
|
Lists attachments in a note.
|
|
@@ -703,6 +763,20 @@ Returns a note attachment's bytes as base64, without writing to disk (the read c
|
|
|
703
763
|
|
|
704
764
|
---
|
|
705
765
|
|
|
766
|
+
#### `show-attachment`
|
|
767
|
+
|
|
768
|
+
Reveals one note attachment in Notes.app. Attachments are elements of a note, so this takes both the note id and the attachment id (the same pair used by `save-attachment` / `fetch-attachment`).
|
|
769
|
+
|
|
770
|
+
| Parameter | Type | Required | Description |
|
|
771
|
+
|-----------|------|----------|-------------|
|
|
772
|
+
| `noteId` | string | Yes | CoreData note ID (from `search-notes`/`list-notes`) |
|
|
773
|
+
| `attachmentId` | string | Yes | Attachment ID (from `list-attachments`) |
|
|
774
|
+
| `separately` | boolean | No | Open in a separate window when supported by Notes.app |
|
|
775
|
+
|
|
776
|
+
**Returns:** Confirmation that Notes.app revealed the attachment.
|
|
777
|
+
|
|
778
|
+
---
|
|
779
|
+
|
|
706
780
|
### Diagnostics
|
|
707
781
|
|
|
708
782
|
#### `health-check`
|
package/build/index.js
CHANGED
|
@@ -26,6 +26,7 @@ import { z } from "zod";
|
|
|
26
26
|
import { AppleNotesManager } from "./services/appleNotesManager.js";
|
|
27
27
|
import { getSyncStatus, withSyncAwarenessSync } from "./utils/syncDetection.js";
|
|
28
28
|
import { getChecklistItems, hasFullDiskAccess } from "./utils/checklistParser.js";
|
|
29
|
+
import { getNoteMetadata } from "./utils/noteMetadata.js";
|
|
29
30
|
import { detectChecklistAttempt } from "./utils/contentWarnings.js";
|
|
30
31
|
import { parseHashtags } from "./utils/hashtags.js";
|
|
31
32
|
import { runDoctor, formatDoctorReport } from "./tools/doctor.js";
|
|
@@ -254,6 +255,54 @@ server.registerTool("get-note-content", {
|
|
|
254
255
|
const hashtags = parseHashtags(content);
|
|
255
256
|
return successResponse(content, { title, content, hashtags });
|
|
256
257
|
}, "Error retrieving note content"));
|
|
258
|
+
// --- get-note-plaintext ---
|
|
259
|
+
server.registerTool("get-note-plaintext", {
|
|
260
|
+
description: "Use when: reading one note's body as plain text with no HTML, by id (preferred) or title.\nReturns: the note's plaintext exactly as Notes exposes it.\nDo not use when: you need the HTML body (get-note-content) or Markdown with checklist state (get-note-markdown).\nNote: this reads the note's native plaintext property, so it skips the HTML-to-text conversion; password-protected notes must be unlocked in Notes.app first.",
|
|
261
|
+
inputSchema: {
|
|
262
|
+
id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
|
|
263
|
+
title: z.string().optional().describe("Note title (use id instead when available)"),
|
|
264
|
+
account: z
|
|
265
|
+
.string()
|
|
266
|
+
.optional()
|
|
267
|
+
.describe("Account name (defaults to iCloud, ignored if id is provided)"),
|
|
268
|
+
},
|
|
269
|
+
outputSchema: {
|
|
270
|
+
title: z.string().optional(),
|
|
271
|
+
plaintext: z.string().optional(),
|
|
272
|
+
},
|
|
273
|
+
}, withErrorHandling(({ id, title, account }) => {
|
|
274
|
+
// Prefer ID-based lookup if provided
|
|
275
|
+
if (id) {
|
|
276
|
+
const note = notesManager.getNoteById(id);
|
|
277
|
+
if (!note) {
|
|
278
|
+
return errorResponse(`Note with ID "${id}" not found`);
|
|
279
|
+
}
|
|
280
|
+
if (note.passwordProtected) {
|
|
281
|
+
return errorResponse(`Note "${note.title}" is password-protected and cannot be read. Unlock it in Notes.app first.`);
|
|
282
|
+
}
|
|
283
|
+
const plaintext = notesManager.getNotePlaintextById(id);
|
|
284
|
+
if (!plaintext) {
|
|
285
|
+
return errorResponse(`Failed to read plaintext of note "${note.title}"`);
|
|
286
|
+
}
|
|
287
|
+
return successResponse(plaintext, { title: note.title, plaintext });
|
|
288
|
+
}
|
|
289
|
+
// Fall back to title-based lookup
|
|
290
|
+
if (!title) {
|
|
291
|
+
return errorResponse("Either 'id' or 'title' is required");
|
|
292
|
+
}
|
|
293
|
+
const note = notesManager.getNoteDetails(title, account);
|
|
294
|
+
if (!note) {
|
|
295
|
+
return errorResponse(`Note "${title}" not found`);
|
|
296
|
+
}
|
|
297
|
+
if (note.passwordProtected) {
|
|
298
|
+
return errorResponse(`Note "${title}" is password-protected and cannot be read. Unlock it in Notes.app first.`);
|
|
299
|
+
}
|
|
300
|
+
const plaintext = notesManager.getNotePlaintext(title, account);
|
|
301
|
+
if (!plaintext) {
|
|
302
|
+
return errorResponse(`Failed to read plaintext of note "${title}"`);
|
|
303
|
+
}
|
|
304
|
+
return successResponse(plaintext, { title, plaintext });
|
|
305
|
+
}, "Error retrieving note plaintext"));
|
|
257
306
|
// --- get-note-by-id ---
|
|
258
307
|
server.registerTool("get-note-by-id", {
|
|
259
308
|
description: "Use when: you have a note id and need its metadata only.\nReturns: id, title, created, modified, shared, passwordProtected.\nDo not use when: you need the body text (get-note-content) or only have a title (get-note-details).",
|
|
@@ -335,9 +384,51 @@ server.registerTool("show-note", {
|
|
|
335
384
|
}
|
|
336
385
|
return successResponse(`Shown note with ID "${id}" in Notes.app`, { id, separately });
|
|
337
386
|
}, "Error showing note"));
|
|
387
|
+
// --- show-folder ---
|
|
388
|
+
server.registerTool("show-folder", {
|
|
389
|
+
description: "Use when: the user wants to reveal a known folder in Notes.app by id.\nReturns: confirmation that Notes.app accepted the show command.\nDo not use when: you only need the folder list (list-folders).\nNote: this opens or focuses the Notes UI. Get the id from list-folders.",
|
|
390
|
+
inputSchema: {
|
|
391
|
+
id: z.string().min(1, "Folder ID is required"),
|
|
392
|
+
separately: z
|
|
393
|
+
.boolean()
|
|
394
|
+
.optional()
|
|
395
|
+
.describe("Open in a separate window when supported by Notes.app"),
|
|
396
|
+
},
|
|
397
|
+
outputSchema: {
|
|
398
|
+
id: z.string().optional(),
|
|
399
|
+
separately: z.boolean().optional(),
|
|
400
|
+
},
|
|
401
|
+
}, withErrorHandling(({ id, separately = false }) => {
|
|
402
|
+
const success = notesManager.showFolderById(id, separately);
|
|
403
|
+
if (!success) {
|
|
404
|
+
return errorResponse(`Failed to show folder with ID "${id}"`);
|
|
405
|
+
}
|
|
406
|
+
return successResponse(`Shown folder with ID "${id}" in Notes.app`, { id, separately });
|
|
407
|
+
}, "Error showing folder"));
|
|
408
|
+
// --- show-account ---
|
|
409
|
+
server.registerTool("show-account", {
|
|
410
|
+
description: "Use when: the user wants to reveal a known account in Notes.app by id.\nReturns: confirmation that Notes.app accepted the show command.\nDo not use when: you only need the account list (list-accounts).\nNote: this opens or focuses the Notes UI. Get the id from list-accounts.",
|
|
411
|
+
inputSchema: {
|
|
412
|
+
id: z.string().min(1, "Account ID is required"),
|
|
413
|
+
separately: z
|
|
414
|
+
.boolean()
|
|
415
|
+
.optional()
|
|
416
|
+
.describe("Open in a separate window when supported by Notes.app"),
|
|
417
|
+
},
|
|
418
|
+
outputSchema: {
|
|
419
|
+
id: z.string().optional(),
|
|
420
|
+
separately: z.boolean().optional(),
|
|
421
|
+
},
|
|
422
|
+
}, withErrorHandling(({ id, separately = false }) => {
|
|
423
|
+
const success = notesManager.showAccountById(id, separately);
|
|
424
|
+
if (!success) {
|
|
425
|
+
return errorResponse(`Failed to show account with ID "${id}"`);
|
|
426
|
+
}
|
|
427
|
+
return successResponse(`Shown account with ID "${id}" in Notes.app`, { id, separately });
|
|
428
|
+
}, "Error showing account"));
|
|
338
429
|
// --- update-note ---
|
|
339
430
|
server.registerTool("update-note", {
|
|
340
|
-
description: "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.",
|
|
431
|
+
description: "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, and run list-attachments first when the note may hold files, images, scans, PDFs, or audio, since a full-body replace can drop embedded attachments. Edits to shared notes are immediately visible to all collaborators.",
|
|
341
432
|
inputSchema: {
|
|
342
433
|
id: z.string().optional().describe("Note ID (preferred - more reliable than title)"),
|
|
343
434
|
title: z.string().optional().describe("Current note title (use id instead when available)"),
|
|
@@ -1048,6 +1139,39 @@ server.registerTool("fetch-attachment", {
|
|
|
1048
1139
|
}
|
|
1049
1140
|
return successResponse(`Fetched "${r.name ?? "attachment"}" (${r.contentType ?? "unknown type"}, ${r.bytes ?? 0} bytes) as base64.`, { name: r.name, contentType: r.contentType, bytes: r.bytes, base64: r.base64 });
|
|
1050
1141
|
}, "Error fetching attachment"));
|
|
1142
|
+
// --- show-attachment ---
|
|
1143
|
+
server.registerTool("show-attachment", {
|
|
1144
|
+
description: "Use when: the user wants to reveal one note attachment in Notes.app.\nReturns: confirmation that Notes.app revealed the attachment.\nDo not use when: you want the bytes (fetch-attachment) or a file on disk (save-attachment).\nNote: this opens or focuses the Notes UI. Get the ids from list-attachments first.",
|
|
1145
|
+
inputSchema: {
|
|
1146
|
+
noteId: z
|
|
1147
|
+
.string()
|
|
1148
|
+
.min(1, "noteId is required")
|
|
1149
|
+
.describe("CoreData note id (from search/list)"),
|
|
1150
|
+
attachmentId: z
|
|
1151
|
+
.string()
|
|
1152
|
+
.min(1, "attachmentId is required")
|
|
1153
|
+
.describe("Attachment id (from list-attachments)"),
|
|
1154
|
+
separately: z
|
|
1155
|
+
.boolean()
|
|
1156
|
+
.optional()
|
|
1157
|
+
.describe("Open in a separate window when supported by Notes.app"),
|
|
1158
|
+
},
|
|
1159
|
+
outputSchema: {
|
|
1160
|
+
noteId: z.string().optional(),
|
|
1161
|
+
attachmentId: z.string().optional(),
|
|
1162
|
+
separately: z.boolean().optional(),
|
|
1163
|
+
},
|
|
1164
|
+
}, withErrorHandling(({ noteId, attachmentId, separately = false }) => {
|
|
1165
|
+
const success = notesManager.showAttachmentById(noteId, attachmentId, separately);
|
|
1166
|
+
if (!success) {
|
|
1167
|
+
return errorResponse(`Failed to show attachment "${attachmentId}" on note "${noteId}"`);
|
|
1168
|
+
}
|
|
1169
|
+
return successResponse(`Shown attachment "${attachmentId}" in Notes.app`, {
|
|
1170
|
+
noteId,
|
|
1171
|
+
attachmentId,
|
|
1172
|
+
separately,
|
|
1173
|
+
});
|
|
1174
|
+
}, "Error showing attachment"));
|
|
1051
1175
|
// --- export-notes-json ---
|
|
1052
1176
|
server.registerTool("export-notes-json", {
|
|
1053
1177
|
description: "Use when: exporting the entire notes library as structured JSON for backup or bulk processing.\nReturns: a summary plus the full JSON of all notes, folders, and accounts.\nDo not use when: you need a single note (get-note-content) — this reads everything and can be large.\nRead-only.",
|
|
@@ -1138,6 +1262,36 @@ server.registerTool("get-checklist-state", {
|
|
|
1138
1262
|
const checked = result.items.filter((i) => i.done).length;
|
|
1139
1263
|
return successResponse(`Checklist for "${note.title}" (${checked}/${result.items.length} done):\n${summary}`, { items: result.items, checked, total: result.items.length });
|
|
1140
1264
|
}, "Error reading checklist state"));
|
|
1265
|
+
// --- get-note-metadata (BETA) ---
|
|
1266
|
+
server.registerTool("get-note-metadata", {
|
|
1267
|
+
description: "[BETA] Use when: reading note metadata AppleScript cannot expose — pinned state, checklist flags, trash/recovery state, preview snippet, password hint — by id.\nReturns: a metadata object; fields vary by macOS version and are omitted when unavailable.\nDo not use when: you need the body (get-note-content) or per-item checklist state (get-checklist-state).\nNote: reads the NoteStore SQLite database read-only and requires Full Disk Access. BETA — the database schema changes between macOS releases, so some fields may be absent. Works on trashed notes that AppleScript can no longer resolve.",
|
|
1268
|
+
inputSchema: {
|
|
1269
|
+
id: z.string().min(1, "Note ID is required. Use search-notes to find the note ID first."),
|
|
1270
|
+
},
|
|
1271
|
+
outputSchema: {
|
|
1272
|
+
pinned: z.boolean().optional(),
|
|
1273
|
+
hasChecklist: z.boolean().optional(),
|
|
1274
|
+
hasChecklistInProgress: z.boolean().optional(),
|
|
1275
|
+
recoveringFromTrash: z.boolean().optional(),
|
|
1276
|
+
passwordProtected: z.boolean().optional(),
|
|
1277
|
+
passwordHint: z.string().optional(),
|
|
1278
|
+
snippet: z.string().optional(),
|
|
1279
|
+
widgetSnippet: z.string().optional(),
|
|
1280
|
+
smartFolderQuery: z.string().optional(),
|
|
1281
|
+
},
|
|
1282
|
+
}, withErrorHandling(({ id }) => {
|
|
1283
|
+
// No AppleScript existence pre-check: reading straight from the database lets
|
|
1284
|
+
// this resolve trashed/recovering notes that `note id ...` can no longer find.
|
|
1285
|
+
const { metadata, message } = getNoteMetadata(id);
|
|
1286
|
+
if (!metadata) {
|
|
1287
|
+
return errorResponse(message || `Failed to read metadata for note "${id}"`);
|
|
1288
|
+
}
|
|
1289
|
+
const keys = Object.keys(metadata);
|
|
1290
|
+
const summary = keys.length === 0
|
|
1291
|
+
? `No additional metadata is available for note "${id}" on this macOS version.`
|
|
1292
|
+
: keys.map((k) => `${k}: ${String(metadata[k])}`).join("\n");
|
|
1293
|
+
return successResponse(summary, metadata);
|
|
1294
|
+
}, "Error reading note metadata"));
|
|
1141
1295
|
// =============================================================================
|
|
1142
1296
|
// Server Startup
|
|
1143
1297
|
// =============================================================================
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export const NOTES_NORMALIZED_HTML_FIXTURES = [
|
|
2
|
+
{
|
|
3
|
+
name: "headingAndParagraphs",
|
|
4
|
+
description: "An <h1> title and two <div> paragraphs separated by a spacer row",
|
|
5
|
+
html: "<div><h1>Meeting Notes</h1></div><div>First line.</div><div><br></div><div>Second line.</div>",
|
|
6
|
+
expectedMarkdown: "# Meeting Notes\n\nFirst line.\n \n\nSecond line.",
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: "bulletList",
|
|
10
|
+
description: "An <h2> section heading followed by a native <ul> bullet list",
|
|
11
|
+
html: "<div><h2>Tasks</h2></div><ul><li>Buy milk</li><li>Walk dog</li></ul>",
|
|
12
|
+
expectedMarkdown: "## Tasks\n\n- Buy milk\n- Walk dog",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "inlineEmphasis",
|
|
16
|
+
description: "Inline <b> and <i> emphasis inside a paragraph div",
|
|
17
|
+
html: "<div>This is <b>bold</b> and <i>italic</i> text.</div>",
|
|
18
|
+
expectedMarkdown: "This is **bold** and _italic_ text.",
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
name: "codeSpan",
|
|
22
|
+
description: "A <tt> code span — the tag is dropped, only its text survives",
|
|
23
|
+
html: "<div>Run <tt>npm install</tt> first.</div>",
|
|
24
|
+
expectedMarkdown: "Run npm install first.",
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: "spacerRuns",
|
|
28
|
+
description: "Consecutive <div><br></div> spacer rows between two paragraphs",
|
|
29
|
+
html: "<div>A</div><div><br></div><div><br></div><div>B</div>",
|
|
30
|
+
expectedMarkdown: "A\n \n\n \n\nB",
|
|
31
|
+
},
|
|
32
|
+
];
|
|
@@ -781,6 +781,53 @@ export class AppleNotesManager {
|
|
|
781
781
|
}
|
|
782
782
|
return result.output;
|
|
783
783
|
}
|
|
784
|
+
/**
|
|
785
|
+
* Retrieves the plain-text content of a note by its exact title.
|
|
786
|
+
*
|
|
787
|
+
* Reads the note's `plaintext` property, which Notes derives from the body
|
|
788
|
+
* with all HTML markup removed. This is the text Notes itself exposes, so it
|
|
789
|
+
* is more faithful than converting the HTML body and skips the markup
|
|
790
|
+
* round-trip entirely.
|
|
791
|
+
*
|
|
792
|
+
* @param title - Exact title of the note
|
|
793
|
+
* @param account - Account to search in (defaults to iCloud)
|
|
794
|
+
* @returns Plain-text content of the note, or empty string if not found
|
|
795
|
+
*/
|
|
796
|
+
getNotePlaintext(title, account) {
|
|
797
|
+
const targetAccount = this.resolveAccount(account);
|
|
798
|
+
const safeTitle = escapePlainStringForAppleScript(title);
|
|
799
|
+
const getCommand = `get plaintext of note "${safeTitle}"`;
|
|
800
|
+
const script = buildAccountScopedScript({ account: targetAccount }, getCommand);
|
|
801
|
+
const result = executeAppleScript(script);
|
|
802
|
+
if (!result.success) {
|
|
803
|
+
console.error(`Failed to get plaintext of note "${title}":`, result.error);
|
|
804
|
+
return "";
|
|
805
|
+
}
|
|
806
|
+
return result.output;
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Retrieves the plain-text content of a note by its CoreData ID.
|
|
810
|
+
*
|
|
811
|
+
* Reads the read-only `plaintext` property (the body with HTML removed). More
|
|
812
|
+
* reliable than getNotePlaintext() because IDs are unique across accounts.
|
|
813
|
+
*
|
|
814
|
+
* Note: Password-protected notes will fail with an AppleScript error. Callers
|
|
815
|
+
* should check for password protection beforehand using getNoteById().
|
|
816
|
+
*
|
|
817
|
+
* @param id - CoreData URL identifier for the note
|
|
818
|
+
* @returns Plain-text content of the note, or empty string if not found
|
|
819
|
+
*/
|
|
820
|
+
getNotePlaintextById(id) {
|
|
821
|
+
const safeId = sanitizeId(id);
|
|
822
|
+
const getCommand = `get plaintext of note id "${safeId}"`;
|
|
823
|
+
const script = buildAppLevelScript(getCommand);
|
|
824
|
+
const result = executeAppleScript(script);
|
|
825
|
+
if (!result.success) {
|
|
826
|
+
console.error(`Failed to get plaintext of note with ID "${id}":`, result.error);
|
|
827
|
+
return "";
|
|
828
|
+
}
|
|
829
|
+
return result.output;
|
|
830
|
+
}
|
|
784
831
|
/**
|
|
785
832
|
* Retrieves a note by its unique CoreData ID.
|
|
786
833
|
*
|
|
@@ -1604,6 +1651,91 @@ export class AppleNotesManager {
|
|
|
1604
1651
|
}
|
|
1605
1652
|
return true;
|
|
1606
1653
|
}
|
|
1654
|
+
/**
|
|
1655
|
+
* Reveals a folder in the Notes.app UI by its id.
|
|
1656
|
+
*
|
|
1657
|
+
* Wraps the Notes `show` command, which the scripting dictionary exposes for
|
|
1658
|
+
* folders as well as notes. This opens or focuses the Notes UI on the folder.
|
|
1659
|
+
*
|
|
1660
|
+
* @param id - CoreData identifier for the folder (from list-folders)
|
|
1661
|
+
* @param separately - Open in a separate window when supported by Notes.app
|
|
1662
|
+
* @returns true if Notes.app accepted the show command, false otherwise
|
|
1663
|
+
*/
|
|
1664
|
+
showFolderById(id, separately = false) {
|
|
1665
|
+
const safeId = sanitizeId(id);
|
|
1666
|
+
const separatelyClause = separately ? " separately true" : "";
|
|
1667
|
+
const result = executeAppleScript(buildAppLevelScript(`show folder id "${safeId}"${separatelyClause}`));
|
|
1668
|
+
if (!result.success) {
|
|
1669
|
+
console.error(`Failed to show folder with ID "${id}":`, result.error);
|
|
1670
|
+
return false;
|
|
1671
|
+
}
|
|
1672
|
+
return true;
|
|
1673
|
+
}
|
|
1674
|
+
/**
|
|
1675
|
+
* Reveals an account in the Notes.app UI by its id.
|
|
1676
|
+
*
|
|
1677
|
+
* Wraps the Notes `show` command, which the scripting dictionary exposes for
|
|
1678
|
+
* accounts as well as notes. This opens or focuses the Notes UI on the account.
|
|
1679
|
+
*
|
|
1680
|
+
* @param id - CoreData identifier for the account (from list-accounts)
|
|
1681
|
+
* @param separately - Open in a separate window when supported by Notes.app
|
|
1682
|
+
* @returns true if Notes.app accepted the show command, false otherwise
|
|
1683
|
+
*/
|
|
1684
|
+
showAccountById(id, separately = false) {
|
|
1685
|
+
const safeId = sanitizeId(id);
|
|
1686
|
+
const separatelyClause = separately ? " separately true" : "";
|
|
1687
|
+
const result = executeAppleScript(buildAppLevelScript(`show account id "${safeId}"${separatelyClause}`));
|
|
1688
|
+
if (!result.success) {
|
|
1689
|
+
console.error(`Failed to show account with ID "${id}":`, result.error);
|
|
1690
|
+
return false;
|
|
1691
|
+
}
|
|
1692
|
+
return true;
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Reveals an attachment in the Notes.app UI.
|
|
1696
|
+
*
|
|
1697
|
+
* Attachments are elements of a note, so they cannot be referenced at the
|
|
1698
|
+
* application level by id alone. This resolves the attachment within its note
|
|
1699
|
+
* (the same lookup used by save-attachment) and then runs the Notes `show`
|
|
1700
|
+
* command on it, opening or focusing the Notes UI on the attachment.
|
|
1701
|
+
*
|
|
1702
|
+
* @param noteId - CoreData identifier for the note containing the attachment
|
|
1703
|
+
* @param attachmentId - id of the attachment (from list-attachments)
|
|
1704
|
+
* @param separately - Open in a separate window when supported by Notes.app
|
|
1705
|
+
* @returns true if Notes.app revealed the attachment, false otherwise
|
|
1706
|
+
*/
|
|
1707
|
+
showAttachmentById(noteId, attachmentId, separately = false) {
|
|
1708
|
+
const safeNoteId = sanitizeId(noteId);
|
|
1709
|
+
const safeAttId = escapePlainStringForAppleScript(attachmentId);
|
|
1710
|
+
const separatelyClause = separately ? " separately true" : "";
|
|
1711
|
+
const script = `
|
|
1712
|
+
tell application "Notes"
|
|
1713
|
+
set theNote to note id "${safeNoteId}"
|
|
1714
|
+
set theAttachment to missing value
|
|
1715
|
+
repeat with a in attachments of theNote
|
|
1716
|
+
if (id of a as text) is "${safeAttId}" then
|
|
1717
|
+
set theAttachment to a
|
|
1718
|
+
exit repeat
|
|
1719
|
+
end if
|
|
1720
|
+
end repeat
|
|
1721
|
+
if theAttachment is missing value then
|
|
1722
|
+
return "ERR${AS_FIELD_SEP}attachment not found"
|
|
1723
|
+
end if
|
|
1724
|
+
show theAttachment${separatelyClause}
|
|
1725
|
+
return "OK"
|
|
1726
|
+
end tell
|
|
1727
|
+
`;
|
|
1728
|
+
const result = executeAppleScript(script);
|
|
1729
|
+
if (!result.success) {
|
|
1730
|
+
console.error(`Failed to show attachment "${attachmentId}" on note "${noteId}":`, result.error);
|
|
1731
|
+
return false;
|
|
1732
|
+
}
|
|
1733
|
+
if ((result.output ?? "").trim().startsWith("ERR")) {
|
|
1734
|
+
console.error(`Attachment "${attachmentId}" not found on note "${noteId}"`);
|
|
1735
|
+
return false;
|
|
1736
|
+
}
|
|
1737
|
+
return true;
|
|
1738
|
+
}
|
|
1607
1739
|
// ===========================================================================
|
|
1608
1740
|
// Health Check
|
|
1609
1741
|
// ===========================================================================
|
|
@@ -664,6 +664,45 @@ describe("AppleNotesManager", () => {
|
|
|
664
664
|
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('tell account "Gmail"'));
|
|
665
665
|
});
|
|
666
666
|
});
|
|
667
|
+
describe("getNotePlaintext", () => {
|
|
668
|
+
it("reads the note's plaintext property by title", () => {
|
|
669
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
670
|
+
success: true,
|
|
671
|
+
output: "Shopping List\n- Eggs\n- Milk",
|
|
672
|
+
});
|
|
673
|
+
const text = manager.getNotePlaintext("Shopping List");
|
|
674
|
+
expect(text).toBe("Shopping List\n- Eggs\n- Milk");
|
|
675
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('get plaintext of note "Shopping List"'));
|
|
676
|
+
});
|
|
677
|
+
it("returns empty string when the note is not found", () => {
|
|
678
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
679
|
+
success: false,
|
|
680
|
+
output: "",
|
|
681
|
+
error: 'Can\'t get note "Missing"',
|
|
682
|
+
});
|
|
683
|
+
expect(manager.getNotePlaintext("Missing Note")).toBe("");
|
|
684
|
+
});
|
|
685
|
+
it("uses the specified account", () => {
|
|
686
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: "Content" });
|
|
687
|
+
manager.getNotePlaintext("My Note", "Gmail");
|
|
688
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('tell account "Gmail"'));
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
describe("getNotePlaintextById", () => {
|
|
692
|
+
it("reads the note's plaintext property by id at the application level", () => {
|
|
693
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: "Just the text" });
|
|
694
|
+
const text = manager.getNotePlaintextById("x-coredata://ABC/ICNote/p1");
|
|
695
|
+
expect(text).toBe("Just the text");
|
|
696
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('get plaintext of note id "x-coredata://ABC/ICNote/p1"'));
|
|
697
|
+
});
|
|
698
|
+
it("returns empty string when Notes.app rejects the read", () => {
|
|
699
|
+
mockExecuteAppleScript.mockReturnValue({ success: false, output: "", error: "no such note" });
|
|
700
|
+
expect(manager.getNotePlaintextById("x-coredata://ABC/ICNote/p1")).toBe("");
|
|
701
|
+
});
|
|
702
|
+
it("rejects malformed IDs", () => {
|
|
703
|
+
expect(() => manager.getNotePlaintextById("arbitrary string")).toThrow();
|
|
704
|
+
});
|
|
705
|
+
});
|
|
667
706
|
// ---------------------------------------------------------------------------
|
|
668
707
|
// Password Protection Helpers
|
|
669
708
|
// ---------------------------------------------------------------------------
|
|
@@ -1615,6 +1654,76 @@ describe("AppleNotesManager", () => {
|
|
|
1615
1654
|
expect(manager.showNoteById("x-coredata://ABC/ICNote/p1")).toBe(false);
|
|
1616
1655
|
});
|
|
1617
1656
|
});
|
|
1657
|
+
describe("showFolderById", () => {
|
|
1658
|
+
it("shows a folder by id", () => {
|
|
1659
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
|
|
1660
|
+
expect(manager.showFolderById("x-coredata://ABC/ICFolder/p1")).toBe(true);
|
|
1661
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('show folder id "x-coredata://ABC/ICFolder/p1"'));
|
|
1662
|
+
});
|
|
1663
|
+
it("can request a separate window", () => {
|
|
1664
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
|
|
1665
|
+
manager.showFolderById("x-coredata://ABC/ICFolder/p1", true);
|
|
1666
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("separately true"));
|
|
1667
|
+
});
|
|
1668
|
+
it("returns false when Notes.app rejects the show command", () => {
|
|
1669
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1670
|
+
success: false,
|
|
1671
|
+
output: "",
|
|
1672
|
+
error: "no such folder",
|
|
1673
|
+
});
|
|
1674
|
+
expect(manager.showFolderById("x-coredata://ABC/ICFolder/p1")).toBe(false);
|
|
1675
|
+
});
|
|
1676
|
+
});
|
|
1677
|
+
describe("showAccountById", () => {
|
|
1678
|
+
it("shows an account by id", () => {
|
|
1679
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
|
|
1680
|
+
expect(manager.showAccountById("x-coredata://ABC/ICAccount/p1")).toBe(true);
|
|
1681
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('show account id "x-coredata://ABC/ICAccount/p1"'));
|
|
1682
|
+
});
|
|
1683
|
+
it("can request a separate window", () => {
|
|
1684
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
|
|
1685
|
+
manager.showAccountById("x-coredata://ABC/ICAccount/p1", true);
|
|
1686
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("separately true"));
|
|
1687
|
+
});
|
|
1688
|
+
it("returns false when Notes.app rejects the show command", () => {
|
|
1689
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1690
|
+
success: false,
|
|
1691
|
+
output: "",
|
|
1692
|
+
error: "no such account",
|
|
1693
|
+
});
|
|
1694
|
+
expect(manager.showAccountById("x-coredata://ABC/ICAccount/p1")).toBe(false);
|
|
1695
|
+
});
|
|
1696
|
+
});
|
|
1697
|
+
describe("showAttachmentById", () => {
|
|
1698
|
+
it("resolves the attachment within its note and shows it", () => {
|
|
1699
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: "OK" });
|
|
1700
|
+
expect(manager.showAttachmentById("x-coredata://ABC/ICNote/p1", "att-123")).toBe(true);
|
|
1701
|
+
const script = mockExecuteAppleScript.mock.calls[0][0];
|
|
1702
|
+
expect(script).toContain('set theNote to note id "x-coredata://ABC/ICNote/p1"');
|
|
1703
|
+
expect(script).toContain('is "att-123"');
|
|
1704
|
+
expect(script).toContain("show theAttachment");
|
|
1705
|
+
});
|
|
1706
|
+
it("can request a separate window", () => {
|
|
1707
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: "OK" });
|
|
1708
|
+
manager.showAttachmentById("x-coredata://ABC/ICNote/p1", "att-123", true);
|
|
1709
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("separately true"));
|
|
1710
|
+
});
|
|
1711
|
+
it("returns false when the attachment is not found on the note", () => {
|
|
1712
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1713
|
+
success: true,
|
|
1714
|
+
output: `ERR${F}attachment not found`,
|
|
1715
|
+
});
|
|
1716
|
+
expect(manager.showAttachmentById("x-coredata://ABC/ICNote/p1", "missing")).toBe(false);
|
|
1717
|
+
});
|
|
1718
|
+
it("returns false when Notes.app rejects the show command", () => {
|
|
1719
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
1720
|
+
success: false,
|
|
1721
|
+
output: "",
|
|
1722
|
+
error: "no such note",
|
|
1723
|
+
});
|
|
1724
|
+
expect(manager.showAttachmentById("x-coredata://ABC/ICNote/p1", "att-123")).toBe(false);
|
|
1725
|
+
});
|
|
1726
|
+
});
|
|
1618
1727
|
// ---------------------------------------------------------------------------
|
|
1619
1728
|
// Health Check
|
|
1620
1729
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { NOTES_NORMALIZED_HTML_FIXTURES } from "../services/__fixtures__/notesNormalizedHtml.js";
|
|
3
|
+
// Mock the same seams the main manager test does: AppleScript execution and the
|
|
4
|
+
// SQLite-backed checklist reader (no Full Disk Access in unit tests).
|
|
5
|
+
vi.mock("@/utils/applescript.js", () => ({
|
|
6
|
+
executeAppleScript: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock("@/utils/checklistParser.js", () => ({
|
|
9
|
+
getChecklistItems: vi.fn().mockReturnValue({ items: null }),
|
|
10
|
+
}));
|
|
11
|
+
import { executeAppleScript } from "../utils/applescript.js";
|
|
12
|
+
import { AppleNotesManager } from "../services/appleNotesManager.js";
|
|
13
|
+
const mockExecuteAppleScript = vi.mocked(executeAppleScript);
|
|
14
|
+
const NOTE_ID = "x-coredata://ABC/ICNote/p1";
|
|
15
|
+
/**
|
|
16
|
+
* Regression coverage for Notes-normalized HTML -> Markdown conversion.
|
|
17
|
+
*
|
|
18
|
+
* The fixtures encode the HTML shape Apple Notes returns and the Markdown the
|
|
19
|
+
* server currently emits. Routing through getNoteMarkdownById exercises the real
|
|
20
|
+
* Turndown pipeline (including the notesDivs rule) with AppleScript mocked to
|
|
21
|
+
* return the fixture body.
|
|
22
|
+
*/
|
|
23
|
+
describe("Notes-normalized HTML to Markdown", () => {
|
|
24
|
+
let manager;
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
vi.clearAllMocks();
|
|
27
|
+
manager = new AppleNotesManager();
|
|
28
|
+
});
|
|
29
|
+
for (const fixture of NOTES_NORMALIZED_HTML_FIXTURES) {
|
|
30
|
+
it(`converts ${fixture.name} (${fixture.description})`, () => {
|
|
31
|
+
mockExecuteAppleScript.mockReturnValue({ success: true, output: fixture.html });
|
|
32
|
+
const markdown = manager.getNoteMarkdownById(NOTE_ID);
|
|
33
|
+
expect(markdown).toBe(fixture.expectedMarkdown);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
it("documents that a <div><br></div> spacer leaves a two-space line", () => {
|
|
37
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
38
|
+
success: true,
|
|
39
|
+
output: "<div>A</div><div><br></div><div>B</div>",
|
|
40
|
+
});
|
|
41
|
+
const markdown = manager.getNoteMarkdownById(NOTE_ID);
|
|
42
|
+
// The spacer survives as a stray " " line — the Markdown-side fingerprint of
|
|
43
|
+
// the whitespace-accumulation behavior CLAUDE.md warns about.
|
|
44
|
+
expect(markdown).toBe("A\n \n\nB");
|
|
45
|
+
});
|
|
46
|
+
it("documents that <tt> is dropped, keeping only its text", () => {
|
|
47
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
48
|
+
success: true,
|
|
49
|
+
output: "<div>Run <tt>npm install</tt> first.</div>",
|
|
50
|
+
});
|
|
51
|
+
const markdown = manager.getNoteMarkdownById(NOTE_ID);
|
|
52
|
+
expect(markdown).toBe("Run npm install first.");
|
|
53
|
+
expect(markdown).not.toContain("`");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apple Notes Read-Only Metadata Reader [BETA]
|
|
3
|
+
*
|
|
4
|
+
* Reads note metadata that the AppleScript dictionary does not expose (pinned
|
|
5
|
+
* state, checklist flags, trash/recovery, preview snippet, password hint) by
|
|
6
|
+
* querying the NoteStore SQLite database directly.
|
|
7
|
+
*
|
|
8
|
+
* Unlike the note body (a gzipped protobuf blob in ZICNOTEDATA.ZDATA), these are
|
|
9
|
+
* plain scalar columns on ZICCLOUDSYNCINGOBJECT, so no protobuf decoding is
|
|
10
|
+
* needed — an ordinary SELECT is enough.
|
|
11
|
+
*
|
|
12
|
+
* BETA / safety:
|
|
13
|
+
* - The database is opened READ-ONLY (`sqlite3 -readonly`). This code never
|
|
14
|
+
* writes to the live store; doing so would corrupt CloudKit sync state.
|
|
15
|
+
* - Requires Full Disk Access for the host process.
|
|
16
|
+
* - The schema changes between macOS releases, so the reader feature-detects
|
|
17
|
+
* which columns exist (PRAGMA table_info) and only selects those.
|
|
18
|
+
*
|
|
19
|
+
* @module utils/noteMetadata
|
|
20
|
+
* @see TECHNICAL_NOTES.md#read-only-metadata-columns-verified-macos-27--notes-413
|
|
21
|
+
*/
|
|
22
|
+
import { execFileSync } from "child_process";
|
|
23
|
+
import * as fs from "fs";
|
|
24
|
+
import * as path from "path";
|
|
25
|
+
import * as os from "os";
|
|
26
|
+
const NOTES_DB_PATH = path.join(os.homedir(), "Library/Group Containers/group.com.apple.notes/NoteStore.sqlite");
|
|
27
|
+
const FDA_MESSAGE = "Full Disk Access is required to read note metadata. " +
|
|
28
|
+
"Grant access in System Settings > Privacy & Security > Full Disk Access, " +
|
|
29
|
+
"then add and restart this application.";
|
|
30
|
+
/**
|
|
31
|
+
* Friendly field name -> NoteStore column. This is a fixed allowlist; nothing
|
|
32
|
+
* here is built from user input, so the column names are never an injection
|
|
33
|
+
* vector. `bool` columns store 0/1; `text` columns store strings.
|
|
34
|
+
*/
|
|
35
|
+
const COLUMN_MAP = [
|
|
36
|
+
{ key: "pinned", column: "ZISPINNED", type: "bool" },
|
|
37
|
+
{ key: "hasChecklist", column: "ZHASCHECKLIST", type: "bool" },
|
|
38
|
+
{ key: "hasChecklistInProgress", column: "ZHASCHECKLISTINPROGRESS", type: "bool" },
|
|
39
|
+
{ key: "recoveringFromTrash", column: "ZISRECOVERINGFROMTRASH", type: "bool" },
|
|
40
|
+
{ key: "passwordProtected", column: "ZISPASSWORDPROTECTED", type: "bool" },
|
|
41
|
+
{ key: "passwordHint", column: "ZPASSWORDHINT", type: "text" },
|
|
42
|
+
{ key: "snippet", column: "ZSNIPPET", type: "text" },
|
|
43
|
+
{ key: "widgetSnippet", column: "ZWIDGETSNIPPET", type: "text" },
|
|
44
|
+
{ key: "smartFolderQuery", column: "ZSMARTFOLDERQUERYJSON", type: "text" },
|
|
45
|
+
];
|
|
46
|
+
/**
|
|
47
|
+
* Runs a read-only sqlite3 query against the live NoteStore and returns trimmed
|
|
48
|
+
* stdout. Throws on failure (callers classify the error).
|
|
49
|
+
*
|
|
50
|
+
* Uses execFileSync with an argument array (no shell), so the database path's
|
|
51
|
+
* spaces and the query string are passed verbatim and shell metacharacters are
|
|
52
|
+
* never interpreted. The only dynamic value in any query is the note's primary
|
|
53
|
+
* key, which callers constrain to digits before it reaches here.
|
|
54
|
+
*/
|
|
55
|
+
function runSqlite(query) {
|
|
56
|
+
return execFileSync("sqlite3", ["-readonly", NOTES_DB_PATH, query], {
|
|
57
|
+
encoding: "utf8",
|
|
58
|
+
timeout: 5000,
|
|
59
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
60
|
+
}).trim();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Returns the set of column names present on ZICCLOUDSYNCINGOBJECT, so the
|
|
64
|
+
* reader can skip columns that do not exist on this macOS version.
|
|
65
|
+
*/
|
|
66
|
+
function presentColumns() {
|
|
67
|
+
const out = runSqlite("PRAGMA table_info(ZICCLOUDSYNCINGOBJECT);");
|
|
68
|
+
const cols = new Set();
|
|
69
|
+
for (const line of out.split("\n")) {
|
|
70
|
+
// Each row: cid|name|type|notnull|dflt_value|pk
|
|
71
|
+
const name = line.split("|")[1];
|
|
72
|
+
if (name)
|
|
73
|
+
cols.add(name);
|
|
74
|
+
}
|
|
75
|
+
return cols;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Reads read-only metadata for a note by its CoreData ID.
|
|
79
|
+
*
|
|
80
|
+
* @param noteId - CoreData URL identifier (e.g., "x-coredata://ABC/ICNote/p123")
|
|
81
|
+
* @returns Structured result with the metadata, an error type, and a message
|
|
82
|
+
*/
|
|
83
|
+
export function getNoteMetadata(noteId) {
|
|
84
|
+
const pkMatch = noteId.match(/\/p(\d+)$/);
|
|
85
|
+
if (!pkMatch) {
|
|
86
|
+
return {
|
|
87
|
+
metadata: null,
|
|
88
|
+
error: "invalid_id",
|
|
89
|
+
message: `Invalid note ID format: "${noteId}". Expected format: x-coredata://UUID/ICNote/pNNN`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
const pk = pkMatch[1];
|
|
93
|
+
if (!fs.existsSync(NOTES_DB_PATH)) {
|
|
94
|
+
return { metadata: null, error: "no_fda", message: FDA_MESSAGE };
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const available = presentColumns();
|
|
98
|
+
const selected = COLUMN_MAP.filter((c) => available.has(c.column));
|
|
99
|
+
if (selected.length === 0) {
|
|
100
|
+
// Schema has none of the known columns (very old or very new macOS).
|
|
101
|
+
return { metadata: {} };
|
|
102
|
+
}
|
|
103
|
+
const pairs = selected.map((c) => `'${c.key}', ${c.column}`).join(", ");
|
|
104
|
+
const row = runSqlite(`SELECT json_object(${pairs}) FROM ZICCLOUDSYNCINGOBJECT WHERE Z_PK = ${pk};`);
|
|
105
|
+
if (!row) {
|
|
106
|
+
return {
|
|
107
|
+
metadata: null,
|
|
108
|
+
error: "not_found",
|
|
109
|
+
message: `No note found in the database for ID "${noteId}".`,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
const raw = JSON.parse(row);
|
|
113
|
+
const metadata = {};
|
|
114
|
+
for (const c of selected) {
|
|
115
|
+
const value = raw[c.key];
|
|
116
|
+
if (value === null || value === undefined)
|
|
117
|
+
continue;
|
|
118
|
+
if (c.type === "bool") {
|
|
119
|
+
metadata[c.key] = value === 1 || value === "1" || value === true;
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
metadata[c.key] = String(value);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return { metadata: metadata };
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
129
|
+
if (message.includes("authorization denied") || message.includes("unable to open database")) {
|
|
130
|
+
return { metadata: null, error: "no_fda", message: FDA_MESSAGE };
|
|
131
|
+
}
|
|
132
|
+
console.error(`Failed to read note metadata: ${message}`);
|
|
133
|
+
return { metadata: null, error: "query_error", message: "Failed to read note metadata." };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the read-only note metadata reader.
|
|
3
|
+
*
|
|
4
|
+
* The NoteStore SQLite access is mocked, so these exercise the pk extraction,
|
|
5
|
+
* column feature-detection, JSON parsing, boolean coercion, and error
|
|
6
|
+
* classification without touching a real database.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
9
|
+
vi.mock("child_process", () => ({
|
|
10
|
+
execFileSync: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
vi.mock("fs", () => ({
|
|
13
|
+
existsSync: vi.fn(() => true),
|
|
14
|
+
}));
|
|
15
|
+
import { execFileSync } from "child_process";
|
|
16
|
+
import { existsSync } from "fs";
|
|
17
|
+
import { getNoteMetadata } from "./noteMetadata.js";
|
|
18
|
+
const mockExecFileSync = vi.mocked(execFileSync);
|
|
19
|
+
const mockExistsSync = vi.mocked(existsSync);
|
|
20
|
+
const NOTE_ID = "x-coredata://ABC/ICNote/p123";
|
|
21
|
+
/** Builds a PRAGMA table_info dump from a list of column names. */
|
|
22
|
+
function tableInfo(columns) {
|
|
23
|
+
return columns.map((name, i) => `${i}|${name}|INTEGER|0||0`).join("\n");
|
|
24
|
+
}
|
|
25
|
+
/** The query string is the third sqlite3 argument: [-readonly, dbPath, query]. */
|
|
26
|
+
function queryOf(call) {
|
|
27
|
+
const args = call[1];
|
|
28
|
+
return args[2];
|
|
29
|
+
}
|
|
30
|
+
describe("getNoteMetadata", () => {
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.clearAllMocks();
|
|
33
|
+
mockExistsSync.mockReturnValue(true);
|
|
34
|
+
});
|
|
35
|
+
it("rejects a malformed note id without touching the database", () => {
|
|
36
|
+
const result = getNoteMetadata("not-a-real-id");
|
|
37
|
+
expect(result.metadata).toBeNull();
|
|
38
|
+
expect(result.error).toBe("invalid_id");
|
|
39
|
+
expect(mockExecFileSync).not.toHaveBeenCalled();
|
|
40
|
+
});
|
|
41
|
+
it("reads pinned/checklist/snippet and coerces 0/1 to booleans", () => {
|
|
42
|
+
mockExecFileSync.mockImplementation((_cmd, args) => {
|
|
43
|
+
const query = args[2];
|
|
44
|
+
if (query.includes("table_info")) {
|
|
45
|
+
return tableInfo(["Z_PK", "ZISPINNED", "ZHASCHECKLIST", "ZSNIPPET", "ZPASSWORDHINT"]);
|
|
46
|
+
}
|
|
47
|
+
return JSON.stringify({
|
|
48
|
+
pinned: 1,
|
|
49
|
+
hasChecklist: 0,
|
|
50
|
+
snippet: "Hello world",
|
|
51
|
+
passwordHint: null,
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
const result = getNoteMetadata(NOTE_ID);
|
|
55
|
+
expect(result.error).toBeUndefined();
|
|
56
|
+
expect(result.metadata).toEqual({
|
|
57
|
+
pinned: true,
|
|
58
|
+
hasChecklist: false,
|
|
59
|
+
snippet: "Hello world",
|
|
60
|
+
});
|
|
61
|
+
// A NULL column (passwordHint) is omitted, not included as null.
|
|
62
|
+
expect(result.metadata).not.toHaveProperty("passwordHint");
|
|
63
|
+
});
|
|
64
|
+
it("only selects columns that exist on this schema", () => {
|
|
65
|
+
mockExecFileSync.mockImplementation((_cmd, args) => {
|
|
66
|
+
const query = args[2];
|
|
67
|
+
if (query.includes("table_info"))
|
|
68
|
+
return tableInfo(["Z_PK", "ZISPINNED"]);
|
|
69
|
+
return JSON.stringify({ pinned: 1 });
|
|
70
|
+
});
|
|
71
|
+
const result = getNoteMetadata(NOTE_ID);
|
|
72
|
+
expect(result.metadata).toEqual({ pinned: true });
|
|
73
|
+
// The SELECT must not reference a column the schema lacks.
|
|
74
|
+
const selectCall = mockExecFileSync.mock.calls.find((c) => queryOf(c).includes("json_object"));
|
|
75
|
+
expect(selectCall).toBeDefined();
|
|
76
|
+
expect(queryOf(selectCall)).toContain("ZISPINNED");
|
|
77
|
+
expect(queryOf(selectCall)).not.toContain("ZSNIPPET");
|
|
78
|
+
});
|
|
79
|
+
it("returns not_found when no row matches the primary key", () => {
|
|
80
|
+
mockExecFileSync.mockImplementation((_cmd, args) => {
|
|
81
|
+
const query = args[2];
|
|
82
|
+
if (query.includes("table_info"))
|
|
83
|
+
return tableInfo(["Z_PK", "ZISPINNED"]);
|
|
84
|
+
return "";
|
|
85
|
+
});
|
|
86
|
+
const result = getNoteMetadata(NOTE_ID);
|
|
87
|
+
expect(result.metadata).toBeNull();
|
|
88
|
+
expect(result.error).toBe("not_found");
|
|
89
|
+
});
|
|
90
|
+
it("classifies a Full Disk Access denial", () => {
|
|
91
|
+
mockExecFileSync.mockImplementation(() => {
|
|
92
|
+
throw new Error("Error: authorization denied");
|
|
93
|
+
});
|
|
94
|
+
const result = getNoteMetadata(NOTE_ID);
|
|
95
|
+
expect(result.metadata).toBeNull();
|
|
96
|
+
expect(result.error).toBe("no_fda");
|
|
97
|
+
expect(result.message).toContain("Full Disk Access");
|
|
98
|
+
});
|
|
99
|
+
it("returns no_fda when the database file is missing", () => {
|
|
100
|
+
mockExistsSync.mockReturnValue(false);
|
|
101
|
+
const result = getNoteMetadata(NOTE_ID);
|
|
102
|
+
expect(result.metadata).toBeNull();
|
|
103
|
+
expect(result.error).toBe("no_fda");
|
|
104
|
+
expect(mockExecFileSync).not.toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
});
|
package/package.json
CHANGED