apple-notes-mcp 1.2.18 → 1.2.19
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 +85 -7
- package/build/index.js +44 -6
- package/build/services/appleNotesManager.js +117 -28
- package/build/services/appleNotesManager.test.js +131 -0
- package/build/utils/checklistParser.js +259 -0
- package/build/utils/checklistParser.test.js +230 -0
- package/build/utils/protobuf.js +151 -0
- package/build/utils/protobuf.test.js +138 -0
- package/package.json +2 -2
|
@@ -17,8 +17,14 @@ import { AppleNotesManager, escapeForAppleScript, escapeHtmlForAppleScript, pars
|
|
|
17
17
|
vi.mock("@/utils/applescript.js", () => ({
|
|
18
18
|
executeAppleScript: vi.fn(),
|
|
19
19
|
}));
|
|
20
|
+
// Mock the checklist parser to avoid SQLite access during tests
|
|
21
|
+
vi.mock("@/utils/checklistParser.js", () => ({
|
|
22
|
+
getChecklistItems: vi.fn().mockReturnValue({ items: null }),
|
|
23
|
+
}));
|
|
20
24
|
import { executeAppleScript } from "../utils/applescript.js";
|
|
21
25
|
const mockExecuteAppleScript = vi.mocked(executeAppleScript);
|
|
26
|
+
import { getChecklistItems } from "../utils/checklistParser.js";
|
|
27
|
+
const mockGetChecklistItems = vi.mocked(getChecklistItems);
|
|
22
28
|
// =============================================================================
|
|
23
29
|
// Text Escaping Tests
|
|
24
30
|
// =============================================================================
|
|
@@ -261,6 +267,37 @@ describe("AppleNotesManager", () => {
|
|
|
261
267
|
const result = manager.createNote("Tagged Note", "Content", ["work", "urgent"]);
|
|
262
268
|
expect(result?.tags).toEqual(["work", "urgent"]);
|
|
263
269
|
});
|
|
270
|
+
it("uses escapeHtmlForAppleScript when format is html", () => {
|
|
271
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
272
|
+
success: true,
|
|
273
|
+
output: "note id x-coredata://12345/ICNote/p200",
|
|
274
|
+
});
|
|
275
|
+
const htmlContent = "<h2>Heading</h2><div>Body text</div>";
|
|
276
|
+
const result = manager.createNote("HTML Note", htmlContent, [], undefined, undefined, "html");
|
|
277
|
+
expect(result).not.toBeNull();
|
|
278
|
+
// HTML tags should NOT be entity-encoded — they should pass through to AppleScript
|
|
279
|
+
// escapeHtmlForAppleScript only escapes \ and ", not HTML tags
|
|
280
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("<h2>Heading</h2><div>Body text</div>"));
|
|
281
|
+
});
|
|
282
|
+
it("uses escapeForAppleScript when format is plaintext (default)", () => {
|
|
283
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
284
|
+
success: true,
|
|
285
|
+
output: "note id x-coredata://12345/ICNote/p201",
|
|
286
|
+
});
|
|
287
|
+
const result = manager.createNote("Plain Note", "Simple text with\nnewline");
|
|
288
|
+
expect(result).not.toBeNull();
|
|
289
|
+
// Default plaintext: newlines become <br>
|
|
290
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("Simple text with<br>newline"));
|
|
291
|
+
});
|
|
292
|
+
it("escapes double quotes in html format for AppleScript safety", () => {
|
|
293
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
294
|
+
success: true,
|
|
295
|
+
output: "note id x-coredata://12345/ICNote/p202",
|
|
296
|
+
});
|
|
297
|
+
manager.createNote("Quote Test", '<div class="test">Content</div>', [], undefined, undefined, "html");
|
|
298
|
+
// Double quotes must be escaped for AppleScript string embedding
|
|
299
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('<div class=\\"test\\">Content</div>'));
|
|
300
|
+
});
|
|
264
301
|
});
|
|
265
302
|
// ---------------------------------------------------------------------------
|
|
266
303
|
// Note Search
|
|
@@ -606,6 +643,67 @@ describe("AppleNotesManager", () => {
|
|
|
606
643
|
manager.updateNote("Old Title", "Brand New Title", "Content");
|
|
607
644
|
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("<div>Brand New Title</div>"));
|
|
608
645
|
});
|
|
646
|
+
it("uses HTML content directly when format is html", () => {
|
|
647
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
648
|
+
success: true,
|
|
649
|
+
output: "",
|
|
650
|
+
});
|
|
651
|
+
// Use content with & — if escapeForAppleScript were accidentally used,
|
|
652
|
+
// the & in & would become &amp;, causing this assertion to fail.
|
|
653
|
+
const htmlContent = "<h1>Title</h1><div>A & B</div>";
|
|
654
|
+
const result = manager.updateNote("Old Title", undefined, htmlContent, undefined, "html");
|
|
655
|
+
expect(result).toBe(true);
|
|
656
|
+
// In HTML mode: content is used as-is, no <div> wrapper added
|
|
657
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining(`to "${htmlContent}"`));
|
|
658
|
+
});
|
|
659
|
+
it("does not wrap HTML content in div tags when format is html", () => {
|
|
660
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
661
|
+
success: true,
|
|
662
|
+
output: "",
|
|
663
|
+
});
|
|
664
|
+
manager.updateNote("Old Title", undefined, "<h1>My Title</h1><div>Content</div>", undefined, "html");
|
|
665
|
+
// Should NOT contain the <div>Old Title</div> wrapper
|
|
666
|
+
expect(mockExecuteAppleScript).not.toHaveBeenCalledWith(expect.stringContaining("<div>Old Title</div>"));
|
|
667
|
+
});
|
|
668
|
+
it("still wraps in div tags when format is plaintext (default)", () => {
|
|
669
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
670
|
+
success: true,
|
|
671
|
+
output: "",
|
|
672
|
+
});
|
|
673
|
+
manager.updateNote("My Title", undefined, "Plain content");
|
|
674
|
+
// Default behavior: should have <div> wrapper
|
|
675
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("<div>My Title</div><div>Plain content</div>"));
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
// ---------------------------------------------------------------------------
|
|
679
|
+
// Note Update by ID
|
|
680
|
+
// ---------------------------------------------------------------------------
|
|
681
|
+
describe("updateNoteById", () => {
|
|
682
|
+
it("uses HTML content directly without div wrapping in HTML mode", () => {
|
|
683
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
684
|
+
success: true,
|
|
685
|
+
output: "",
|
|
686
|
+
});
|
|
687
|
+
const htmlContent = "<h1>My Title</h1><div>A & B</div>";
|
|
688
|
+
const result = manager.updateNoteById("x-coredata://abc/123", undefined, htmlContent, "html");
|
|
689
|
+
expect(result).toBe(true);
|
|
690
|
+
// HTML mode: content passed directly, no <div> wrapper
|
|
691
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining(`to "${htmlContent}"`));
|
|
692
|
+
// Should NOT contain the div-wrapped title pattern
|
|
693
|
+
expect(mockExecuteAppleScript).not.toHaveBeenCalledWith(expect.stringContaining("<div>My Title</div>"));
|
|
694
|
+
});
|
|
695
|
+
it("does not call getNoteById in HTML mode (skips lookup optimization)", () => {
|
|
696
|
+
mockExecuteAppleScript.mockReturnValue({
|
|
697
|
+
success: true,
|
|
698
|
+
output: "",
|
|
699
|
+
});
|
|
700
|
+
const htmlContent = "<h1>Title</h1><div>Body</div>";
|
|
701
|
+
manager.updateNoteById("x-coredata://abc/456", undefined, htmlContent, "html");
|
|
702
|
+
// In HTML mode, getNoteById should NOT be called (it would trigger
|
|
703
|
+
// an additional executeAppleScript call). Only one call should happen:
|
|
704
|
+
// the update itself.
|
|
705
|
+
expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1);
|
|
706
|
+
});
|
|
609
707
|
});
|
|
610
708
|
// ---------------------------------------------------------------------------
|
|
611
709
|
// Note Listing
|
|
@@ -1242,5 +1340,38 @@ describe("AppleNotesManager", () => {
|
|
|
1242
1340
|
const markdown = manager.getNoteMarkdownById("x-coredata://invalid");
|
|
1243
1341
|
expect(markdown).toBe("");
|
|
1244
1342
|
});
|
|
1343
|
+
it("enriches markdown with checklist state when available", () => {
|
|
1344
|
+
mockExecuteAppleScript.mockReturnValueOnce({
|
|
1345
|
+
success: true,
|
|
1346
|
+
output: "<ul><li>Buy milk</li><li>Walk dog</li><li>Send email</li></ul>",
|
|
1347
|
+
});
|
|
1348
|
+
mockGetChecklistItems.mockReturnValueOnce({
|
|
1349
|
+
items: [
|
|
1350
|
+
{ text: "Buy milk", done: true },
|
|
1351
|
+
{ text: "Walk dog", done: false },
|
|
1352
|
+
{ text: "Send email", done: true },
|
|
1353
|
+
],
|
|
1354
|
+
});
|
|
1355
|
+
const markdown = manager.getNoteMarkdownById("x-coredata://ABC/ICNote/p123");
|
|
1356
|
+
expect(markdown).toMatch(/-\s+\[x\] Buy milk/);
|
|
1357
|
+
expect(markdown).toMatch(/-\s+\[ \] Walk dog/);
|
|
1358
|
+
expect(markdown).toMatch(/-\s+\[x\] Send email/);
|
|
1359
|
+
});
|
|
1360
|
+
it("returns plain markdown when checklist state is unavailable", () => {
|
|
1361
|
+
mockExecuteAppleScript.mockReturnValueOnce({
|
|
1362
|
+
success: true,
|
|
1363
|
+
output: "<ul><li>Item 1</li><li>Item 2</li></ul>",
|
|
1364
|
+
});
|
|
1365
|
+
mockGetChecklistItems.mockReturnValueOnce({
|
|
1366
|
+
items: null,
|
|
1367
|
+
error: "no_fda",
|
|
1368
|
+
message: "Full Disk Access required",
|
|
1369
|
+
});
|
|
1370
|
+
const markdown = manager.getNoteMarkdownById("x-coredata://ABC/ICNote/p456");
|
|
1371
|
+
expect(markdown).toMatch(/-\s+Item 1/);
|
|
1372
|
+
expect(markdown).toMatch(/-\s+Item 2/);
|
|
1373
|
+
expect(markdown).not.toContain("[x]");
|
|
1374
|
+
expect(markdown).not.toContain("[ ]");
|
|
1375
|
+
});
|
|
1245
1376
|
});
|
|
1246
1377
|
});
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apple Notes Checklist State Parser
|
|
3
|
+
*
|
|
4
|
+
* Reads checklist done/undone state by querying the NoteStore SQLite database
|
|
5
|
+
* and decoding the protobuf-encoded note content. This bypasses the AppleScript
|
|
6
|
+
* limitation where `body of note` strips checklist state information.
|
|
7
|
+
*
|
|
8
|
+
* Data flow:
|
|
9
|
+
* 1. Query NoteStore.sqlite for the gzipped protobuf blob (ZICNOTEDATA.ZDATA)
|
|
10
|
+
* 2. Decompress with gzip
|
|
11
|
+
* 3. Decode protobuf to extract text and attribute runs
|
|
12
|
+
* 4. Walk attribute runs to identify checklist items and their done state
|
|
13
|
+
*
|
|
14
|
+
* Protobuf field path:
|
|
15
|
+
* Document (root) → field 2 (Note) → field 3 (Note body)
|
|
16
|
+
* → field 2 (note_text: plain text)
|
|
17
|
+
* → field 5 (attribute_run: repeated styling runs)
|
|
18
|
+
* → field 1 (length)
|
|
19
|
+
* → field 2 (paragraph_style)
|
|
20
|
+
* → field 1 (style_type: 103 = checklist)
|
|
21
|
+
* → field 5 (checklist)
|
|
22
|
+
* → field 2 (done: 0 = unchecked, 1 = checked)
|
|
23
|
+
*
|
|
24
|
+
* @module utils/checklistParser
|
|
25
|
+
* @see https://github.com/sweetrb/apple-notes-mcp/issues/2
|
|
26
|
+
*/
|
|
27
|
+
import { execSync } from "child_process";
|
|
28
|
+
import * as zlib from "zlib";
|
|
29
|
+
import * as fs from "fs";
|
|
30
|
+
import * as path from "path";
|
|
31
|
+
import * as os from "os";
|
|
32
|
+
import { decodeMessage, getField, getFields, varintValue, stringValue, embeddedMessage, } from "../utils/protobuf.js";
|
|
33
|
+
/** Style type value for checklist items in Apple Notes protobuf format. */
|
|
34
|
+
const CHECKLIST_STYLE_TYPE = 103;
|
|
35
|
+
const NOTES_DB_PATH = path.join(os.homedir(), "Library/Group Containers/group.com.apple.notes/NoteStore.sqlite");
|
|
36
|
+
/**
|
|
37
|
+
* Checks whether the NoteStore database is accessible (Full Disk Access).
|
|
38
|
+
*
|
|
39
|
+
* @returns true if the database file exists and can be read
|
|
40
|
+
*/
|
|
41
|
+
export function hasFullDiskAccess() {
|
|
42
|
+
try {
|
|
43
|
+
if (!fs.existsSync(NOTES_DB_PATH))
|
|
44
|
+
return false;
|
|
45
|
+
// Try to open the database with a simple query
|
|
46
|
+
execSync(`sqlite3 -readonly "${NOTES_DB_PATH}" "SELECT 1;"`, {
|
|
47
|
+
encoding: "utf8",
|
|
48
|
+
timeout: 3000,
|
|
49
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
50
|
+
});
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Queries the NoteStore SQLite database for a note's raw ZDATA blob.
|
|
59
|
+
*
|
|
60
|
+
* Uses the note's CoreData identifier to find the corresponding protobuf data.
|
|
61
|
+
* The identifier is extracted from the full CoreData URL format:
|
|
62
|
+
* x-coredata://DEVICE-UUID/ICNote/pXXXX → pXXXX
|
|
63
|
+
*
|
|
64
|
+
* @param noteId - CoreData URL identifier (e.g., "x-coredata://ABC/ICNote/p123")
|
|
65
|
+
* @returns Object with hex data or error classification
|
|
66
|
+
*/
|
|
67
|
+
function queryNoteData(noteId) {
|
|
68
|
+
// Extract the primary key suffix (e.g., "p123" from "x-coredata://ABC/ICNote/p123")
|
|
69
|
+
const pkMatch = noteId.match(/\/p(\d+)$/);
|
|
70
|
+
if (!pkMatch) {
|
|
71
|
+
console.error(`Invalid note ID format: ${noteId}`);
|
|
72
|
+
return { hex: null, error: "invalid_id" };
|
|
73
|
+
}
|
|
74
|
+
const pk = pkMatch[1];
|
|
75
|
+
// Query for the gzipped protobuf data, output as hex for safe transport
|
|
76
|
+
const query = `SELECT hex(nd.ZDATA) FROM ZICNOTEDATA nd JOIN ZICCLOUDSYNCINGOBJECT n ON nd.ZNOTE = n.Z_PK WHERE n.Z_PK = ${pk};`;
|
|
77
|
+
try {
|
|
78
|
+
const result = execSync(`sqlite3 -readonly "${NOTES_DB_PATH}" "${query}"`, {
|
|
79
|
+
encoding: "utf8",
|
|
80
|
+
timeout: 5000,
|
|
81
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
82
|
+
});
|
|
83
|
+
const hex = result.trim();
|
|
84
|
+
if (!hex)
|
|
85
|
+
return { hex: null };
|
|
86
|
+
return { hex };
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
90
|
+
console.error(`Failed to query NoteStore database: ${message}`);
|
|
91
|
+
// Detect Full Disk Access denial
|
|
92
|
+
if (message.includes("authorization denied") || message.includes("unable to open database")) {
|
|
93
|
+
return { hex: null, error: "no_fda" };
|
|
94
|
+
}
|
|
95
|
+
return { hex: null };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Converts a hex string to a Uint8Array.
|
|
100
|
+
*/
|
|
101
|
+
function hexToBytes(hex) {
|
|
102
|
+
const bytes = new Uint8Array(hex.length / 2);
|
|
103
|
+
for (let i = 0; i < hex.length; i += 2) {
|
|
104
|
+
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
105
|
+
}
|
|
106
|
+
return bytes;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Extracts checklist items from a protobuf-encoded note.
|
|
110
|
+
*
|
|
111
|
+
* Navigates the protobuf structure:
|
|
112
|
+
* Document → Note (field 2) → Note body (field 3)
|
|
113
|
+
* → note_text (field 2): plain text with \n line separators
|
|
114
|
+
* → attribute_run (field 5): repeated, sequential styling runs
|
|
115
|
+
*
|
|
116
|
+
* Walks attribute runs sequentially, tracking character position in the plain
|
|
117
|
+
* text. When a run has style_type == 103 (checklist), extracts the line text
|
|
118
|
+
* and done state.
|
|
119
|
+
*
|
|
120
|
+
* @param data - Decompressed protobuf bytes
|
|
121
|
+
* @returns Array of checklist items, or null if parsing fails
|
|
122
|
+
*/
|
|
123
|
+
function parseChecklistFromProtobuf(data) {
|
|
124
|
+
try {
|
|
125
|
+
// Document root
|
|
126
|
+
const docFields = decodeMessage(data);
|
|
127
|
+
// Field 2 = Note (Version/Document wrapper)
|
|
128
|
+
const noteWrapper = getField(docFields, 2);
|
|
129
|
+
const noteWrapperFields = embeddedMessage(noteWrapper);
|
|
130
|
+
if (!noteWrapperFields)
|
|
131
|
+
return null;
|
|
132
|
+
// Field 3 = Note body (the actual content)
|
|
133
|
+
const noteBody = getField(noteWrapperFields, 3);
|
|
134
|
+
const noteBodyFields = embeddedMessage(noteBody);
|
|
135
|
+
if (!noteBodyFields)
|
|
136
|
+
return null;
|
|
137
|
+
// Field 2 = note_text (plain text content)
|
|
138
|
+
const noteTextField = getField(noteBodyFields, 2);
|
|
139
|
+
const noteText = stringValue(noteTextField);
|
|
140
|
+
if (!noteText)
|
|
141
|
+
return null;
|
|
142
|
+
// Field 5 = attribute_run (repeated)
|
|
143
|
+
const attributeRuns = getFields(noteBodyFields, 5);
|
|
144
|
+
if (attributeRuns.length === 0)
|
|
145
|
+
return null;
|
|
146
|
+
// Split text into lines for mapping
|
|
147
|
+
const lines = noteText.split("\n");
|
|
148
|
+
// Walk attribute runs, tracking position in the text
|
|
149
|
+
const items = [];
|
|
150
|
+
let charPos = 0;
|
|
151
|
+
// Track which lines we've already added (multiple runs can cover the same line)
|
|
152
|
+
const seenLines = new Set();
|
|
153
|
+
for (const run of attributeRuns) {
|
|
154
|
+
const runFields = embeddedMessage(run);
|
|
155
|
+
if (!runFields)
|
|
156
|
+
continue;
|
|
157
|
+
// Field 1 = length (character count this run covers)
|
|
158
|
+
const lengthField = getField(runFields, 1);
|
|
159
|
+
const runLength = varintValue(lengthField) ?? 0;
|
|
160
|
+
// Field 2 = paragraph_style
|
|
161
|
+
const paragraphStyle = getField(runFields, 2);
|
|
162
|
+
const styleFields = embeddedMessage(paragraphStyle);
|
|
163
|
+
if (styleFields) {
|
|
164
|
+
// Field 1 = style_type
|
|
165
|
+
const styleType = varintValue(getField(styleFields, 1));
|
|
166
|
+
if (styleType === CHECKLIST_STYLE_TYPE) {
|
|
167
|
+
// Field 5 = checklist info
|
|
168
|
+
const checklistField = getField(styleFields, 5);
|
|
169
|
+
const checklistFields = embeddedMessage(checklistField);
|
|
170
|
+
// Field 2 = done (0 = unchecked, 1 = checked)
|
|
171
|
+
const done = checklistFields ? (varintValue(getField(checklistFields, 2)) ?? 0) : 0;
|
|
172
|
+
// Find which line this position corresponds to
|
|
173
|
+
let lineStart = 0;
|
|
174
|
+
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
175
|
+
const lineEnd = lineStart + lines[lineIdx].length;
|
|
176
|
+
if (charPos >= lineStart && charPos < lineEnd + 1 && !seenLines.has(lineIdx)) {
|
|
177
|
+
seenLines.add(lineIdx);
|
|
178
|
+
items.push({
|
|
179
|
+
text: lines[lineIdx],
|
|
180
|
+
done: done === 1,
|
|
181
|
+
});
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
lineStart = lineEnd + 1; // +1 for the \n
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
charPos += runLength;
|
|
189
|
+
}
|
|
190
|
+
return items;
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
194
|
+
console.error(`Failed to parse protobuf checklist data: ${message}`);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Gets the checklist state for a note by its CoreData ID.
|
|
200
|
+
*
|
|
201
|
+
* This reads directly from the NoteStore SQLite database, bypassing
|
|
202
|
+
* AppleScript's limitation of stripping checklist state from `body of note`.
|
|
203
|
+
*
|
|
204
|
+
* Requires Full Disk Access to read the Notes database.
|
|
205
|
+
*
|
|
206
|
+
* @param noteId - CoreData URL identifier (e.g., "x-coredata://ABC/ICNote/p123")
|
|
207
|
+
* @returns Structured result with items, error type, and message
|
|
208
|
+
*/
|
|
209
|
+
export function getChecklistItems(noteId) {
|
|
210
|
+
// Query the database for raw note data
|
|
211
|
+
const { hex: hexData, error: queryError } = queryNoteData(noteId);
|
|
212
|
+
if (queryError === "invalid_id") {
|
|
213
|
+
return {
|
|
214
|
+
items: null,
|
|
215
|
+
error: "invalid_id",
|
|
216
|
+
message: `Invalid note ID format: "${noteId}". Expected format: x-coredata://UUID/ICNote/pNNN`,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
if (queryError === "no_fda") {
|
|
220
|
+
return {
|
|
221
|
+
items: null,
|
|
222
|
+
error: "no_fda",
|
|
223
|
+
message: "Full Disk Access is required to read checklist state. " +
|
|
224
|
+
"Grant access in System Settings > Privacy & Security > Full Disk Access, " +
|
|
225
|
+
"then add and restart this application.",
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
if (!hexData) {
|
|
229
|
+
return {
|
|
230
|
+
items: null,
|
|
231
|
+
error: "no_checklists",
|
|
232
|
+
message: "No data found for this note in the database.",
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
// Convert hex to bytes and decompress
|
|
236
|
+
const compressedData = hexToBytes(hexData);
|
|
237
|
+
let decompressed;
|
|
238
|
+
try {
|
|
239
|
+
decompressed = zlib.gunzipSync(compressedData);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
console.error("Failed to decompress note data — may not be gzip format");
|
|
243
|
+
return {
|
|
244
|
+
items: null,
|
|
245
|
+
error: "parse_error",
|
|
246
|
+
message: "Failed to decompress note data.",
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
// Parse protobuf and extract checklist items
|
|
250
|
+
const items = parseChecklistFromProtobuf(new Uint8Array(decompressed));
|
|
251
|
+
if (!items || items.length === 0) {
|
|
252
|
+
return {
|
|
253
|
+
items: null,
|
|
254
|
+
error: "no_checklists",
|
|
255
|
+
message: "This note does not contain any checklist items.",
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
return { items };
|
|
259
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the Apple Notes checklist state parser.
|
|
3
|
+
*
|
|
4
|
+
* These tests mock the SQLite database access and test the protobuf
|
|
5
|
+
* parsing logic with realistic test fixtures.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
8
|
+
import * as zlib from "zlib";
|
|
9
|
+
import { getChecklistItems, hasFullDiskAccess } from "./checklistParser.js";
|
|
10
|
+
// Mock child_process to avoid actual database access
|
|
11
|
+
vi.mock("child_process", () => ({
|
|
12
|
+
execSync: vi.fn(),
|
|
13
|
+
spawnSync: vi.fn(() => ({ error: null })),
|
|
14
|
+
}));
|
|
15
|
+
// Mock fs for database existence checks
|
|
16
|
+
vi.mock("fs", () => ({
|
|
17
|
+
existsSync: vi.fn(() => true),
|
|
18
|
+
statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
|
|
19
|
+
}));
|
|
20
|
+
import { execSync } from "child_process";
|
|
21
|
+
import { existsSync } from "fs";
|
|
22
|
+
const mockExecSync = vi.mocked(execSync);
|
|
23
|
+
const mockExistsSync = vi.mocked(existsSync);
|
|
24
|
+
/**
|
|
25
|
+
* Builds a minimal Apple Notes protobuf structure with checklist items.
|
|
26
|
+
*
|
|
27
|
+
* Structure:
|
|
28
|
+
* Document (root)
|
|
29
|
+
* field 2 (Note wrapper)
|
|
30
|
+
* field 3 (Note body)
|
|
31
|
+
* field 2 (note_text: plain text)
|
|
32
|
+
* field 5 (attribute_run) - repeated
|
|
33
|
+
*/
|
|
34
|
+
function buildChecklistProtobuf(items) {
|
|
35
|
+
// Helper to encode a varint
|
|
36
|
+
function encodeVarint(value) {
|
|
37
|
+
const bytes = [];
|
|
38
|
+
while (value > 0x7f) {
|
|
39
|
+
bytes.push((value & 0x7f) | 0x80);
|
|
40
|
+
value >>>= 7;
|
|
41
|
+
}
|
|
42
|
+
bytes.push(value & 0x7f);
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
// Helper to encode a tag
|
|
46
|
+
function encodeTag(fieldNumber, wireType) {
|
|
47
|
+
return encodeVarint((fieldNumber << 3) | wireType);
|
|
48
|
+
}
|
|
49
|
+
// Helper to wrap bytes as a length-delimited field
|
|
50
|
+
function lengthDelimited(fieldNumber, data) {
|
|
51
|
+
const bytes = data instanceof Uint8Array ? Array.from(data) : data;
|
|
52
|
+
return [...encodeTag(fieldNumber, 2), ...encodeVarint(bytes.length), ...bytes];
|
|
53
|
+
}
|
|
54
|
+
// Helper to encode a varint field
|
|
55
|
+
function varintField(fieldNumber, value) {
|
|
56
|
+
return [...encodeTag(fieldNumber, 0), ...encodeVarint(value)];
|
|
57
|
+
}
|
|
58
|
+
// Build the plain text: "Title\nitem1\nitem2\n..."
|
|
59
|
+
const textLines = ["Checklist Note", ...items.map((i) => i.text)];
|
|
60
|
+
const noteText = textLines.join("\n");
|
|
61
|
+
const encoder = new TextEncoder();
|
|
62
|
+
// Build attribute runs
|
|
63
|
+
// First run: title line (not a checklist)
|
|
64
|
+
const titleLength = textLines[0].length + 1; // +1 for \n
|
|
65
|
+
const titleRun = lengthDelimited(5, [
|
|
66
|
+
...varintField(1, titleLength), // length
|
|
67
|
+
// No paragraph_style with checklist — just a regular paragraph
|
|
68
|
+
]);
|
|
69
|
+
// Checklist runs
|
|
70
|
+
const checklistRuns = [];
|
|
71
|
+
for (const item of items) {
|
|
72
|
+
const runLength = item.text.length + 1; // +1 for \n (or end of text)
|
|
73
|
+
// Build checklist info: field 2 = done
|
|
74
|
+
const checklistInfo = lengthDelimited(5, varintField(2, item.done ? 1 : 0));
|
|
75
|
+
// Build paragraph_style: field 1 = 103 (checklist), field 5 = checklist info
|
|
76
|
+
const paragraphStyle = lengthDelimited(2, [...varintField(1, 103), ...checklistInfo]);
|
|
77
|
+
// Build attribute run: field 1 = length, field 2 = paragraph_style
|
|
78
|
+
const run = lengthDelimited(5, [...varintField(1, runLength), ...paragraphStyle]);
|
|
79
|
+
checklistRuns.push(...run);
|
|
80
|
+
}
|
|
81
|
+
// Build note body (field 3):
|
|
82
|
+
// field 2 = note_text, field 5 = attribute_runs (already encoded above)
|
|
83
|
+
const noteTextField = lengthDelimited(2, encoder.encode(noteText));
|
|
84
|
+
const noteBody = lengthDelimited(3, [...noteTextField, ...titleRun, ...checklistRuns]);
|
|
85
|
+
// Build note wrapper (field 2 of document)
|
|
86
|
+
const noteWrapper = lengthDelimited(2, noteBody);
|
|
87
|
+
return new Uint8Array(noteWrapper);
|
|
88
|
+
}
|
|
89
|
+
describe("hasFullDiskAccess", () => {
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
vi.clearAllMocks();
|
|
92
|
+
});
|
|
93
|
+
it("returns true when database is accessible", () => {
|
|
94
|
+
mockExistsSync.mockReturnValue(true);
|
|
95
|
+
mockExecSync.mockReturnValue("1\n");
|
|
96
|
+
expect(hasFullDiskAccess()).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
it("returns false when database file does not exist", () => {
|
|
99
|
+
mockExistsSync.mockReturnValue(false);
|
|
100
|
+
expect(hasFullDiskAccess()).toBe(false);
|
|
101
|
+
});
|
|
102
|
+
it("returns false when sqlite3 query fails", () => {
|
|
103
|
+
mockExistsSync.mockReturnValue(true);
|
|
104
|
+
mockExecSync.mockImplementation(() => {
|
|
105
|
+
throw new Error("authorization denied");
|
|
106
|
+
});
|
|
107
|
+
expect(hasFullDiskAccess()).toBe(false);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe("getChecklistItems", () => {
|
|
111
|
+
beforeEach(() => {
|
|
112
|
+
vi.clearAllMocks();
|
|
113
|
+
});
|
|
114
|
+
it("returns error for invalid note ID format", () => {
|
|
115
|
+
const result = getChecklistItems("invalid-id");
|
|
116
|
+
expect(result.items).toBeNull();
|
|
117
|
+
expect(result.error).toBe("invalid_id");
|
|
118
|
+
});
|
|
119
|
+
it("returns no_checklists when database query returns empty", () => {
|
|
120
|
+
mockExecSync.mockReturnValue("");
|
|
121
|
+
const result = getChecklistItems("x-coredata://ABC/ICNote/p123");
|
|
122
|
+
expect(result.items).toBeNull();
|
|
123
|
+
expect(result.error).toBe("no_checklists");
|
|
124
|
+
});
|
|
125
|
+
it("parses checklist with mixed done/undone items", () => {
|
|
126
|
+
const items = [
|
|
127
|
+
{ text: "Buy milk", done: true },
|
|
128
|
+
{ text: "Walk dog", done: false },
|
|
129
|
+
{ text: "Send email", done: true },
|
|
130
|
+
];
|
|
131
|
+
const protobuf = buildChecklistProtobuf(items);
|
|
132
|
+
const compressed = zlib.gzipSync(Buffer.from(protobuf));
|
|
133
|
+
const hex = Buffer.from(compressed).toString("hex").toUpperCase();
|
|
134
|
+
mockExecSync.mockReturnValue((hex + "\n"));
|
|
135
|
+
const result = getChecklistItems("x-coredata://ABC/ICNote/p123");
|
|
136
|
+
expect(result.items).not.toBeNull();
|
|
137
|
+
expect(result.items).toHaveLength(3);
|
|
138
|
+
expect(result.items[0]).toEqual({ text: "Buy milk", done: true });
|
|
139
|
+
expect(result.items[1]).toEqual({ text: "Walk dog", done: false });
|
|
140
|
+
expect(result.items[2]).toEqual({ text: "Send email", done: true });
|
|
141
|
+
expect(result.error).toBeUndefined();
|
|
142
|
+
});
|
|
143
|
+
it("parses checklist with all items unchecked", () => {
|
|
144
|
+
const items = [
|
|
145
|
+
{ text: "Task A", done: false },
|
|
146
|
+
{ text: "Task B", done: false },
|
|
147
|
+
];
|
|
148
|
+
const protobuf = buildChecklistProtobuf(items);
|
|
149
|
+
const compressed = zlib.gzipSync(Buffer.from(protobuf));
|
|
150
|
+
const hex = Buffer.from(compressed).toString("hex").toUpperCase();
|
|
151
|
+
mockExecSync.mockReturnValue((hex + "\n"));
|
|
152
|
+
const result = getChecklistItems("x-coredata://ABC/ICNote/p456");
|
|
153
|
+
expect(result.items).not.toBeNull();
|
|
154
|
+
expect(result.items).toHaveLength(2);
|
|
155
|
+
expect(result.items.every((i) => !i.done)).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
it("parses checklist with all items checked", () => {
|
|
158
|
+
const items = [
|
|
159
|
+
{ text: "Done 1", done: true },
|
|
160
|
+
{ text: "Done 2", done: true },
|
|
161
|
+
];
|
|
162
|
+
const protobuf = buildChecklistProtobuf(items);
|
|
163
|
+
const compressed = zlib.gzipSync(Buffer.from(protobuf));
|
|
164
|
+
const hex = Buffer.from(compressed).toString("hex").toUpperCase();
|
|
165
|
+
mockExecSync.mockReturnValue((hex + "\n"));
|
|
166
|
+
const result = getChecklistItems("x-coredata://ABC/ICNote/p789");
|
|
167
|
+
expect(result.items).not.toBeNull();
|
|
168
|
+
expect(result.items).toHaveLength(2);
|
|
169
|
+
expect(result.items.every((i) => i.done)).toBe(true);
|
|
170
|
+
});
|
|
171
|
+
it("returns null when note has no checklist items", () => {
|
|
172
|
+
// Build a protobuf with no checklist style_type
|
|
173
|
+
const encoder = new TextEncoder();
|
|
174
|
+
// Minimal note with just text, no checklist runs
|
|
175
|
+
// Field 2 (note text) = "Just a regular note"
|
|
176
|
+
const noteText = encoder.encode("Just a regular note");
|
|
177
|
+
const noteTextField = [0x12, noteText.length, ...noteText]; // field 2, length-delimited
|
|
178
|
+
// A non-checklist attribute run (style_type = 0, regular paragraph)
|
|
179
|
+
const regularRun = [
|
|
180
|
+
0x2a, // field 5, length-delimited (attribute_run)
|
|
181
|
+
0x04, // length 4
|
|
182
|
+
0x08,
|
|
183
|
+
0x13, // field 1 (length) = 19
|
|
184
|
+
0x12,
|
|
185
|
+
0x00, // field 2 (paragraph_style) = empty
|
|
186
|
+
];
|
|
187
|
+
const noteBody = [
|
|
188
|
+
0x1a, // field 3, length-delimited
|
|
189
|
+
noteTextField.length + regularRun.length,
|
|
190
|
+
...noteTextField,
|
|
191
|
+
...regularRun,
|
|
192
|
+
];
|
|
193
|
+
const noteWrapper = [0x12, noteBody.length, ...noteBody]; // field 2
|
|
194
|
+
const compressed = zlib.gzipSync(Buffer.from(new Uint8Array(noteWrapper)));
|
|
195
|
+
const hex = Buffer.from(compressed).toString("hex").toUpperCase();
|
|
196
|
+
mockExecSync.mockReturnValue((hex + "\n"));
|
|
197
|
+
const result = getChecklistItems("x-coredata://ABC/ICNote/p100");
|
|
198
|
+
expect(result.items).toBeNull();
|
|
199
|
+
expect(result.error).toBe("no_checklists");
|
|
200
|
+
});
|
|
201
|
+
it("returns null when sqlite3 command fails", () => {
|
|
202
|
+
mockExecSync.mockImplementation(() => {
|
|
203
|
+
throw new Error("database is locked");
|
|
204
|
+
});
|
|
205
|
+
const result = getChecklistItems("x-coredata://ABC/ICNote/p123");
|
|
206
|
+
expect(result.items).toBeNull();
|
|
207
|
+
});
|
|
208
|
+
it("returns no_fda error when authorization is denied", () => {
|
|
209
|
+
mockExecSync.mockImplementation(() => {
|
|
210
|
+
throw new Error("unable to open database: authorization denied");
|
|
211
|
+
});
|
|
212
|
+
const result = getChecklistItems("x-coredata://ABC/ICNote/p123");
|
|
213
|
+
expect(result.items).toBeNull();
|
|
214
|
+
expect(result.error).toBe("no_fda");
|
|
215
|
+
expect(result.message).toContain("Full Disk Access");
|
|
216
|
+
expect(result.message).toContain("System Settings");
|
|
217
|
+
});
|
|
218
|
+
it("returns parse_error for non-gzip data", () => {
|
|
219
|
+
// Return valid hex that isn't gzip
|
|
220
|
+
mockExecSync.mockReturnValue("DEADBEEF\n");
|
|
221
|
+
const result = getChecklistItems("x-coredata://ABC/ICNote/p123");
|
|
222
|
+
expect(result.items).toBeNull();
|
|
223
|
+
expect(result.error).toBe("parse_error");
|
|
224
|
+
});
|
|
225
|
+
it("extracts correct primary key from note ID", () => {
|
|
226
|
+
mockExecSync.mockReturnValue("");
|
|
227
|
+
getChecklistItems("x-coredata://12345-ABCDE/ICNote/p42");
|
|
228
|
+
expect(mockExecSync).toHaveBeenCalledWith(expect.stringContaining("Z_PK = 42"), expect.any(Object));
|
|
229
|
+
});
|
|
230
|
+
});
|