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.
@@ -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 &amp; — if escapeForAppleScript were accidentally used,
652
+ // the & in &amp; would become &amp;amp;, causing this assertion to fail.
653
+ const htmlContent = "<h1>Title</h1><div>A &amp; 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 &amp; 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
+ });