apple-notes-mcp 2.5.6 → 2.5.8

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.
Files changed (34) hide show
  1. package/README.md +8 -5
  2. package/build/index.js +42669 -1080
  3. package/package.json +3 -3
  4. package/build/index.test.js +0 -446
  5. package/build/services/__fixtures__/notesNormalizedHtml.js +0 -32
  6. package/build/services/appleNotesManager.js +0 -2629
  7. package/build/services/appleNotesManager.test.js +0 -2389
  8. package/build/services/attachmentSave.test.js +0 -85
  9. package/build/services/fileConfig.js +0 -51
  10. package/build/services/fileConfig.test.js +0 -48
  11. package/build/services/notesHtmlMarkdown.test.js +0 -55
  12. package/build/tools/doctor.js +0 -50
  13. package/build/tools/doctor.test.js +0 -42
  14. package/build/tools/resourcesAndPrompts.js +0 -70
  15. package/build/tools/resourcesAndPrompts.test.js +0 -63
  16. package/build/types.js +0 -13
  17. package/build/utils/applescript.js +0 -421
  18. package/build/utils/applescript.test.js +0 -342
  19. package/build/utils/attachmentFs.js +0 -97
  20. package/build/utils/attachmentFs.test.js +0 -69
  21. package/build/utils/checklistParser.js +0 -259
  22. package/build/utils/checklistParser.test.js +0 -230
  23. package/build/utils/contentWarnings.js +0 -44
  24. package/build/utils/contentWarnings.test.js +0 -52
  25. package/build/utils/hashtags.js +0 -56
  26. package/build/utils/hashtags.test.js +0 -45
  27. package/build/utils/jxa.js +0 -139
  28. package/build/utils/jxa.test.js +0 -134
  29. package/build/utils/noteMetadata.js +0 -135
  30. package/build/utils/noteMetadata.test.js +0 -106
  31. package/build/utils/protobuf.js +0 -151
  32. package/build/utils/protobuf.test.js +0 -138
  33. package/build/utils/syncDetection.js +0 -242
  34. package/build/utils/syncDetection.test.js +0 -228
@@ -1,2389 +0,0 @@
1
- /**
2
- * Unit Tests for Apple Notes Manager
3
- *
4
- * These tests verify the AppleNotesManager class and its helper functions.
5
- * The AppleScript execution is mocked to allow testing without macOS.
6
- *
7
- * Test Strategy:
8
- * - Helper functions (escapeForAppleScript, parseAppleScriptDate) are tested
9
- * with various inputs to ensure correct escaping and parsing
10
- * - Manager methods are tested for success/failure paths
11
- * - Script generation is verified by checking for expected AppleScript patterns
12
- */
13
- import { describe, it, expect, vi, beforeEach } from "vitest";
14
- import { AppleNotesManager, escapeForAppleScript, escapeHtmlForAppleScript, buildAppleScriptDateVar, buildFolderReference, splitFolderPath, parseAppleScriptDate, sanitizeId, } from "./appleNotesManager.js";
15
- // Mock the AppleScript execution module
16
- // This prevents actual osascript calls during testing
17
- vi.mock("@/utils/applescript.js", () => ({
18
- executeAppleScript: vi.fn(),
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
- }));
24
- import { executeAppleScript } from "../utils/applescript.js";
25
- const mockExecuteAppleScript = vi.mocked(executeAppleScript);
26
- import { getChecklistItems } from "../utils/checklistParser.js";
27
- const mockGetChecklistItems = vi.mocked(getChecklistItems);
28
- // Result delimiters (#18) — must match appleNotesManager.ts.
29
- // FIELD_SEP (US, \x1f) separates fields within a record;
30
- // RECORD_SEP (RS, \x1e) separates records within a list.
31
- const F = "\x1f";
32
- const R = "\x1e";
33
- // =============================================================================
34
- // Text Escaping Tests
35
- // =============================================================================
36
- describe("escapeForAppleScript", () => {
37
- describe("empty and null handling", () => {
38
- it("returns empty string for empty input", () => {
39
- expect(escapeForAppleScript("")).toBe("");
40
- });
41
- it("returns empty string for null-like input", () => {
42
- // TypeScript prevents actual null, but runtime might have undefined
43
- expect(escapeForAppleScript(undefined)).toBe("");
44
- });
45
- });
46
- describe("single quote handling", () => {
47
- it("preserves single quotes (no escaping needed in AppleScript double-quoted strings)", () => {
48
- // Single quotes don't need escaping inside AppleScript double-quoted strings
49
- const result = escapeForAppleScript("it's working");
50
- expect(result).toBe("it's working");
51
- });
52
- it("handles multiple single quotes", () => {
53
- const result = escapeForAppleScript("Rob's mom's note");
54
- expect(result).toBe("Rob's mom's note");
55
- });
56
- });
57
- describe("double quote escaping (AppleScript strings)", () => {
58
- it("escapes double quotes for AppleScript", () => {
59
- // AppleScript strings: "hello \"quoted\" world"
60
- const result = escapeForAppleScript('say "hello"');
61
- expect(result).toBe('say \\"hello\\"');
62
- });
63
- it("handles mixed quotes", () => {
64
- const result = escapeForAppleScript('He said "it\'s fine"');
65
- expect(result).toBe('He said \\"it\'s fine\\"');
66
- });
67
- });
68
- describe("control character conversion (HTML for Notes.app)", () => {
69
- it("converts newlines to <br> tags", () => {
70
- const result = escapeForAppleScript("line 1\nline 2\nline 3");
71
- expect(result).toBe("line 1<br>line 2<br>line 3");
72
- });
73
- it("converts tabs to <br> tags", () => {
74
- const result = escapeForAppleScript("col1\tcol2\tcol3");
75
- expect(result).toBe("col1<br>col2<br>col3");
76
- });
77
- it("handles mixed control characters", () => {
78
- const result = escapeForAppleScript("row1\tcol2\nrow2\tcol2");
79
- expect(result).toBe("row1<br>col2<br>row2<br>col2");
80
- });
81
- });
82
- describe("complex content", () => {
83
- it("handles real-world note content", () => {
84
- const content = 'John\'s "Meeting Notes"\n- Item 1\n- Item 2';
85
- const result = escapeForAppleScript(content);
86
- expect(result).toBe('John\'s \\"Meeting Notes\\"<br>- Item 1<br>- Item 2');
87
- });
88
- });
89
- describe("unicode and special characters", () => {
90
- it("preserves unicode characters", () => {
91
- const result = escapeForAppleScript("日本語テスト 🎉");
92
- expect(result).toBe("日本語テスト 🎉");
93
- });
94
- it("preserves emoji in content", () => {
95
- const result = escapeForAppleScript("Shopping 🛒\n- Eggs 🥚\n- Milk 🥛");
96
- expect(result).toBe("Shopping 🛒<br>- Eggs 🥚<br>- Milk 🥛");
97
- });
98
- it("handles accented characters", () => {
99
- const result = escapeForAppleScript("Café résumé naïve");
100
- expect(result).toBe("Café résumé naïve");
101
- });
102
- it("handles backslashes", () => {
103
- // Backslashes are HTML-encoded to avoid AppleScript escaping issues
104
- const result = escapeForAppleScript("path\\to\\file");
105
- expect(result).toBe("path&#92;to&#92;file");
106
- });
107
- it("handles ampersands", () => {
108
- // Ampersands are HTML-encoded for Notes.app (& becomes &amp;)
109
- const result = escapeForAppleScript("A && B & C");
110
- expect(result).toBe("A &amp;&amp; B &amp; C");
111
- });
112
- it("handles angle brackets (HTML-like content)", () => {
113
- // Single quotes pass through unchanged
114
- const result = escapeForAppleScript("<script>alert('xss')</script>");
115
- expect(result).toBe("<script>alert('xss')</script>");
116
- });
117
- });
118
- describe("boundary conditions", () => {
119
- it("handles very short strings", () => {
120
- expect(escapeForAppleScript("a")).toBe("a");
121
- expect(escapeForAppleScript("'")).toBe("'");
122
- expect(escapeForAppleScript('"')).toBe('\\"');
123
- });
124
- it("handles string with only whitespace", () => {
125
- expect(escapeForAppleScript(" ")).toBe(" ");
126
- });
127
- it("handles multiple consecutive special characters", () => {
128
- // Single quotes pass through, double quotes are escaped
129
- const result = escapeForAppleScript("'''\"\"\"");
130
- expect(result).toBe("'''\\\"\\\"\\\"");
131
- });
132
- });
133
- });
134
- // =============================================================================
135
- // HTML Content Escaping Tests (for already-HTML content)
136
- // =============================================================================
137
- describe("escapeHtmlForAppleScript", () => {
138
- describe("basic escaping", () => {
139
- it("returns empty string for null/undefined", () => {
140
- expect(escapeHtmlForAppleScript("")).toBe("");
141
- expect(escapeHtmlForAppleScript(null)).toBe("");
142
- expect(escapeHtmlForAppleScript(undefined)).toBe("");
143
- });
144
- it("escapes double quotes for AppleScript", () => {
145
- const result = escapeHtmlForAppleScript('<div>Hello "World"</div>');
146
- expect(result).toBe('<div>Hello \\"World\\"</div>');
147
- });
148
- it("escapes backslashes for AppleScript", () => {
149
- const result = escapeHtmlForAppleScript("<div>Path: C:\\Users\\test</div>");
150
- expect(result).toBe("<div>Path: C:\\\\Users\\\\test</div>");
151
- });
152
- it("handles both backslashes and quotes", () => {
153
- const result = escapeHtmlForAppleScript('<div>Path: "C:\\test"</div>');
154
- expect(result).toBe('<div>Path: \\"C:\\\\test\\"</div>');
155
- });
156
- });
157
- describe("preserves HTML content", () => {
158
- it("does not re-encode existing HTML entities", () => {
159
- const result = escapeHtmlForAppleScript("<div>&amp; &lt; &gt;</div>");
160
- expect(result).toBe("<div>&amp; &lt; &gt;</div>");
161
- });
162
- it("preserves HTML tags", () => {
163
- const result = escapeHtmlForAppleScript("<div><b>Bold</b><br><i>Italic</i></div>");
164
- expect(result).toBe("<div><b>Bold</b><br><i>Italic</i></div>");
165
- });
166
- it("preserves numeric HTML entities", () => {
167
- const result = escapeHtmlForAppleScript("<div>&#92; &#60; &#62;</div>");
168
- expect(result).toBe("<div>&#92; &#60; &#62;</div>");
169
- });
170
- });
171
- });
172
- // =============================================================================
173
- // Date Parsing Tests
174
- // =============================================================================
175
- describe("parseAppleScriptDate", () => {
176
- describe("standard format parsing", () => {
177
- it("parses AppleScript date with 'date' prefix", () => {
178
- const dateStr = "date Saturday, December 27, 2025 at 3:44:02 PM";
179
- const result = parseAppleScriptDate(dateStr);
180
- expect(result.getFullYear()).toBe(2025);
181
- expect(result.getMonth()).toBe(11); // December is month 11 (0-indexed)
182
- expect(result.getDate()).toBe(27);
183
- });
184
- it("parses date without 'date' prefix", () => {
185
- const dateStr = "Saturday, December 27, 2025 at 3:44:02 PM";
186
- const result = parseAppleScriptDate(dateStr);
187
- expect(result.getFullYear()).toBe(2025);
188
- expect(result.getMonth()).toBe(11);
189
- });
190
- it("correctly handles AM/PM times", () => {
191
- const morningDate = "date Monday, January 1, 2025 at 9:30:00 AM";
192
- const eveningDate = "date Monday, January 1, 2025 at 9:30:00 PM";
193
- const morning = parseAppleScriptDate(morningDate);
194
- const evening = parseAppleScriptDate(eveningDate);
195
- expect(morning.getHours()).toBe(9);
196
- expect(evening.getHours()).toBe(21);
197
- });
198
- });
199
- describe("locale-independent numeric format (#25)", () => {
200
- it("parses the Y-M-D-H-m-s form emitted by our producers", () => {
201
- const result = parseAppleScriptDate("2025-12-27-15-44-2");
202
- expect(result.getFullYear()).toBe(2025);
203
- expect(result.getMonth()).toBe(11);
204
- expect(result.getDate()).toBe(27);
205
- expect(result.getHours()).toBe(15);
206
- expect(result.getMinutes()).toBe(44);
207
- expect(result.getSeconds()).toBe(2);
208
- });
209
- it("handles single-digit components and midnight", () => {
210
- const result = parseAppleScriptDate("2025-1-5-0-0-0");
211
- expect(result.getMonth()).toBe(0);
212
- expect(result.getDate()).toBe(5);
213
- expect(result.getHours()).toBe(0);
214
- });
215
- });
216
- describe("fallback behavior", () => {
217
- it("returns current date for invalid input", () => {
218
- const before = new Date();
219
- const result = parseAppleScriptDate("not a valid date");
220
- const after = new Date();
221
- // Result should be between before and after (i.e., "now")
222
- expect(result.getTime()).toBeGreaterThanOrEqual(before.getTime());
223
- expect(result.getTime()).toBeLessThanOrEqual(after.getTime());
224
- });
225
- it("returns current date for empty string", () => {
226
- const before = new Date();
227
- const result = parseAppleScriptDate("");
228
- const after = new Date();
229
- expect(result.getTime()).toBeGreaterThanOrEqual(before.getTime());
230
- expect(result.getTime()).toBeLessThanOrEqual(after.getTime());
231
- });
232
- });
233
- });
234
- // =============================================================================
235
- // buildFolderReference Tests
236
- // =============================================================================
237
- describe("splitFolderPath", () => {
238
- it("splits simple path on /", () => {
239
- expect(splitFolderPath("Work/Clients")).toEqual(["Work", "Clients"]);
240
- });
241
- it("returns single segment for a name without /", () => {
242
- expect(splitFolderPath("Work")).toEqual(["Work"]);
243
- });
244
- it("preserves escaped slashes in folder names", () => {
245
- expect(splitFolderPath("Travel/Spain\\/Portugal 2023")).toEqual([
246
- "Travel",
247
- "Spain/Portugal 2023",
248
- ]);
249
- });
250
- it("handles multiple escaped slashes", () => {
251
- expect(splitFolderPath("A\\/B/C\\/D")).toEqual(["A/B", "C/D"]);
252
- });
253
- });
254
- describe("buildFolderReference", () => {
255
- it("returns simple folder reference for a single name", () => {
256
- expect(buildFolderReference("Work")).toBe('folder "Work"');
257
- });
258
- it("returns nested folder reference for a path", () => {
259
- expect(buildFolderReference("Work/Clients")).toBe('folder "Clients" of folder "Work"');
260
- });
261
- it("handles deeply nested paths", () => {
262
- expect(buildFolderReference("Work/Clients/Omnia")).toBe('folder "Omnia" of folder "Clients" of folder "Work"');
263
- });
264
- it("handles special characters in folder names", () => {
265
- const result = buildFolderReference("Food & Drink/🥘 Recipes");
266
- expect(result).toContain('folder "🥘 Recipes"');
267
- expect(result).toContain('folder "Food & Drink"');
268
- });
269
- it("handles escaped slashes in folder names", () => {
270
- const result = buildFolderReference("Travel/Spain\\/Portugal 2023");
271
- expect(result).toBe('folder "Spain/Portugal 2023" of folder "Travel"');
272
- });
273
- });
274
- // =============================================================================
275
- // buildAppleScriptDateVar Tests
276
- // =============================================================================
277
- describe("buildAppleScriptDateVar", () => {
278
- it("generates locale-safe AppleScript date setup code", () => {
279
- const date = new Date(2025, 5, 15, 14, 30, 0); // June 15, 2025 2:30 PM
280
- const result = buildAppleScriptDateVar(date);
281
- expect(result).toContain("set thresholdDate to current date");
282
- expect(result).toContain("set year of thresholdDate to 2025");
283
- expect(result).toContain("set month of thresholdDate to 6");
284
- expect(result).toContain("set day of thresholdDate to 15");
285
- // 14*3600 + 30*60 = 52200
286
- expect(result).toContain("set time of thresholdDate to 52200");
287
- });
288
- it("handles midnight (time = 0)", () => {
289
- const date = new Date(2025, 0, 1, 0, 0, 0); // Jan 1, 2025 midnight
290
- const result = buildAppleScriptDateVar(date);
291
- expect(result).toContain("set month of thresholdDate to 1");
292
- expect(result).toContain("set day of thresholdDate to 1");
293
- expect(result).toContain("set time of thresholdDate to 0");
294
- });
295
- it("uses custom variable name", () => {
296
- const date = new Date(2025, 0, 1, 0, 0, 0);
297
- const result = buildAppleScriptDateVar(date, "myDate");
298
- expect(result).toContain("set myDate to current date");
299
- expect(result).toContain("set year of myDate to 2025");
300
- expect(result).toContain("set month of myDate to 1");
301
- });
302
- it("calculates time in seconds correctly", () => {
303
- const date = new Date(2025, 11, 25, 9, 5, 3); // 9:05:03 AM
304
- const result = buildAppleScriptDateVar(date);
305
- // 9*3600 + 5*60 + 3 = 32703
306
- expect(result).toContain("set time of thresholdDate to 32703");
307
- });
308
- });
309
- // =============================================================================
310
- // AppleNotesManager Tests
311
- // =============================================================================
312
- describe("AppleNotesManager", () => {
313
- let manager;
314
- beforeEach(() => {
315
- manager = new AppleNotesManager();
316
- vi.clearAllMocks();
317
- });
318
- describe("listAttachments — security", () => {
319
- it("escapes the account name so it cannot break out of the AppleScript literal (injection regression)", () => {
320
- mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
321
- manager.listAttachments("My Note", 'evil" injected');
322
- const script = String(mockExecuteAppleScript.mock.calls.at(-1)?.[0]);
323
- // The account's double-quote must be escaped (\\") — a raw quote would
324
- // terminate the tell-account string literal and allow `do shell script` injection.
325
- expect(script).toContain('tell account "evil\\" injected"');
326
- expect(script).not.toContain('tell account "evil" injected"');
327
- });
328
- });
329
- // ---------------------------------------------------------------------------
330
- // Note Creation
331
- // ---------------------------------------------------------------------------
332
- describe("createNote", () => {
333
- it("returns Note object on successful creation", () => {
334
- mockExecuteAppleScript.mockReturnValue({
335
- success: true,
336
- output: "note id x-coredata://12345/ICNote/p100",
337
- });
338
- const result = manager.createNote("Shopping List", "Eggs, Milk, Bread");
339
- expect(result).not.toBeNull();
340
- expect(result?.title).toBe("Shopping List");
341
- expect(result?.content).toBe("Eggs, Milk, Bread");
342
- expect(result?.account).toBe("iCloud"); // Default account
343
- });
344
- it("returns null when AppleScript fails", () => {
345
- mockExecuteAppleScript.mockReturnValue({
346
- success: false,
347
- output: "",
348
- error: "Notes.app not responding",
349
- });
350
- const result = manager.createNote("Test", "Content");
351
- expect(result).toBeNull();
352
- });
353
- it("uses specified account instead of default", () => {
354
- mockExecuteAppleScript.mockReturnValue({
355
- success: true,
356
- output: "note id x-coredata://...",
357
- });
358
- const result = manager.createNote("Draft", "Email content", [], undefined, "Gmail");
359
- expect(result?.account).toBe("Gmail");
360
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('tell account "Gmail"'));
361
- });
362
- it("creates note in specified folder", () => {
363
- mockExecuteAppleScript.mockReturnValue({
364
- success: true,
365
- output: "note id x-coredata://...",
366
- });
367
- manager.createNote("Work Note", "Content", [], "Work Projects");
368
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('at folder "Work Projects"'));
369
- });
370
- it("stores tags in returned Note object", () => {
371
- mockExecuteAppleScript.mockReturnValue({
372
- success: true,
373
- output: "note id x-coredata://...",
374
- });
375
- const result = manager.createNote("Tagged Note", "Content", ["work", "urgent"]);
376
- expect(result?.tags).toEqual(["work", "urgent"]);
377
- });
378
- it("uses escapeHtmlForAppleScript when format is html", () => {
379
- mockExecuteAppleScript.mockReturnValue({
380
- success: true,
381
- output: "note id x-coredata://12345/ICNote/p200",
382
- });
383
- const htmlContent = "<h2>Heading</h2><div>Body text</div>";
384
- const result = manager.createNote("HTML Note", htmlContent, [], undefined, undefined, "html");
385
- expect(result).not.toBeNull();
386
- // HTML tags should NOT be entity-encoded — they should pass through to AppleScript
387
- // escapeHtmlForAppleScript only escapes \ and ", not HTML tags
388
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("<h2>Heading</h2><div>Body text</div>"));
389
- });
390
- it("uses escapeForAppleScript when format is plaintext (default)", () => {
391
- mockExecuteAppleScript.mockReturnValue({
392
- success: true,
393
- output: "note id x-coredata://12345/ICNote/p201",
394
- });
395
- const result = manager.createNote("Plain Note", "Simple text with\nnewline");
396
- expect(result).not.toBeNull();
397
- // Default plaintext: newlines become <br>
398
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("Simple text with<br>newline"));
399
- });
400
- it("escapes double quotes in html format for AppleScript safety", () => {
401
- mockExecuteAppleScript.mockReturnValue({
402
- success: true,
403
- output: "note id x-coredata://12345/ICNote/p202",
404
- });
405
- manager.createNote("Quote Test", '<div class="test">Content</div>', [], undefined, undefined, "html");
406
- // Double quotes must be escaped for AppleScript string embedding
407
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('<div class=\\"test\\">Content</div>'));
408
- });
409
- it("sets title as h1 in body, not as name property", () => {
410
- mockExecuteAppleScript.mockReturnValue({
411
- success: true,
412
- output: "note id x-coredata://12345/ICNote/p203",
413
- });
414
- manager.createNote("My Title", "Body content");
415
- const script = mockExecuteAppleScript.mock.calls[0][0];
416
- // Title must appear as h1 in body
417
- expect(script).toContain("<h1>My Title</h1>");
418
- // name property must NOT be set (causes title duplication in Notes.app)
419
- expect(script).not.toContain('name:"My Title"');
420
- });
421
- it("HTML-encodes special chars in title for h1 tag", () => {
422
- mockExecuteAppleScript.mockReturnValue({
423
- success: true,
424
- output: "note id x-coredata://12345/ICNote/p204",
425
- });
426
- manager.createNote("Q&A: <Hello> World", "Content");
427
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("<h1>Q&amp;A: &lt;Hello&gt; World</h1>"));
428
- });
429
- it("HTML-encodes special chars in plaintext content", () => {
430
- mockExecuteAppleScript.mockReturnValue({
431
- success: true,
432
- output: "note id x-coredata://12345/ICNote/p205",
433
- });
434
- manager.createNote("Title", "Price: <10 & >5\nNext line");
435
- const script = mockExecuteAppleScript.mock.calls[0][0];
436
- expect(script).toContain("Price: &lt;10 &amp; &gt;5<br>Next line");
437
- });
438
- it("prepends h1 title before html content", () => {
439
- mockExecuteAppleScript.mockReturnValue({
440
- success: true,
441
- output: "note id x-coredata://12345/ICNote/p206",
442
- });
443
- manager.createNote("Report", "<h2>Section</h2><div>Details</div>", [], undefined, undefined, "html");
444
- const script = mockExecuteAppleScript.mock.calls[0][0];
445
- expect(script).toContain("<h1>Report</h1><h2>Section</h2><div>Details</div>");
446
- });
447
- it("encodes backslashes as HTML entities in plaintext content", () => {
448
- mockExecuteAppleScript.mockReturnValue({
449
- success: true,
450
- output: "note id x-coredata://12345/ICNote/p207",
451
- });
452
- manager.createNote("Title", "path\\to\\file");
453
- const script = mockExecuteAppleScript.mock.calls[0][0];
454
- expect(script).toContain("path&#92;to&#92;file");
455
- });
456
- it("converts tabs to br in plaintext content", () => {
457
- mockExecuteAppleScript.mockReturnValue({
458
- success: true,
459
- output: "note id x-coredata://12345/ICNote/p208",
460
- });
461
- manager.createNote("Title", "col1\tcol2\tcol3");
462
- const script = mockExecuteAppleScript.mock.calls[0][0];
463
- expect(script).toContain("col1<br>col2<br>col3");
464
- });
465
- });
466
- // ---------------------------------------------------------------------------
467
- // Note Search
468
- // ---------------------------------------------------------------------------
469
- describe("searchNotes", () => {
470
- it("returns array of matching notes with folder info", () => {
471
- mockExecuteAppleScript.mockReturnValue({
472
- success: true,
473
- output: [
474
- ["Meeting Notes", "x-coredata://ABC/ICNote/p1", "Work"].join(F),
475
- ["Project Plan", "x-coredata://ABC/ICNote/p2", "Notes"].join(F),
476
- ["Weekly Review", "x-coredata://ABC/ICNote/p3", "Archive"].join(F),
477
- ].join(R),
478
- });
479
- const results = manager.searchNotes("notes");
480
- expect(results).toHaveLength(3);
481
- expect(results[0].title).toBe("Meeting Notes");
482
- expect(results[0].id).toBe("x-coredata://ABC/ICNote/p1");
483
- expect(results[0].folder).toBe("Work");
484
- expect(results[1].title).toBe("Project Plan");
485
- expect(results[1].id).toBe("x-coredata://ABC/ICNote/p2");
486
- expect(results[1].folder).toBe("Notes");
487
- expect(results[2].title).toBe("Weekly Review");
488
- expect(results[2].id).toBe("x-coredata://ABC/ICNote/p3");
489
- expect(results[2].folder).toBe("Archive");
490
- });
491
- it("returns empty array when no matches found", () => {
492
- mockExecuteAppleScript.mockReturnValue({
493
- success: true,
494
- output: "",
495
- });
496
- const results = manager.searchNotes("nonexistent");
497
- expect(results).toHaveLength(0);
498
- });
499
- it("throws on AppleScript error rather than returning empty (#19)", () => {
500
- mockExecuteAppleScript.mockReturnValue({
501
- success: false,
502
- output: "",
503
- error: "Search failed",
504
- });
505
- expect(() => manager.searchNotes("test")).toThrow(/Search failed/);
506
- });
507
- it("searches content when searchContent is true", () => {
508
- mockExecuteAppleScript.mockReturnValue({
509
- success: true,
510
- output: ["Note with keyword", "x-coredata://ABC/ICNote/p1", "Notes"].join(F),
511
- });
512
- manager.searchNotes("project alpha", true);
513
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('body contains "project alpha"'));
514
- });
515
- it("searches titles when searchContent is false", () => {
516
- mockExecuteAppleScript.mockReturnValue({
517
- success: true,
518
- output: ["Project Alpha Notes", "x-coredata://ABC/ICNote/p1", "Notes"].join(F),
519
- });
520
- manager.searchNotes("Project Alpha", false);
521
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('name contains "Project Alpha"'));
522
- });
523
- it("identifies notes in Recently Deleted folder", () => {
524
- mockExecuteAppleScript.mockReturnValue({
525
- success: true,
526
- output: [
527
- ["Old Note", "x-coredata://ABC/ICNote/p1", "Recently Deleted"].join(F),
528
- ["Active Note", "x-coredata://ABC/ICNote/p2", "Notes"].join(F),
529
- ].join(R),
530
- });
531
- const results = manager.searchNotes("note");
532
- expect(results).toHaveLength(2);
533
- expect(results[0].title).toBe("Old Note");
534
- expect(results[0].id).toBe("x-coredata://ABC/ICNote/p1");
535
- expect(results[0].folder).toBe("Recently Deleted");
536
- expect(results[1].title).toBe("Active Note");
537
- expect(results[1].id).toBe("x-coredata://ABC/ICNote/p2");
538
- expect(results[1].folder).toBe("Notes");
539
- });
540
- it("deduplicates duplicate note IDs returned by Notes.app", () => {
541
- mockExecuteAppleScript.mockReturnValue({
542
- success: true,
543
- output: [
544
- ["Not uploaded", "x-coredata://ABC/ICNote/p1", "Notes"].join(F),
545
- ["Not uploaded", "x-coredata://ABC/ICNote/p1", "Notes"].join(F),
546
- ].join(R),
547
- });
548
- const results = manager.searchNotes("Not uploaded");
549
- expect(results).toHaveLength(1);
550
- expect(results[0].title).toBe("Not uploaded");
551
- expect(results[0].id).toBe("x-coredata://ABC/ICNote/p1");
552
- });
553
- it("scopes search to specified account", () => {
554
- mockExecuteAppleScript.mockReturnValue({
555
- success: true,
556
- output: "",
557
- });
558
- manager.searchNotes("work", false, "Exchange");
559
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('tell account "Exchange"'));
560
- });
561
- it("limits search to specified folder", () => {
562
- mockExecuteAppleScript.mockReturnValue({
563
- success: true,
564
- output: ["Work Note", "x-coredata://ABC/ICNote/p1", "Work"].join(F),
565
- });
566
- manager.searchNotes("note", false, undefined, "Work");
567
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('notes of folder "Work"'));
568
- });
569
- it("combines folder and account filters", () => {
570
- mockExecuteAppleScript.mockReturnValue({
571
- success: true,
572
- output: "",
573
- });
574
- manager.searchNotes("task", false, "Exchange", "Projects");
575
- const script = mockExecuteAppleScript.mock.calls[0][0];
576
- expect(script).toContain('tell account "Exchange"');
577
- expect(script).toContain('notes of folder "Projects"');
578
- });
579
- it("adds date filter when modifiedSince is provided", () => {
580
- mockExecuteAppleScript.mockReturnValue({
581
- success: true,
582
- output: ["Recent Note", "x-coredata://ABC/ICNote/p1", "Notes"].join(F),
583
- });
584
- manager.searchNotes("note", false, undefined, undefined, "2025-06-15T00:00:00");
585
- const script = mockExecuteAppleScript.mock.calls[0][0];
586
- // Locale-safe: uses variable setup instead of date "string"
587
- expect(script).toContain("set thresholdDate to current date");
588
- expect(script).toContain("set year of thresholdDate to 2025");
589
- expect(script).toContain("set month of thresholdDate to 6");
590
- expect(script).toContain("set day of thresholdDate to 15");
591
- expect(script).toContain("modification date >= thresholdDate");
592
- expect(script).toContain('name contains "note"');
593
- });
594
- it("combines date filter with content search", () => {
595
- mockExecuteAppleScript.mockReturnValue({
596
- success: true,
597
- output: ["Note", "x-coredata://ABC/ICNote/p1", "Notes"].join(F),
598
- });
599
- manager.searchNotes("keyword", true, undefined, undefined, "2025-01-01");
600
- const script = mockExecuteAppleScript.mock.calls[0][0];
601
- expect(script).toContain('body contains "keyword"');
602
- expect(script).toContain("set thresholdDate to current date");
603
- expect(script).toContain("modification date >= thresholdDate");
604
- });
605
- it("ignores invalid modifiedSince date", () => {
606
- mockExecuteAppleScript.mockReturnValue({
607
- success: true,
608
- output: ["Note", "x-coredata://ABC/ICNote/p1", "Notes"].join(F),
609
- });
610
- manager.searchNotes("note", false, undefined, undefined, "not-a-date");
611
- const script = mockExecuteAppleScript.mock.calls[0][0];
612
- expect(script).not.toContain("modification date");
613
- });
614
- it("applies limit to search results", () => {
615
- mockExecuteAppleScript.mockReturnValue({
616
- success: true,
617
- output: ["Note 1", "x-coredata://ABC/ICNote/p1", "Notes"].join(F),
618
- });
619
- manager.searchNotes("note", false, undefined, undefined, undefined, 5);
620
- const script = mockExecuteAppleScript.mock.calls[0][0];
621
- expect(script).toContain("(count of resultList) >= 5");
622
- expect(script).toContain("exit repeat");
623
- });
624
- it("combines modifiedSince, limit, folder, and content search", () => {
625
- mockExecuteAppleScript.mockReturnValue({
626
- success: true,
627
- output: ["Note", "x-coredata://ABC/ICNote/p1", "Work"].join(F),
628
- });
629
- manager.searchNotes("project", true, "iCloud", "Work", "2025-03-01", 10);
630
- const script = mockExecuteAppleScript.mock.calls[0][0];
631
- expect(script).toContain('body contains "project"');
632
- expect(script).toContain("set thresholdDate to current date");
633
- expect(script).toContain("modification date >= thresholdDate");
634
- expect(script).toContain('notes of folder "Work"');
635
- expect(script).toContain("(count of resultList) >= 10");
636
- expect(script).toContain('tell account "iCloud"');
637
- });
638
- });
639
- // ---------------------------------------------------------------------------
640
- // Note Content Retrieval
641
- // ---------------------------------------------------------------------------
642
- describe("getNoteContent", () => {
643
- it("returns HTML content of note", () => {
644
- mockExecuteAppleScript.mockReturnValue({
645
- success: true,
646
- output: "<div>Shopping List</div><div>- Eggs<br>- Milk</div>",
647
- });
648
- const content = manager.getNoteContent("Shopping List");
649
- expect(content).toBe("<div>Shopping List</div><div>- Eggs<br>- Milk</div>");
650
- });
651
- it("returns empty string when note not found", () => {
652
- mockExecuteAppleScript.mockReturnValue({
653
- success: false,
654
- output: "",
655
- error: 'Can\'t get note "Missing"',
656
- });
657
- const content = manager.getNoteContent("Missing Note");
658
- expect(content).toBe("");
659
- });
660
- it("looks up titles containing & literally, not HTML-escaped (regression)", () => {
661
- // Bug found in live testing: titles with "&" were HTML-escaped to "&amp;"
662
- // in the `note "..."` lookup, so the note could never be found.
663
- mockExecuteAppleScript.mockReturnValue({ success: true, output: "<div>x</div>" });
664
- manager.getNoteContent("Tom & Jerry", "iCloud");
665
- const script = mockExecuteAppleScript.mock.calls[0][0];
666
- expect(script).toContain("Tom & Jerry");
667
- expect(script).not.toContain("Tom &amp; Jerry");
668
- });
669
- it("uses specified account", () => {
670
- mockExecuteAppleScript.mockReturnValue({
671
- success: true,
672
- output: "<div>Content</div>",
673
- });
674
- manager.getNoteContent("My Note", "Gmail");
675
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('tell account "Gmail"'));
676
- });
677
- });
678
- describe("getNotePlaintext", () => {
679
- it("reads the note's plaintext property by title", () => {
680
- mockExecuteAppleScript.mockReturnValue({
681
- success: true,
682
- output: "Shopping List\n- Eggs\n- Milk",
683
- });
684
- const text = manager.getNotePlaintext("Shopping List");
685
- expect(text).toBe("Shopping List\n- Eggs\n- Milk");
686
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('get plaintext of note "Shopping List"'));
687
- });
688
- it("returns empty string when the note is not found", () => {
689
- mockExecuteAppleScript.mockReturnValue({
690
- success: false,
691
- output: "",
692
- error: 'Can\'t get note "Missing"',
693
- });
694
- expect(manager.getNotePlaintext("Missing Note")).toBe("");
695
- });
696
- it("uses the specified account", () => {
697
- mockExecuteAppleScript.mockReturnValue({ success: true, output: "Content" });
698
- manager.getNotePlaintext("My Note", "Gmail");
699
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('tell account "Gmail"'));
700
- });
701
- });
702
- describe("getNotePlaintextById", () => {
703
- it("reads the note's plaintext property by id at the application level", () => {
704
- mockExecuteAppleScript.mockReturnValue({ success: true, output: "Just the text" });
705
- const text = manager.getNotePlaintextById("x-coredata://ABC/ICNote/p1");
706
- expect(text).toBe("Just the text");
707
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('get plaintext of note id "x-coredata://ABC/ICNote/p1"'));
708
- });
709
- it("returns empty string when Notes.app rejects the read", () => {
710
- mockExecuteAppleScript.mockReturnValue({ success: false, output: "", error: "no such note" });
711
- expect(manager.getNotePlaintextById("x-coredata://ABC/ICNote/p1")).toBe("");
712
- });
713
- it("rejects malformed IDs", () => {
714
- expect(() => manager.getNotePlaintextById("arbitrary string")).toThrow();
715
- });
716
- });
717
- // ---------------------------------------------------------------------------
718
- // Password Protection Helpers
719
- // ---------------------------------------------------------------------------
720
- describe("isNotePasswordProtected", () => {
721
- it("returns true when note is password-protected", () => {
722
- mockExecuteAppleScript.mockReturnValue({
723
- success: true,
724
- output: [
725
- "Locked Note",
726
- "x-coredata://ABC/ICNote/p1",
727
- "Monday, January 1, 2024 at 12:00:00 PM",
728
- "Monday, January 1, 2024 at 12:00:00 PM",
729
- "false",
730
- "true",
731
- ].join(F),
732
- });
733
- const result = manager.isNotePasswordProtected("Locked Note");
734
- expect(result).toBe(true);
735
- });
736
- it("returns false when note is not password-protected", () => {
737
- mockExecuteAppleScript.mockReturnValue({
738
- success: true,
739
- output: [
740
- "Open Note",
741
- "x-coredata://ABC/ICNote/p2",
742
- "Monday, January 1, 2024 at 12:00:00 PM",
743
- "Monday, January 1, 2024 at 12:00:00 PM",
744
- "false",
745
- "false",
746
- ].join(F),
747
- });
748
- const result = manager.isNotePasswordProtected("Open Note");
749
- expect(result).toBe(false);
750
- });
751
- it("returns false when note is not found", () => {
752
- mockExecuteAppleScript.mockReturnValue({
753
- success: false,
754
- output: "",
755
- error: "Note not found",
756
- });
757
- const result = manager.isNotePasswordProtected("Missing Note");
758
- expect(result).toBe(false);
759
- });
760
- });
761
- describe("isNotePasswordProtectedById", () => {
762
- it("returns true when note is password-protected", () => {
763
- mockExecuteAppleScript.mockReturnValue({
764
- success: true,
765
- output: [
766
- "Locked Note",
767
- "x-coredata://ABC/ICNote/p1",
768
- "Monday, January 1, 2024 at 12:00:00 PM",
769
- "Monday, January 1, 2024 at 12:00:00 PM",
770
- "false",
771
- "true",
772
- ].join(F),
773
- });
774
- const result = manager.isNotePasswordProtectedById("x-coredata://ABC/ICNote/p1");
775
- expect(result).toBe(true);
776
- });
777
- it("returns false when note is not password-protected", () => {
778
- mockExecuteAppleScript.mockReturnValue({
779
- success: true,
780
- output: [
781
- "Open Note",
782
- "x-coredata://ABC/ICNote/p2",
783
- "Monday, January 1, 2024 at 12:00:00 PM",
784
- "Monday, January 1, 2024 at 12:00:00 PM",
785
- "false",
786
- "false",
787
- ].join(F),
788
- });
789
- const result = manager.isNotePasswordProtectedById("x-coredata://ABC/ICNote/p2");
790
- expect(result).toBe(false);
791
- });
792
- it("returns false when note is not found", () => {
793
- mockExecuteAppleScript.mockReturnValue({
794
- success: false,
795
- output: "",
796
- error: "Note not found",
797
- });
798
- const result = manager.isNotePasswordProtectedById("x-coredata://00000000-0000-0000-0000-000000000000/ICNote/p999");
799
- expect(result).toBe(false);
800
- });
801
- });
802
- // ---------------------------------------------------------------------------
803
- // Get Note By ID
804
- // ---------------------------------------------------------------------------
805
- describe("getNoteById", () => {
806
- it("returns Note object with metadata for valid ID", () => {
807
- mockExecuteAppleScript.mockReturnValue({
808
- success: true,
809
- output: [
810
- "My Note",
811
- "x-coredata://ABC123/ICNote/p100",
812
- "Saturday, December 27, 2025 at 3:00:00 PM",
813
- "Saturday, December 27, 2025 at 4:00:00 PM",
814
- "false",
815
- "false",
816
- ].join(F),
817
- });
818
- const result = manager.getNoteById("x-coredata://ABC123/ICNote/p100");
819
- expect(result).not.toBeNull();
820
- expect(result?.title).toBe("My Note");
821
- expect(result?.id).toBe("x-coredata://ABC123/ICNote/p100");
822
- expect(result?.shared).toBe(false);
823
- expect(result?.passwordProtected).toBe(false);
824
- });
825
- it("returns null when note ID not found", () => {
826
- mockExecuteAppleScript.mockReturnValue({
827
- success: false,
828
- output: "",
829
- error: "Can't get note id",
830
- });
831
- const result = manager.getNoteById("x-coredata://00000000-0000-0000-0000-000000000000/ICNote/p999");
832
- expect(result).toBeNull();
833
- });
834
- it("returns null when response format is unexpected (no commas)", () => {
835
- mockExecuteAppleScript.mockReturnValue({
836
- success: true,
837
- output: "incomplete data with no commas",
838
- });
839
- const result = manager.getNoteById("x-coredata://ABC123/ICNote/p100");
840
- expect(result).toBeNull();
841
- });
842
- it("returns null when response format is missing second comma", () => {
843
- mockExecuteAppleScript.mockReturnValue({
844
- success: true,
845
- output: "title only, no more data",
846
- });
847
- const result = manager.getNoteById("x-coredata://ABC123/ICNote/p100");
848
- // The new parsing requires at least title and ID separated by commas
849
- expect(result).toBeNull();
850
- });
851
- it("correctly parses shared and passwordProtected as true", () => {
852
- mockExecuteAppleScript.mockReturnValue({
853
- success: true,
854
- output: [
855
- "Shared Note",
856
- "x-coredata://ABC/ICNote/p1",
857
- "Monday, January 1, 2025 at 12:00:00 PM",
858
- "Monday, January 1, 2025 at 12:00:00 PM",
859
- "true",
860
- "true",
861
- ].join(F),
862
- });
863
- const result = manager.getNoteById("x-coredata://ABC/ICNote/p1");
864
- expect(result?.shared).toBe(true);
865
- expect(result?.passwordProtected).toBe(true);
866
- });
867
- });
868
- // ---------------------------------------------------------------------------
869
- // Get Note Details
870
- // ---------------------------------------------------------------------------
871
- describe("getNoteDetails", () => {
872
- it("returns Note object with full metadata", () => {
873
- mockExecuteAppleScript.mockReturnValue({
874
- success: true,
875
- output: [
876
- "Project Notes",
877
- "x-coredata://ABC123/ICNote/p200",
878
- "Friday, December 20, 2025 at 10:00:00 AM",
879
- "Saturday, December 27, 2025 at 2:30:00 PM",
880
- "false",
881
- "false",
882
- ].join(F),
883
- });
884
- const result = manager.getNoteDetails("Project Notes");
885
- expect(result).not.toBeNull();
886
- expect(result?.title).toBe("Project Notes");
887
- expect(result?.id).toBe("x-coredata://ABC123/ICNote/p200");
888
- expect(result?.account).toBe("iCloud");
889
- });
890
- it("returns null when note not found", () => {
891
- mockExecuteAppleScript.mockReturnValue({
892
- success: false,
893
- output: "",
894
- error: "Can't get note",
895
- });
896
- const result = manager.getNoteDetails("Nonexistent");
897
- expect(result).toBeNull();
898
- });
899
- it("uses specified account", () => {
900
- mockExecuteAppleScript.mockReturnValue({
901
- success: true,
902
- output: [
903
- "Note",
904
- "id123",
905
- "Monday, January 1, 2025 at 12:00:00 PM",
906
- "Monday, January 1, 2025 at 12:00:00 PM",
907
- "false",
908
- "false",
909
- ].join(F),
910
- });
911
- const result = manager.getNoteDetails("My Note", "Exchange");
912
- expect(result?.account).toBe("Exchange");
913
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('tell account "Exchange"'));
914
- });
915
- it("handles shared notes correctly", () => {
916
- mockExecuteAppleScript.mockReturnValue({
917
- success: true,
918
- output: [
919
- "Shared Doc",
920
- "id456",
921
- "Monday, January 1, 2025 at 12:00:00 PM",
922
- "Monday, January 1, 2025 at 12:00:00 PM",
923
- "true",
924
- "false",
925
- ].join(F),
926
- });
927
- const result = manager.getNoteDetails("Shared Doc");
928
- expect(result?.shared).toBe(true);
929
- });
930
- });
931
- // ---------------------------------------------------------------------------
932
- // Note Deletion
933
- // ---------------------------------------------------------------------------
934
- describe("deleteNote", () => {
935
- it("returns true on successful deletion", () => {
936
- mockExecuteAppleScript.mockReturnValue({
937
- success: true,
938
- output: "",
939
- });
940
- const result = manager.deleteNote("Old Note");
941
- expect(result).toBe(true);
942
- });
943
- it("returns false when deletion fails", () => {
944
- mockExecuteAppleScript.mockReturnValue({
945
- success: false,
946
- output: "",
947
- error: "Cannot delete protected note",
948
- });
949
- const result = manager.deleteNote("Protected Note");
950
- expect(result).toBe(false);
951
- });
952
- it("uses specified account", () => {
953
- mockExecuteAppleScript.mockReturnValue({
954
- success: true,
955
- output: "",
956
- });
957
- manager.deleteNote("Draft", "Gmail");
958
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('tell account "Gmail"'));
959
- });
960
- });
961
- // ---------------------------------------------------------------------------
962
- // Note Updates
963
- // ---------------------------------------------------------------------------
964
- describe("updateNote", () => {
965
- it("returns true on successful update", () => {
966
- mockExecuteAppleScript.mockReturnValue({
967
- success: true,
968
- output: "",
969
- });
970
- const result = manager.updateNote("Old Title", "New Title", "Updated content");
971
- expect(result).toBe(true);
972
- });
973
- it("returns false when update fails", () => {
974
- mockExecuteAppleScript.mockReturnValue({
975
- success: false,
976
- output: "",
977
- error: "Note not found",
978
- });
979
- const result = manager.updateNote("Missing", "New Title", "Content");
980
- expect(result).toBe(false);
981
- });
982
- it("preserves original title when newTitle is undefined", () => {
983
- mockExecuteAppleScript.mockReturnValue({
984
- success: true,
985
- output: "",
986
- });
987
- manager.updateNote("Keep This Title", undefined, "New content only");
988
- // The generated body should use the original title
989
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("<div>Keep This Title</div>"));
990
- });
991
- it("uses new title when provided", () => {
992
- mockExecuteAppleScript.mockReturnValue({
993
- success: true,
994
- output: "",
995
- });
996
- manager.updateNote("Old Title", "Brand New Title", "Content");
997
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("<div>Brand New Title</div>"));
998
- });
999
- it("uses HTML content directly when format is html", () => {
1000
- mockExecuteAppleScript.mockReturnValue({
1001
- success: true,
1002
- output: "",
1003
- });
1004
- // Use content with &amp; — if escapeForAppleScript were accidentally used,
1005
- // the & in &amp; would become &amp;amp;, causing this assertion to fail.
1006
- const htmlContent = "<h1>Title</h1><div>A &amp; B</div>";
1007
- const result = manager.updateNote("Old Title", undefined, htmlContent, undefined, "html");
1008
- expect(result).toBe(true);
1009
- // In HTML mode: content is used as-is, no <div> wrapper added
1010
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining(`to "${htmlContent}"`));
1011
- });
1012
- it("does not wrap HTML content in div tags when format is html", () => {
1013
- mockExecuteAppleScript.mockReturnValue({
1014
- success: true,
1015
- output: "",
1016
- });
1017
- manager.updateNote("Old Title", undefined, "<h1>My Title</h1><div>Content</div>", undefined, "html");
1018
- // Should NOT contain the <div>Old Title</div> wrapper
1019
- expect(mockExecuteAppleScript).not.toHaveBeenCalledWith(expect.stringContaining("<div>Old Title</div>"));
1020
- });
1021
- it("still wraps in div tags when format is plaintext (default)", () => {
1022
- mockExecuteAppleScript.mockReturnValue({
1023
- success: true,
1024
- output: "",
1025
- });
1026
- manager.updateNote("My Title", undefined, "Plain content");
1027
- // Default behavior: should have <div> wrapper
1028
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("<div>My Title</div><div>Plain content</div>"));
1029
- });
1030
- });
1031
- // ---------------------------------------------------------------------------
1032
- // Note Update by ID
1033
- // ---------------------------------------------------------------------------
1034
- describe("updateNoteById", () => {
1035
- it("uses HTML content directly without div wrapping in HTML mode", () => {
1036
- mockExecuteAppleScript.mockReturnValue({
1037
- success: true,
1038
- output: "",
1039
- });
1040
- const htmlContent = "<h1>My Title</h1><div>A &amp; B</div>";
1041
- const result = manager.updateNoteById("x-coredata://ABC00000-0000-0000-0000-000000000001/ICNote/p123", undefined, htmlContent, "html");
1042
- expect(result).toBe(true);
1043
- // HTML mode: content passed directly, no <div> wrapper
1044
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining(`to "${htmlContent}"`));
1045
- // Should NOT contain the div-wrapped title pattern
1046
- expect(mockExecuteAppleScript).not.toHaveBeenCalledWith(expect.stringContaining("<div>My Title</div>"));
1047
- });
1048
- it("does not call getNoteById in HTML mode (skips lookup optimization)", () => {
1049
- mockExecuteAppleScript.mockReturnValue({
1050
- success: true,
1051
- output: "",
1052
- });
1053
- const htmlContent = "<h1>Title</h1><div>Body</div>";
1054
- manager.updateNoteById("x-coredata://ABC00000-0000-0000-0000-000000000002/ICNote/p456", undefined, htmlContent, "html");
1055
- // In HTML mode, getNoteById should NOT be called (it would trigger
1056
- // an additional executeAppleScript call). Only one call should happen:
1057
- // the update itself.
1058
- expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1);
1059
- });
1060
- });
1061
- // ---------------------------------------------------------------------------
1062
- // Note Listing
1063
- // ---------------------------------------------------------------------------
1064
- describe("listNotes", () => {
1065
- it("returns array of note titles", () => {
1066
- mockExecuteAppleScript.mockReturnValue({
1067
- success: true,
1068
- output: [
1069
- ["Note A", "x-coredata://ABC/ICNote/p1"].join(F),
1070
- ["Note B", "x-coredata://ABC/ICNote/p2"].join(F),
1071
- ["Note C", "x-coredata://ABC/ICNote/p3"].join(F),
1072
- ].join(R),
1073
- });
1074
- const titles = manager.listNotes();
1075
- expect(titles).toEqual(["Note A", "Note B", "Note C"]);
1076
- });
1077
- it("filters out empty entries", () => {
1078
- mockExecuteAppleScript.mockReturnValue({
1079
- success: true,
1080
- output: [
1081
- ["Note A", "x-coredata://ABC/ICNote/p1"].join(F),
1082
- "",
1083
- ["Note B", "x-coredata://ABC/ICNote/p2"].join(F),
1084
- "",
1085
- "",
1086
- ].join(R),
1087
- });
1088
- const titles = manager.listNotes();
1089
- expect(titles).toEqual(["Note A", "Note B"]);
1090
- });
1091
- it("deduplicates duplicate note IDs while preserving separate notes with the same title", () => {
1092
- mockExecuteAppleScript.mockReturnValue({
1093
- success: true,
1094
- output: [
1095
- ["Same Title", "x-coredata://ABC/ICNote/p1"].join(F),
1096
- ["Same Title", "x-coredata://ABC/ICNote/p1"].join(F),
1097
- ["Same Title", "x-coredata://ABC/ICNote/p2"].join(F),
1098
- ].join(R),
1099
- });
1100
- const titles = manager.listNotes();
1101
- expect(titles).toEqual(["Same Title", "Same Title"]);
1102
- });
1103
- it("throws on failure rather than returning empty (#19)", () => {
1104
- mockExecuteAppleScript.mockReturnValue({
1105
- success: false,
1106
- output: "",
1107
- error: "Account not found",
1108
- });
1109
- expect(() => manager.listNotes()).toThrow(/Account not found/);
1110
- });
1111
- it("filters by folder when specified", () => {
1112
- mockExecuteAppleScript.mockReturnValue({
1113
- success: true,
1114
- output: [
1115
- ["Work Note 1", "x-coredata://ABC/ICNote/p1"].join(F),
1116
- ["Work Note 2", "x-coredata://ABC/ICNote/p2"].join(F),
1117
- ].join(R),
1118
- });
1119
- manager.listNotes("iCloud", "Work");
1120
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('notes of folder "Work"'));
1121
- });
1122
- it("uses whose clause when modifiedSince is provided", () => {
1123
- mockExecuteAppleScript.mockReturnValue({
1124
- success: true,
1125
- output: [
1126
- ["Recent Note 1", "x-coredata://ABC/ICNote/p1"].join(F),
1127
- ["Recent Note 2", "x-coredata://ABC/ICNote/p2"].join(F),
1128
- ].join(R),
1129
- });
1130
- const results = manager.listNotes(undefined, undefined, "2025-06-15T00:00:00");
1131
- const script = mockExecuteAppleScript.mock.calls[0][0];
1132
- // Locale-safe: uses variable setup + whose clause (no sort order assumption)
1133
- expect(script).toContain("set thresholdDate to current date");
1134
- expect(script).toContain("set year of thresholdDate to 2025");
1135
- expect(script).toContain("set month of thresholdDate to 6");
1136
- expect(script).toContain("set day of thresholdDate to 15");
1137
- expect(script).toContain("whose modification date >= thresholdDate");
1138
- expect(results).toEqual(["Recent Note 1", "Recent Note 2"]);
1139
- });
1140
- it("uses repeat loop when limit is provided", () => {
1141
- mockExecuteAppleScript.mockReturnValue({
1142
- success: true,
1143
- output: [
1144
- ["Note 1", "x-coredata://ABC/ICNote/p1"].join(F),
1145
- ["Note 2", "x-coredata://ABC/ICNote/p2"].join(F),
1146
- ["Note 3", "x-coredata://ABC/ICNote/p3"].join(F),
1147
- ].join(R),
1148
- });
1149
- const results = manager.listNotes(undefined, undefined, undefined, 3);
1150
- const script = mockExecuteAppleScript.mock.calls[0][0];
1151
- expect(script).toContain("(count of resultList) >= 3");
1152
- expect(results).toEqual(["Note 1", "Note 2", "Note 3"]);
1153
- });
1154
- it("combines folder, modifiedSince, and limit", () => {
1155
- mockExecuteAppleScript.mockReturnValue({
1156
- success: true,
1157
- output: [
1158
- ["Work Note", "x-coredata://ABC/ICNote/p1"].join(F),
1159
- ["Another Work Note", "x-coredata://ABC/ICNote/p2"].join(F),
1160
- ].join(R),
1161
- });
1162
- manager.listNotes("iCloud", "Work", "2025-01-01", 10);
1163
- const script = mockExecuteAppleScript.mock.calls[0][0];
1164
- expect(script).toContain("whose modification date >= thresholdDate");
1165
- expect(script).toContain('notes of folder "Work"');
1166
- expect(script).toContain("(count of resultList) >= 10");
1167
- });
1168
- it("returns empty array when modifiedSince yields no results", () => {
1169
- mockExecuteAppleScript.mockReturnValue({
1170
- success: true,
1171
- output: "",
1172
- });
1173
- const results = manager.listNotes(undefined, undefined, "2099-01-01");
1174
- expect(results).toEqual([]);
1175
- });
1176
- it("ignores invalid modifiedSince date and falls back to limit-only", () => {
1177
- mockExecuteAppleScript.mockReturnValue({
1178
- success: true,
1179
- output: [
1180
- ["Note 1", "x-coredata://ABC/ICNote/p1"].join(F),
1181
- ["Note 2", "x-coredata://ABC/ICNote/p2"].join(F),
1182
- ].join(R),
1183
- });
1184
- const results = manager.listNotes(undefined, undefined, "not-a-date", 5);
1185
- const script = mockExecuteAppleScript.mock.calls[0][0];
1186
- expect(script).not.toContain("thresholdDate");
1187
- expect(script).toContain("(count of resultList) >= 5");
1188
- expect(results).toEqual(["Note 1", "Note 2"]);
1189
- });
1190
- });
1191
- // ---------------------------------------------------------------------------
1192
- // Folder Operations
1193
- // ---------------------------------------------------------------------------
1194
- describe("listFolders", () => {
1195
- it("returns array of Folder objects with paths", () => {
1196
- mockExecuteAppleScript.mockReturnValue({
1197
- success: true,
1198
- output: [
1199
- ["id1", "Notes", "", "false"].join(F),
1200
- ["id2", "Archive", "", "false"].join(F),
1201
- ["id3", "Work", "", "true"].join(F),
1202
- ].join(R),
1203
- });
1204
- const folders = manager.listFolders();
1205
- expect(folders).toHaveLength(3);
1206
- expect(folders[0].name).toBe("Notes");
1207
- expect(folders[1].name).toBe("Archive");
1208
- expect(folders[2].name).toBe("Work");
1209
- expect(folders[0].id).toBe("id1");
1210
- expect(folders[2].shared).toBe(true);
1211
- });
1212
- it("includes parent folder in path", () => {
1213
- mockExecuteAppleScript.mockReturnValue({
1214
- success: true,
1215
- output: [
1216
- ["id1", "Dev", "", "false"].join(F),
1217
- ["id2", "Accessibility", "id1", "false"].join(F),
1218
- ["id3", "Work", "", "false"].join(F),
1219
- ["id4", "Clients", "id3", "false"].join(F),
1220
- ].join(R),
1221
- });
1222
- const folders = manager.listFolders();
1223
- expect(folders).toHaveLength(4);
1224
- expect(folders[0].name).toBe("Dev");
1225
- expect(folders[1].name).toBe("Dev/Accessibility");
1226
- expect(folders[2].name).toBe("Work");
1227
- expect(folders[3].name).toBe("Work/Clients");
1228
- });
1229
- it("disambiguates duplicate folder names using IDs", () => {
1230
- mockExecuteAppleScript.mockReturnValue({
1231
- success: true,
1232
- output: [
1233
- ["id1", "Finance", "", "false"].join(F),
1234
- ["id2", "Archive", "id1", "false"].join(F),
1235
- ["id3", "Travel", "", "false"].join(F),
1236
- ["id4", "Trips", "id3", "false"].join(F),
1237
- ["id5", "Archive", "id4", "false"].join(F),
1238
- ].join(R),
1239
- });
1240
- const folders = manager.listFolders();
1241
- expect(folders).toHaveLength(5);
1242
- expect(folders[1].name).toBe("Finance/Archive");
1243
- expect(folders[4].name).toBe("Travel/Trips/Archive");
1244
- });
1245
- it("escapes slashes in folder names", () => {
1246
- mockExecuteAppleScript.mockReturnValue({
1247
- success: true,
1248
- output: [
1249
- ["id1", "Travel", "", "false"].join(F),
1250
- ["id2", "Spain/Portugal 2023", "id1", "false"].join(F),
1251
- ].join(R),
1252
- });
1253
- const folders = manager.listFolders();
1254
- expect(folders).toHaveLength(2);
1255
- expect(folders[0].name).toBe("Travel");
1256
- expect(folders[1].name).toBe("Travel/Spain\\/Portugal 2023");
1257
- });
1258
- it("parses legacy tab/newline output (backward compat)", () => {
1259
- mockExecuteAppleScript.mockReturnValue({
1260
- success: true,
1261
- output: "id1\tNotes\nid2\tArchive\tid1",
1262
- });
1263
- const folders = manager.listFolders();
1264
- expect(folders).toHaveLength(2);
1265
- expect(folders[0].name).toBe("Notes");
1266
- expect(folders[1].name).toBe("Notes/Archive");
1267
- expect(folders[1].shared).toBe(false);
1268
- });
1269
- it("includes account in Folder objects", () => {
1270
- mockExecuteAppleScript.mockReturnValue({
1271
- success: true,
1272
- output: ["id1", "Notes", "", "false"].join(F),
1273
- });
1274
- const folders = manager.listFolders("Gmail");
1275
- expect(folders[0].account).toBe("Gmail");
1276
- });
1277
- });
1278
- describe("createFolder", () => {
1279
- it("returns Folder object on success", () => {
1280
- mockExecuteAppleScript
1281
- // Check existence — folder doesn't exist
1282
- .mockReturnValueOnce({ success: false, output: "", error: "Can't get folder" })
1283
- // Create the folder
1284
- .mockReturnValueOnce({
1285
- success: true,
1286
- output: "folder id x-coredata://ABC123/ICFolder/p456",
1287
- })
1288
- // Get ID of created folder
1289
- .mockReturnValueOnce({
1290
- success: true,
1291
- output: "folder id x-coredata://ABC123/ICFolder/p456",
1292
- });
1293
- const result = manager.createFolder("New Project");
1294
- expect(result).not.toBeNull();
1295
- expect(result?.name).toBe("New Project");
1296
- expect(result?.id).toBe("x-coredata://ABC123/ICFolder/p456");
1297
- });
1298
- it("returns existing folder without creating duplicate", () => {
1299
- mockExecuteAppleScript
1300
- // Check existence — folder already exists
1301
- .mockReturnValueOnce({
1302
- success: true,
1303
- output: "x-coredata://ABC123/ICFolder/p789",
1304
- })
1305
- // Get ID of existing folder
1306
- .mockReturnValueOnce({
1307
- success: true,
1308
- output: "folder id x-coredata://ABC123/ICFolder/p789",
1309
- });
1310
- const result = manager.createFolder("Existing Folder");
1311
- expect(result).not.toBeNull();
1312
- expect(result?.name).toBe("Existing Folder");
1313
- expect(result?.id).toBe("x-coredata://ABC123/ICFolder/p789");
1314
- // Should only have 2 calls (check + get ID), no create call
1315
- expect(mockExecuteAppleScript).toHaveBeenCalledTimes(2);
1316
- });
1317
- it("returns null on genuine failure", () => {
1318
- mockExecuteAppleScript
1319
- // Check existence — doesn't exist
1320
- .mockReturnValueOnce({ success: false, output: "", error: "Can't get folder" })
1321
- // Create fails
1322
- .mockReturnValueOnce({
1323
- success: false,
1324
- output: "",
1325
- error: "Permission denied",
1326
- });
1327
- const result = manager.createFolder("Restricted Folder");
1328
- expect(result).toBeNull();
1329
- });
1330
- it("creates nested folder path", () => {
1331
- mockExecuteAppleScript
1332
- // Check "Retro Tech" — doesn't exist
1333
- .mockReturnValueOnce({ success: false, output: "", error: "Can't get folder" })
1334
- // Create "Retro Tech"
1335
- .mockReturnValueOnce({ success: true, output: "folder id x-coredata://A/ICFolder/p1" })
1336
- // Check "Retro Tech/PC" — doesn't exist
1337
- .mockReturnValueOnce({ success: false, output: "", error: "Can't get folder" })
1338
- // Create "PC" inside "Retro Tech"
1339
- .mockReturnValueOnce({ success: true, output: "folder id x-coredata://A/ICFolder/p2" })
1340
- // Check "Retro Tech/PC/CPUs" — doesn't exist
1341
- .mockReturnValueOnce({ success: false, output: "", error: "Can't get folder" })
1342
- // Create "CPUs" inside "Retro Tech/PC"
1343
- .mockReturnValueOnce({ success: true, output: "folder id x-coredata://A/ICFolder/p3" })
1344
- // Get ID of final folder
1345
- .mockReturnValueOnce({ success: true, output: "folder id x-coredata://A/ICFolder/p3" });
1346
- const result = manager.createFolder("Retro Tech/PC/CPUs");
1347
- expect(result).not.toBeNull();
1348
- expect(result?.name).toBe("Retro Tech/PC/CPUs");
1349
- expect(result?.id).toBe("x-coredata://A/ICFolder/p3");
1350
- // Verify the create commands (calls at index 1, 3, 5)
1351
- const calls = mockExecuteAppleScript.mock.calls;
1352
- expect(calls[1][0]).toContain('make new folder with properties {name:"Retro Tech"}');
1353
- expect(calls[3][0]).toContain('make new folder at folder "Retro Tech" with properties {name:"PC"}');
1354
- expect(calls[5][0]).toContain('make new folder at folder "PC" of folder "Retro Tech" with properties {name:"CPUs"}');
1355
- });
1356
- it("skips existing intermediate folders in nested path", () => {
1357
- mockExecuteAppleScript
1358
- // Check "Retro Tech" — exists
1359
- .mockReturnValueOnce({ success: true, output: "x-coredata://A/ICFolder/p1" })
1360
- // Check "Retro Tech/PC" — doesn't exist
1361
- .mockReturnValueOnce({ success: false, output: "", error: "Can't get folder" })
1362
- // Create "PC" inside "Retro Tech"
1363
- .mockReturnValueOnce({ success: true, output: "folder id x-coredata://A/ICFolder/p2" })
1364
- // Get ID of final folder
1365
- .mockReturnValueOnce({ success: true, output: "folder id x-coredata://A/ICFolder/p2" });
1366
- const result = manager.createFolder("Retro Tech/PC");
1367
- expect(result).not.toBeNull();
1368
- expect(result?.name).toBe("Retro Tech/PC");
1369
- // No create call for "Retro Tech" — only for "PC"
1370
- const createCalls = mockExecuteAppleScript.mock.calls.filter((c) => c[0].includes("make new folder"));
1371
- expect(createCalls).toHaveLength(1);
1372
- expect(createCalls[0][0]).toContain('name:"PC"');
1373
- });
1374
- });
1375
- describe("deleteFolder", () => {
1376
- it("returns true on successful deletion", () => {
1377
- mockExecuteAppleScript.mockReturnValue({
1378
- success: true,
1379
- output: "",
1380
- });
1381
- const result = manager.deleteFolder("Empty Folder");
1382
- expect(result).toBe(true);
1383
- });
1384
- it("returns false when deletion fails", () => {
1385
- mockExecuteAppleScript.mockReturnValue({
1386
- success: false,
1387
- output: "",
1388
- error: "Folder contains notes",
1389
- });
1390
- const result = manager.deleteFolder("Non-Empty Folder");
1391
- expect(result).toBe(false);
1392
- });
1393
- });
1394
- // ---------------------------------------------------------------------------
1395
- // Note Moving
1396
- // ---------------------------------------------------------------------------
1397
- describe("moveNote", () => {
1398
- // The note-details lookup output reused by the title-based move tests.
1399
- const detailsOutput = [
1400
- "My Note",
1401
- "x-coredata://ABC/ICNote/p123",
1402
- "Monday, January 1, 2024 at 12:00:00 PM",
1403
- "Monday, January 1, 2024 at 12:00:00 PM",
1404
- "false",
1405
- "false",
1406
- ].join(F);
1407
- it("returns true when the native move completes successfully", () => {
1408
- // Mock sequence: getNoteDetails (resolve id) -> native move
1409
- mockExecuteAppleScript
1410
- .mockReturnValueOnce({ success: true, output: detailsOutput })
1411
- .mockReturnValueOnce({ success: true, output: "" });
1412
- const result = manager.moveNote("My Note", "Archive");
1413
- expect(result).toBe(true);
1414
- // No copy-then-delete: just the details lookup + a single native `move`.
1415
- expect(mockExecuteAppleScript).toHaveBeenCalledTimes(2);
1416
- });
1417
- it("uses the native AppleScript `move` command (preserves attachments/identity)", () => {
1418
- mockExecuteAppleScript
1419
- .mockReturnValueOnce({ success: true, output: detailsOutput })
1420
- .mockReturnValueOnce({ success: true, output: "" });
1421
- manager.moveNote("My Note", "Archive");
1422
- // The second call is the move; assert it issues a native `move ... to` and
1423
- // does NOT rebuild the note via `make new note` (the old lossy path).
1424
- const moveScript = mockExecuteAppleScript.mock.calls[1][0];
1425
- expect(moveScript).toContain("move noteRef to destFolder");
1426
- expect(moveScript).not.toContain("make new note");
1427
- });
1428
- it("returns false when source note cannot be found", () => {
1429
- mockExecuteAppleScript.mockReturnValueOnce({
1430
- success: false,
1431
- output: "",
1432
- error: "Note not found",
1433
- });
1434
- const result = manager.moveNote("Missing Note", "Archive");
1435
- expect(result).toBe(false);
1436
- expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1); // Only tried to get details
1437
- });
1438
- it("returns false when the move fails (e.g. destination folder missing)", () => {
1439
- mockExecuteAppleScript
1440
- .mockReturnValueOnce({ success: true, output: detailsOutput })
1441
- .mockReturnValueOnce({
1442
- success: false,
1443
- output: "",
1444
- error: "Folder not found",
1445
- });
1446
- const result = manager.moveNote("My Note", "Nonexistent Folder");
1447
- expect(result).toBe(false);
1448
- expect(mockExecuteAppleScript).toHaveBeenCalledTimes(2); // Details + failed move
1449
- });
1450
- });
1451
- describe("moveNoteById", () => {
1452
- it("returns true when the native move succeeds", () => {
1453
- mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "" });
1454
- const result = manager.moveNoteById("x-coredata://ABC/ICNote/p123", "Archive");
1455
- expect(result).toBe(true);
1456
- // Single native `move` — no getNoteContentById/create/delete fan-out.
1457
- expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1);
1458
- const moveScript = mockExecuteAppleScript.mock.calls[0][0];
1459
- expect(moveScript).toContain("move noteRef to destFolder");
1460
- expect(moveScript).not.toContain("make new note");
1461
- });
1462
- it("returns false when the move fails", () => {
1463
- mockExecuteAppleScript.mockReturnValueOnce({
1464
- success: false,
1465
- output: "",
1466
- error: "Folder not found",
1467
- });
1468
- const result = manager.moveNoteById("x-coredata://ABC/ICNote/p123", "Nonexistent");
1469
- expect(result).toBe(false);
1470
- expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1);
1471
- });
1472
- });
1473
- // ---------------------------------------------------------------------------
1474
- // Account Operations
1475
- // ---------------------------------------------------------------------------
1476
- describe("listAccounts", () => {
1477
- it("returns array of Account objects", () => {
1478
- mockExecuteAppleScript.mockReturnValue({
1479
- success: true,
1480
- output: [
1481
- ["acc1", "iCloud", "true", "folder1", "Notes"].join(F),
1482
- ["acc2", "Gmail", "false", "folder2", "Inbox"].join(F),
1483
- ["acc3", "Exchange", "false", "", ""].join(F),
1484
- ].join(R),
1485
- });
1486
- const accounts = manager.listAccounts();
1487
- expect(accounts).toHaveLength(3);
1488
- expect(accounts[0].name).toBe("iCloud");
1489
- expect(accounts[1].name).toBe("Gmail");
1490
- expect(accounts[2].name).toBe("Exchange");
1491
- expect(accounts[0].id).toBe("acc1");
1492
- expect(accounts[0].upgraded).toBe(true);
1493
- expect(accounts[0].defaultFolder).toBe("Notes");
1494
- });
1495
- it("parses legacy plain-name output (backward compat)", () => {
1496
- mockExecuteAppleScript.mockReturnValue({
1497
- success: true,
1498
- output: ["iCloud", "Gmail"].join(R),
1499
- });
1500
- const accounts = manager.listAccounts();
1501
- expect(accounts).toHaveLength(2);
1502
- expect(accounts[0]).toEqual({ name: "iCloud" });
1503
- expect(accounts[1]).toEqual({ name: "Gmail" });
1504
- });
1505
- it("handles account records with empty fields", () => {
1506
- mockExecuteAppleScript.mockReturnValue({
1507
- success: true,
1508
- output: ["", "", "", "", ""].join(F),
1509
- });
1510
- const accounts = manager.listAccounts();
1511
- expect(accounts).toHaveLength(1);
1512
- expect(accounts[0].name).toBe("");
1513
- expect(accounts[0].upgraded).toBe(false);
1514
- expect(accounts[0].defaultFolderId).toBeUndefined();
1515
- });
1516
- it("throws on failure rather than returning empty (#19)", () => {
1517
- mockExecuteAppleScript.mockReturnValue({
1518
- success: false,
1519
- output: "",
1520
- error: "Notes.app not available",
1521
- });
1522
- expect(() => manager.listAccounts()).toThrow(/Notes.app not available/);
1523
- });
1524
- });
1525
- describe("getDefaultLocation", () => {
1526
- it("returns default account and folder metadata", () => {
1527
- mockExecuteAppleScript.mockReturnValue({
1528
- success: true,
1529
- output: ["acc1", "iCloud", "true", "folder1", "Notes", "false"].join(F),
1530
- });
1531
- const location = manager.getDefaultLocation();
1532
- expect(location.account).toMatchObject({
1533
- id: "acc1",
1534
- name: "iCloud",
1535
- upgraded: true,
1536
- defaultFolderId: "folder1",
1537
- defaultFolder: "Notes",
1538
- });
1539
- expect(location.folder).toMatchObject({
1540
- id: "folder1",
1541
- name: "Notes",
1542
- account: "iCloud",
1543
- shared: false,
1544
- });
1545
- });
1546
- it("throws when default location output cannot be parsed", () => {
1547
- mockExecuteAppleScript.mockReturnValue({
1548
- success: true,
1549
- output: "bad-output",
1550
- });
1551
- expect(() => manager.getDefaultLocation()).toThrow(/parse default Notes location/);
1552
- });
1553
- it("throws when AppleScript fails", () => {
1554
- mockExecuteAppleScript.mockReturnValue({ success: false, output: "", error: "boom" });
1555
- expect(() => manager.getDefaultLocation()).toThrow(/Failed to get default Notes location/);
1556
- });
1557
- it("handles empty fields in default location output", () => {
1558
- mockExecuteAppleScript.mockReturnValue({
1559
- success: true,
1560
- output: ["", "", "", "", "", ""].join(F),
1561
- });
1562
- const location = manager.getDefaultLocation();
1563
- expect(location.account.name).toBe("");
1564
- expect(location.account.upgraded).toBe(false);
1565
- expect(location.folder.id).toBe("");
1566
- expect(location.folder.shared).toBe(false);
1567
- });
1568
- });
1569
- describe("getSelectedNotes", () => {
1570
- it("returns selected note metadata", () => {
1571
- mockExecuteAppleScript.mockReturnValue({
1572
- success: true,
1573
- output: [
1574
- [
1575
- "x-coredata://ABC/ICNote/p1",
1576
- "Selected Note",
1577
- "2026-6-22-14-30-0",
1578
- "2026-6-22-14-35-0",
1579
- "false",
1580
- "false",
1581
- "Notes",
1582
- "iCloud",
1583
- ].join(F),
1584
- ].join(R),
1585
- });
1586
- const notes = manager.getSelectedNotes();
1587
- expect(notes).toHaveLength(1);
1588
- expect(notes[0]).toMatchObject({
1589
- id: "x-coredata://ABC/ICNote/p1",
1590
- title: "Selected Note",
1591
- shared: false,
1592
- passwordProtected: false,
1593
- folder: "Notes",
1594
- account: "iCloud",
1595
- });
1596
- expect(notes[0].created.getFullYear()).toBe(2026);
1597
- });
1598
- it("returns an empty array when no note is selected", () => {
1599
- mockExecuteAppleScript.mockReturnValue({
1600
- success: true,
1601
- output: "",
1602
- });
1603
- expect(manager.getSelectedNotes()).toEqual([]);
1604
- });
1605
- it("throws when AppleScript fails", () => {
1606
- mockExecuteAppleScript.mockReturnValue({ success: false, output: "", error: "boom" });
1607
- expect(() => manager.getSelectedNotes()).toThrow(/Failed to get selected notes/);
1608
- });
1609
- it("handles selected notes with empty optional fields", () => {
1610
- mockExecuteAppleScript.mockReturnValue({
1611
- success: true,
1612
- output: ["", "", "", "", "", "", "", ""].join(F),
1613
- });
1614
- const notes = manager.getSelectedNotes();
1615
- expect(notes).toHaveLength(1);
1616
- expect(notes[0].id).toBe("");
1617
- expect(notes[0].shared).toBe(false);
1618
- expect(notes[0].passwordProtected).toBe(false);
1619
- expect(notes[0].folder).toBeUndefined();
1620
- expect(notes[0].account).toBeUndefined();
1621
- });
1622
- });
1623
- describe("showNoteById", () => {
1624
- it("shows a note by id", () => {
1625
- mockExecuteAppleScript.mockReturnValue({
1626
- success: true,
1627
- output: "",
1628
- });
1629
- expect(manager.showNoteById("x-coredata://ABC/ICNote/p1")).toBe(true);
1630
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('show note id "x-coredata://ABC/ICNote/p1"'));
1631
- });
1632
- it("can request a separate window", () => {
1633
- mockExecuteAppleScript.mockReturnValue({
1634
- success: true,
1635
- output: "",
1636
- });
1637
- manager.showNoteById("x-coredata://ABC/ICNote/p1", true);
1638
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("separately true"));
1639
- });
1640
- it("returns false when Notes.app rejects the show command", () => {
1641
- mockExecuteAppleScript.mockReturnValue({ success: false, output: "", error: "no such note" });
1642
- expect(manager.showNoteById("x-coredata://ABC/ICNote/p1")).toBe(false);
1643
- });
1644
- });
1645
- describe("showFolderById", () => {
1646
- it("shows a folder by id", () => {
1647
- mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
1648
- expect(manager.showFolderById("x-coredata://ABC/ICFolder/p1")).toBe(true);
1649
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('show folder id "x-coredata://ABC/ICFolder/p1"'));
1650
- });
1651
- it("can request a separate window", () => {
1652
- mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
1653
- manager.showFolderById("x-coredata://ABC/ICFolder/p1", true);
1654
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("separately true"));
1655
- });
1656
- it("returns false when Notes.app rejects the show command", () => {
1657
- mockExecuteAppleScript.mockReturnValue({
1658
- success: false,
1659
- output: "",
1660
- error: "no such folder",
1661
- });
1662
- expect(manager.showFolderById("x-coredata://ABC/ICFolder/p1")).toBe(false);
1663
- });
1664
- });
1665
- describe("showAccountById", () => {
1666
- it("shows an account by id", () => {
1667
- mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
1668
- expect(manager.showAccountById("x-coredata://ABC/ICAccount/p1")).toBe(true);
1669
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('show account id "x-coredata://ABC/ICAccount/p1"'));
1670
- });
1671
- it("can request a separate window", () => {
1672
- mockExecuteAppleScript.mockReturnValue({ success: true, output: "" });
1673
- manager.showAccountById("x-coredata://ABC/ICAccount/p1", true);
1674
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("separately true"));
1675
- });
1676
- it("returns false when Notes.app rejects the show command", () => {
1677
- mockExecuteAppleScript.mockReturnValue({
1678
- success: false,
1679
- output: "",
1680
- error: "no such account",
1681
- });
1682
- expect(manager.showAccountById("x-coredata://ABC/ICAccount/p1")).toBe(false);
1683
- });
1684
- });
1685
- describe("showAttachmentById", () => {
1686
- it("resolves the attachment within its note and shows it", () => {
1687
- mockExecuteAppleScript.mockReturnValue({ success: true, output: "OK" });
1688
- expect(manager.showAttachmentById("x-coredata://ABC/ICNote/p1", "att-123")).toBe(true);
1689
- const script = mockExecuteAppleScript.mock.calls[0][0];
1690
- expect(script).toContain('set theNote to note id "x-coredata://ABC/ICNote/p1"');
1691
- expect(script).toContain('is "att-123"');
1692
- expect(script).toContain("show theAttachment");
1693
- });
1694
- it("can request a separate window", () => {
1695
- mockExecuteAppleScript.mockReturnValue({ success: true, output: "OK" });
1696
- manager.showAttachmentById("x-coredata://ABC/ICNote/p1", "att-123", true);
1697
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining("separately true"));
1698
- });
1699
- it("returns false when the attachment is not found on the note", () => {
1700
- mockExecuteAppleScript.mockReturnValue({
1701
- success: true,
1702
- output: `ERR${F}attachment not found`,
1703
- });
1704
- expect(manager.showAttachmentById("x-coredata://ABC/ICNote/p1", "missing")).toBe(false);
1705
- });
1706
- it("returns false when Notes.app rejects the show command", () => {
1707
- mockExecuteAppleScript.mockReturnValue({
1708
- success: false,
1709
- output: "",
1710
- error: "no such note",
1711
- });
1712
- expect(manager.showAttachmentById("x-coredata://ABC/ICNote/p1", "att-123")).toBe(false);
1713
- });
1714
- });
1715
- // ---------------------------------------------------------------------------
1716
- // Health Check
1717
- // ---------------------------------------------------------------------------
1718
- describe("healthCheck", () => {
1719
- it("returns healthy when all checks pass", () => {
1720
- mockExecuteAppleScript
1721
- // Check 1: Notes.app accessible
1722
- .mockReturnValueOnce({ success: true, output: "ok" })
1723
- // Check 2: Permissions (get account name)
1724
- .mockReturnValueOnce({ success: true, output: "iCloud" })
1725
- // Check 3: listAccounts
1726
- .mockReturnValueOnce({ success: true, output: "iCloud" })
1727
- // Check 4: listNotes
1728
- .mockReturnValueOnce({
1729
- success: true,
1730
- output: [
1731
- ["Note 1", "x-coredata://ABC/ICNote/p1"].join(F),
1732
- ["Note 2", "x-coredata://ABC/ICNote/p2"].join(F),
1733
- ].join(R),
1734
- });
1735
- const result = manager.healthCheck();
1736
- expect(result.healthy).toBe(true);
1737
- expect(result.checks).toHaveLength(4);
1738
- expect(result.checks.every((c) => c.passed)).toBe(true);
1739
- });
1740
- it("returns unhealthy when Notes.app is not accessible", () => {
1741
- mockExecuteAppleScript.mockReturnValueOnce({
1742
- success: false,
1743
- output: "",
1744
- error: "Application not found",
1745
- });
1746
- const result = manager.healthCheck();
1747
- expect(result.healthy).toBe(false);
1748
- expect(result.checks).toHaveLength(1);
1749
- expect(result.checks[0].name).toBe("notes_app");
1750
- expect(result.checks[0].passed).toBe(false);
1751
- });
1752
- it("returns unhealthy with permission hint when not authorized", () => {
1753
- mockExecuteAppleScript.mockReturnValueOnce({
1754
- success: false,
1755
- output: "",
1756
- error: "not authorized to send Apple events",
1757
- });
1758
- const result = manager.healthCheck();
1759
- expect(result.healthy).toBe(false);
1760
- expect(result.checks[0].message).toContain("Automation permissions");
1761
- });
1762
- it("returns unhealthy when no accounts found", () => {
1763
- mockExecuteAppleScript
1764
- .mockReturnValueOnce({ success: true, output: "ok" })
1765
- .mockReturnValueOnce({ success: true, output: "iCloud" })
1766
- .mockReturnValueOnce({ success: true, output: "" }); // No accounts
1767
- const result = manager.healthCheck();
1768
- expect(result.healthy).toBe(false);
1769
- expect(result.checks.find((c) => c.name === "accounts")?.passed).toBe(false);
1770
- });
1771
- it("includes account names in successful account check", () => {
1772
- mockExecuteAppleScript
1773
- .mockReturnValueOnce({ success: true, output: "ok" })
1774
- .mockReturnValueOnce({ success: true, output: "iCloud" })
1775
- .mockReturnValueOnce({ success: true, output: ["iCloud", "Gmail"].join(R) })
1776
- .mockReturnValueOnce({ success: true, output: "" });
1777
- const result = manager.healthCheck();
1778
- const accountCheck = result.checks.find((c) => c.name === "accounts");
1779
- expect(accountCheck?.message).toContain("iCloud");
1780
- expect(accountCheck?.message).toContain("Gmail");
1781
- });
1782
- });
1783
- // ---------------------------------------------------------------------------
1784
- // Statistics
1785
- // ---------------------------------------------------------------------------
1786
- describe("getNotesStats", () => {
1787
- it("returns statistics for all accounts and folders", () => {
1788
- mockExecuteAppleScript
1789
- // listAccounts
1790
- .mockReturnValueOnce({ success: true, output: "iCloud" })
1791
- // per-account folder counts: name<F>count, records joined by R
1792
- .mockReturnValueOnce({
1793
- success: true,
1794
- output: ["Notes", "3"].join(F) + R + ["Work", "2"].join(F) + R,
1795
- })
1796
- // getRecentlyModifiedCounts: c1<F>c7<F>c30
1797
- .mockReturnValueOnce({ success: true, output: ["0", "0", "0"].join(F) });
1798
- const stats = manager.getNotesStats();
1799
- expect(stats.totalNotes).toBe(5);
1800
- expect(stats.accounts).toHaveLength(1);
1801
- expect(stats.accounts[0].name).toBe("iCloud");
1802
- expect(stats.accounts[0].totalNotes).toBe(5);
1803
- expect(stats.accounts[0].folderCount).toBe(2);
1804
- expect(stats.accounts[0].folders).toHaveLength(2);
1805
- });
1806
- it("returns zero counts when no notes exist", () => {
1807
- mockExecuteAppleScript
1808
- .mockReturnValueOnce({ success: true, output: "iCloud" })
1809
- .mockReturnValueOnce({ success: true, output: ["Notes", "0"].join(F) + R })
1810
- .mockReturnValueOnce({ success: true, output: ["0", "0", "0"].join(F) });
1811
- const stats = manager.getNotesStats();
1812
- expect(stats.totalNotes).toBe(0);
1813
- expect(stats.recentlyModified.last24h).toBe(0);
1814
- expect(stats.recentlyModified.last7d).toBe(0);
1815
- expect(stats.recentlyModified.last30d).toBe(0);
1816
- });
1817
- it("handles multiple accounts", () => {
1818
- mockExecuteAppleScript
1819
- // listAccounts
1820
- .mockReturnValueOnce({ success: true, output: ["iCloud", "Gmail"].join(R) })
1821
- // iCloud folder counts
1822
- .mockReturnValueOnce({ success: true, output: ["Notes", "1"].join(F) + R })
1823
- // Gmail folder counts
1824
- .mockReturnValueOnce({ success: true, output: ["Notes", "1"].join(F) + R })
1825
- // getRecentlyModifiedCounts
1826
- .mockReturnValueOnce({ success: true, output: ["0", "0", "0"].join(F) });
1827
- const stats = manager.getNotesStats();
1828
- expect(stats.totalNotes).toBe(2);
1829
- expect(stats.accounts).toHaveLength(2);
1830
- expect(stats.accounts[0].name).toBe("iCloud");
1831
- expect(stats.accounts[1].name).toBe("Gmail");
1832
- });
1833
- it("reports complete coverage when every scope succeeds (#19)", () => {
1834
- mockExecuteAppleScript
1835
- .mockReturnValueOnce({ success: true, output: "iCloud" })
1836
- .mockReturnValueOnce({ success: true, output: ["Notes", "3"].join(F) + R })
1837
- .mockReturnValueOnce({ success: true, output: ["1", "2", "3"].join(F) });
1838
- const stats = manager.getNotesStats();
1839
- expect(stats.coverage.complete).toBe(true);
1840
- expect(stats.coverage.warnings).toEqual([]);
1841
- expect(stats.coverage.covered).toBe(stats.coverage.scanned);
1842
- });
1843
- it("degrades gracefully when one account fails, with a coverage warning (#19)", () => {
1844
- mockExecuteAppleScript
1845
- // listAccounts
1846
- .mockReturnValueOnce({ success: true, output: ["iCloud", "Gmail"].join(R) })
1847
- // iCloud folder counts succeed
1848
- .mockReturnValueOnce({ success: true, output: ["Notes", "4"].join(F) + R })
1849
- // Gmail folder counts FAIL
1850
- .mockReturnValueOnce({ success: false, output: "", error: "Gmail account is locked" })
1851
- // getRecentlyModifiedCounts succeed
1852
- .mockReturnValueOnce({ success: true, output: ["0", "0", "0"].join(F) });
1853
- const stats = manager.getNotesStats();
1854
- // Healthy account's data is preserved, not discarded
1855
- expect(stats.totalNotes).toBe(4);
1856
- expect(stats.accounts).toHaveLength(1);
1857
- expect(stats.accounts[0].name).toBe("iCloud");
1858
- // Failure surfaced as a coverage warning
1859
- expect(stats.coverage.complete).toBe(false);
1860
- expect(stats.coverage.warnings).toHaveLength(1);
1861
- expect(stats.coverage.warnings[0].scope).toBe("Gmail");
1862
- expect(stats.coverage.warnings[0].reason).toContain("locked");
1863
- });
1864
- it("flags recent-activity failure as a coverage warning, not fake zeros (#19)", () => {
1865
- mockExecuteAppleScript
1866
- .mockReturnValueOnce({ success: true, output: "iCloud" })
1867
- .mockReturnValueOnce({ success: true, output: ["Notes", "5"].join(F) + R })
1868
- // getRecentlyModifiedCounts FAILS
1869
- .mockReturnValueOnce({ success: false, output: "", error: "timed out" });
1870
- const stats = manager.getNotesStats();
1871
- expect(stats.totalNotes).toBe(5);
1872
- expect(stats.recentlyModified.last24h).toBe(0);
1873
- expect(stats.coverage.complete).toBe(false);
1874
- expect(stats.coverage.warnings.some((w) => w.scope === "recent-activity")).toBe(true);
1875
- });
1876
- it("throws when no account can be read at all (#19)", () => {
1877
- mockExecuteAppleScript
1878
- .mockReturnValueOnce({ success: true, output: ["iCloud", "Gmail"].join(R) })
1879
- .mockReturnValueOnce({ success: false, output: "", error: "iCloud unreachable" })
1880
- .mockReturnValueOnce({ success: false, output: "", error: "Gmail unreachable" });
1881
- expect(() => manager.getNotesStats()).toThrow(/Failed to read folder stats for any/);
1882
- });
1883
- });
1884
- // ---------------------------------------------------------------------------
1885
- // Attachment Listing
1886
- // ---------------------------------------------------------------------------
1887
- describe("listAttachmentsById", () => {
1888
- it("returns attachments for a note", () => {
1889
- mockExecuteAppleScript.mockReturnValueOnce({
1890
- success: true,
1891
- output: [
1892
- ["x-coredata://ABC/ICAttachment/p1", "photo.jpg", "public.jpeg"].join(F),
1893
- ["x-coredata://ABC/ICAttachment/p2", "document.pdf", "com.adobe.pdf"].join(F),
1894
- ].join(R),
1895
- });
1896
- const attachments = manager.listAttachmentsById("x-coredata://ABC/ICNote/p123");
1897
- expect(attachments).toHaveLength(2);
1898
- expect(attachments[0]).toMatchObject({
1899
- id: "x-coredata://ABC/ICAttachment/p1",
1900
- name: "photo.jpg",
1901
- contentType: "public.jpeg",
1902
- contentId: "public.jpeg",
1903
- });
1904
- expect(attachments[1]).toMatchObject({
1905
- id: "x-coredata://ABC/ICAttachment/p2",
1906
- name: "document.pdf",
1907
- contentType: "com.adobe.pdf",
1908
- contentId: "com.adobe.pdf",
1909
- });
1910
- });
1911
- it("parses richer attachment metadata when present", () => {
1912
- mockExecuteAppleScript.mockReturnValueOnce({
1913
- success: true,
1914
- output: [
1915
- [
1916
- "x-coredata://ABC/ICAttachment/p1",
1917
- "site.webloc",
1918
- "cid:123",
1919
- "https://example.com",
1920
- "2026-6-22-10-0-0",
1921
- "2026-6-22-11-0-0",
1922
- "true",
1923
- ].join(F),
1924
- ].join(R),
1925
- });
1926
- const attachments = manager.listAttachmentsById("x-coredata://ABC/ICNote/p123");
1927
- expect(attachments[0]).toMatchObject({
1928
- id: "x-coredata://ABC/ICAttachment/p1",
1929
- name: "site.webloc",
1930
- contentType: "cid:123",
1931
- contentId: "cid:123",
1932
- url: "https://example.com",
1933
- shared: true,
1934
- });
1935
- expect(attachments[0].created?.getFullYear()).toBe(2026);
1936
- expect(attachments[0].modified?.getHours()).toBe(11);
1937
- });
1938
- it("returns empty array when note has no attachments", () => {
1939
- mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "" });
1940
- const attachments = manager.listAttachmentsById("x-coredata://ABC/ICNote/p123");
1941
- expect(attachments).toEqual([]);
1942
- });
1943
- it("returns empty array on error", () => {
1944
- mockExecuteAppleScript.mockReturnValueOnce({
1945
- success: false,
1946
- output: "",
1947
- error: "Note not found",
1948
- });
1949
- const attachments = manager.listAttachmentsById("x-coredata://ABC/ICNote/p999");
1950
- expect(attachments).toEqual([]);
1951
- });
1952
- it("generates correct AppleScript for ID lookup", () => {
1953
- mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "" });
1954
- manager.listAttachmentsById("x-coredata://ABC/ICNote/p123");
1955
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('note id "x-coredata://ABC/ICNote/p123"'));
1956
- });
1957
- });
1958
- describe("listAttachments", () => {
1959
- it("returns attachments for a note by title", () => {
1960
- mockExecuteAppleScript.mockReturnValueOnce({
1961
- success: true,
1962
- output: ["attach-id", "image.png", "public.png"].join(F),
1963
- });
1964
- const attachments = manager.listAttachments("My Note");
1965
- expect(attachments).toHaveLength(1);
1966
- expect(attachments[0]).toMatchObject({
1967
- id: "attach-id",
1968
- name: "image.png",
1969
- contentType: "public.png",
1970
- contentId: "public.png",
1971
- });
1972
- });
1973
- it("uses specified account", () => {
1974
- mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "" });
1975
- manager.listAttachments("My Note", "Gmail");
1976
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('account "Gmail"'));
1977
- });
1978
- it("defaults to iCloud account", () => {
1979
- mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "" });
1980
- manager.listAttachments("My Note");
1981
- expect(mockExecuteAppleScript).toHaveBeenCalledWith(expect.stringContaining('account "iCloud"'));
1982
- });
1983
- it("returns empty array when note has no attachments", () => {
1984
- mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "" });
1985
- const attachments = manager.listAttachments("Empty Note");
1986
- expect(attachments).toEqual([]);
1987
- });
1988
- });
1989
- // ---------------------------------------------------------------------------
1990
- // Batch Operations
1991
- // ---------------------------------------------------------------------------
1992
- describe("batchDeleteNotes", () => {
1993
- const ID1 = "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1";
1994
- const ID2 = "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2";
1995
- it("deletes the whole batch in a single osascript spawn (#26)", () => {
1996
- // One script handles all ids; per-id status tokens joined by RECORD_SEP.
1997
- mockExecuteAppleScript.mockReturnValueOnce({
1998
- success: true,
1999
- output: ["ok", "ok"].join(R) + R,
2000
- });
2001
- const results = manager.batchDeleteNotes([ID1, ID2]);
2002
- // Exactly one spawn for N notes, not 3N.
2003
- expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1);
2004
- expect(results).toHaveLength(2);
2005
- expect(results[0]).toEqual({ id: ID1, success: true });
2006
- expect(results[1]).toEqual({ id: ID2, success: true });
2007
- });
2008
- it("returns error for non-existent note", () => {
2009
- mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "missing" + R });
2010
- const results = manager.batchDeleteNotes([
2011
- "x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
2012
- ]);
2013
- expect(results[0]).toEqual({
2014
- id: "x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
2015
- success: false,
2016
- error: "Note not found",
2017
- });
2018
- });
2019
- it("returns error for password-protected note", () => {
2020
- mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "pw" + R });
2021
- const results = manager.batchDeleteNotes([ID1]);
2022
- expect(results[0]).toEqual({ id: ID1, success: false, error: "Note is password-protected" });
2023
- });
2024
- it("handles mixed success and failure, preserving order", () => {
2025
- mockExecuteAppleScript.mockReturnValueOnce({
2026
- success: true,
2027
- output: ["ok", "missing"].join(R) + R,
2028
- });
2029
- const results = manager.batchDeleteNotes([ID1, ID2]);
2030
- expect(results[0]).toEqual({ id: ID1, success: true });
2031
- expect(results[1]).toEqual({ id: ID2, success: false, error: "Note not found" });
2032
- });
2033
- it("fails an invalid id without spawning, isolating it from valid ids", () => {
2034
- mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "ok" + R });
2035
- const results = manager.batchDeleteNotes(["not-a-valid-id", ID1]);
2036
- expect(results[0].success).toBe(false);
2037
- expect(results[0].error).toMatch(/Invalid note ID/);
2038
- expect(results[1]).toEqual({ id: ID1, success: true });
2039
- });
2040
- it("fails the whole batch when the single script errors", () => {
2041
- mockExecuteAppleScript.mockReturnValueOnce({
2042
- success: false,
2043
- output: "",
2044
- error: "Notes.app not responding",
2045
- });
2046
- const results = manager.batchDeleteNotes([ID1, ID2]);
2047
- expect(results.every((r) => r.success === false)).toBe(true);
2048
- expect(results[0].error).toContain("Notes.app not responding");
2049
- });
2050
- it("returns [] for an empty batch without spawning", () => {
2051
- const results = manager.batchDeleteNotes([]);
2052
- expect(results).toEqual([]);
2053
- expect(mockExecuteAppleScript).not.toHaveBeenCalled();
2054
- });
2055
- });
2056
- describe("batchMoveNotes", () => {
2057
- const ID1 = "x-coredata://ABC00000-0000-0000-0000-000000000011/ICNote/p1";
2058
- const ID2 = "x-coredata://ABC00000-0000-0000-0000-000000000012/ICNote/p2";
2059
- it("moves the whole batch in a single osascript spawn (#26)", () => {
2060
- mockExecuteAppleScript.mockReturnValueOnce({
2061
- success: true,
2062
- output: ["ok", "ok"].join(R) + R,
2063
- });
2064
- const results = manager.batchMoveNotes([ID1, ID2], "Archive");
2065
- expect(mockExecuteAppleScript).toHaveBeenCalledTimes(1);
2066
- expect(results).toHaveLength(2);
2067
- expect(results[0]).toEqual({ id: ID1, success: true });
2068
- expect(results[1]).toEqual({ id: ID2, success: true });
2069
- });
2070
- it("returns error for non-existent note", () => {
2071
- mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "missing" + R });
2072
- const results = manager.batchMoveNotes(["x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404"], "Archive");
2073
- expect(results[0]).toEqual({
2074
- id: "x-coredata://ABC00000-0000-0000-0000-000000000099/ICNote/p404",
2075
- success: false,
2076
- error: "Note not found",
2077
- });
2078
- });
2079
- it("returns error for password-protected note", () => {
2080
- mockExecuteAppleScript.mockReturnValueOnce({ success: true, output: "pw" + R });
2081
- const results = manager.batchMoveNotes([ID1], "Archive");
2082
- expect(results[0]).toEqual({ id: ID1, success: false, error: "Note is password-protected" });
2083
- });
2084
- it("maps a per-item move failure to 'Move failed'", () => {
2085
- mockExecuteAppleScript.mockReturnValueOnce({
2086
- success: true,
2087
- output: ["ok", "fail"].join(R) + R,
2088
- });
2089
- const results = manager.batchMoveNotes([ID1, ID2], "Archive");
2090
- expect(results[0]).toEqual({ id: ID1, success: true });
2091
- expect(results[1]).toEqual({ id: ID2, success: false, error: "Move failed" });
2092
- });
2093
- });
2094
- // ---------------------------------------------------------------------------
2095
- // Export Operations
2096
- // ---------------------------------------------------------------------------
2097
- describe("exportNotesAsJson", () => {
2098
- // Note details output helper - format: title, id, date, date, shared, passwordProtected
2099
- const noteDetailsOutput = (title, passwordProtected = false) => [
2100
- title,
2101
- "x-coredata://ABC/ICNote/p1",
2102
- "Sunday, January 1, 2025 at 1:00:00 PM",
2103
- "Sunday, January 1, 2025 at 1:00:00 PM",
2104
- "false",
2105
- String(passwordProtected),
2106
- ].join(F);
2107
- it("exports notes with metadata and content", () => {
2108
- mockExecuteAppleScript
2109
- // listAccounts
2110
- .mockReturnValueOnce({ success: true, output: "iCloud" })
2111
- // listFolders for iCloud
2112
- .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
2113
- // listNotes for Notes folder
2114
- .mockReturnValueOnce({
2115
- success: true,
2116
- output: ["Test Note", "x-coredata://ABC/ICNote/p1"].join(F),
2117
- })
2118
- // getNoteDetails
2119
- .mockReturnValueOnce({ success: true, output: noteDetailsOutput("Test Note", false) })
2120
- // getNoteContent
2121
- .mockReturnValueOnce({
2122
- success: true,
2123
- output: "<div>Test Note</div><div>Content here</div>",
2124
- });
2125
- const result = manager.exportNotesAsJson();
2126
- expect(result.version).toBe("1.0");
2127
- expect(result.exportDate).toBeDefined();
2128
- expect(result.summary.totalNotes).toBe(1);
2129
- expect(result.summary.totalFolders).toBe(1);
2130
- expect(result.summary.totalAccounts).toBe(1);
2131
- expect(result.accounts[0].name).toBe("iCloud");
2132
- expect(result.accounts[0].folders[0].name).toBe("Notes");
2133
- expect(result.accounts[0].folders[0].notes).toHaveLength(1);
2134
- });
2135
- it("skips content for password-protected notes", () => {
2136
- mockExecuteAppleScript
2137
- // listAccounts
2138
- .mockReturnValueOnce({ success: true, output: "iCloud" })
2139
- // listFolders for iCloud
2140
- .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
2141
- // listNotes for Notes folder
2142
- .mockReturnValueOnce({
2143
- success: true,
2144
- output: ["Locked Note", "x-coredata://ABC/ICNote/p1"].join(F),
2145
- })
2146
- // getNoteDetails (passwordProtected = true)
2147
- .mockReturnValueOnce({ success: true, output: noteDetailsOutput("Locked Note", true) });
2148
- // No getNoteContent call because note is password-protected
2149
- const result = manager.exportNotesAsJson();
2150
- const note = result.accounts[0].folders[0].notes[0];
2151
- expect(note.passwordProtected).toBe(true);
2152
- expect(note.content).toBe("");
2153
- });
2154
- it("handles empty accounts", () => {
2155
- mockExecuteAppleScript
2156
- // listAccounts
2157
- .mockReturnValueOnce({ success: true, output: "iCloud" })
2158
- // listFolders for iCloud
2159
- .mockReturnValueOnce({ success: true, output: "id1\tNotes" })
2160
- // listNotes returns empty
2161
- .mockReturnValueOnce({ success: true, output: "" });
2162
- const result = manager.exportNotesAsJson();
2163
- expect(result.summary.totalNotes).toBe(0);
2164
- });
2165
- });
2166
- // ---------------------------------------------------------------------------
2167
- // Markdown Conversion
2168
- // ---------------------------------------------------------------------------
2169
- describe("getNoteMarkdown", () => {
2170
- it("converts HTML to Markdown", () => {
2171
- mockExecuteAppleScript.mockReturnValueOnce({
2172
- success: true,
2173
- output: "<div>My Title</div><div>This is a paragraph.</div><div><b>Bold text</b></div>",
2174
- });
2175
- const markdown = manager.getNoteMarkdown("My Note");
2176
- expect(markdown).toContain("My Title");
2177
- expect(markdown).toContain("This is a paragraph.");
2178
- expect(markdown).toContain("**Bold text**");
2179
- });
2180
- it("returns empty string when note not found", () => {
2181
- mockExecuteAppleScript.mockReturnValueOnce({
2182
- success: false,
2183
- output: "",
2184
- error: "Note not found",
2185
- });
2186
- const markdown = manager.getNoteMarkdown("Missing Note");
2187
- expect(markdown).toBe("");
2188
- });
2189
- it("handles lists correctly", () => {
2190
- mockExecuteAppleScript.mockReturnValueOnce({
2191
- success: true,
2192
- output: "<ul><li>Item 1</li><li>Item 2</li></ul>",
2193
- });
2194
- const markdown = manager.getNoteMarkdown("List Note");
2195
- // Turndown may add extra whitespace after the bullet
2196
- expect(markdown).toMatch(/-\s+Item 1/);
2197
- expect(markdown).toMatch(/-\s+Item 2/);
2198
- });
2199
- });
2200
- describe("getNoteMarkdownById", () => {
2201
- it("converts HTML to Markdown using ID", () => {
2202
- mockExecuteAppleScript.mockReturnValueOnce({
2203
- success: true,
2204
- output: "<div>Note Title</div><div>Content here</div>",
2205
- });
2206
- const markdown = manager.getNoteMarkdownById("x-coredata://ABC/ICNote/p123");
2207
- expect(markdown).toContain("Note Title");
2208
- expect(markdown).toContain("Content here");
2209
- });
2210
- it("returns empty string when note ID not found", () => {
2211
- mockExecuteAppleScript.mockReturnValueOnce({
2212
- success: false,
2213
- output: "",
2214
- error: "Note not found",
2215
- });
2216
- const markdown = manager.getNoteMarkdownById("x-coredata://00000000-0000-0000-0000-000000000000/ICNote/p999");
2217
- expect(markdown).toBe("");
2218
- });
2219
- it("enriches markdown with checklist state when available", () => {
2220
- mockExecuteAppleScript.mockReturnValueOnce({
2221
- success: true,
2222
- output: "<ul><li>Buy milk</li><li>Walk dog</li><li>Send email</li></ul>",
2223
- });
2224
- mockGetChecklistItems.mockReturnValueOnce({
2225
- items: [
2226
- { text: "Buy milk", done: true },
2227
- { text: "Walk dog", done: false },
2228
- { text: "Send email", done: true },
2229
- ],
2230
- });
2231
- const markdown = manager.getNoteMarkdownById("x-coredata://ABC/ICNote/p123");
2232
- expect(markdown).toMatch(/-\s+\[x\] Buy milk/);
2233
- expect(markdown).toMatch(/-\s+\[ \] Walk dog/);
2234
- expect(markdown).toMatch(/-\s+\[x\] Send email/);
2235
- });
2236
- it("returns plain markdown when checklist state is unavailable", () => {
2237
- mockExecuteAppleScript.mockReturnValueOnce({
2238
- success: true,
2239
- output: "<ul><li>Item 1</li><li>Item 2</li></ul>",
2240
- });
2241
- mockGetChecklistItems.mockReturnValueOnce({
2242
- items: null,
2243
- error: "no_fda",
2244
- message: "Full Disk Access required",
2245
- });
2246
- const markdown = manager.getNoteMarkdownById("x-coredata://ABC/ICNote/p456");
2247
- expect(markdown).toMatch(/-\s+Item 1/);
2248
- expect(markdown).toMatch(/-\s+Item 2/);
2249
- expect(markdown).not.toContain("[x]");
2250
- expect(markdown).not.toContain("[ ]");
2251
- });
2252
- });
2253
- // ===========================================================================
2254
- // Security Tests
2255
- // ===========================================================================
2256
- describe("sanitizeId", () => {
2257
- it("accepts valid CoreData IDs", () => {
2258
- const id = "x-coredata://12345ABC-DEF0-1234-5678-9ABCDEF01234/ICNote/p100";
2259
- expect(sanitizeId(id)).toBe(id);
2260
- });
2261
- it("accepts temp IDs from generateFallbackId", () => {
2262
- expect(sanitizeId("temp-1704067200000-0")).toBe("temp-1704067200000-0");
2263
- expect(sanitizeId("temp-1704067200000-42")).toBe("temp-1704067200000-42");
2264
- });
2265
- it("rejects IDs with AppleScript injection", () => {
2266
- expect(() => sanitizeId('x-coredata://test" & do shell script "rm -rf ~" & "')).toThrow("Invalid note ID format");
2267
- });
2268
- it("rejects IDs with double-quote breakout", () => {
2269
- expect(() => sanitizeId('x-coredata://test"; delete note id "dummy" & "')).toThrow("Invalid note ID format");
2270
- });
2271
- it("rejects arbitrary strings", () => {
2272
- expect(() => sanitizeId("not-a-valid-id")).toThrow("Invalid note ID format");
2273
- });
2274
- it("rejects empty string", () => {
2275
- expect(() => sanitizeId("")).toThrow("Invalid note ID format");
2276
- });
2277
- it("accepts various ICEntity types", () => {
2278
- expect(sanitizeId("x-coredata://ABC123/ICFolder/p50")).toBe("x-coredata://ABC123/ICFolder/p50");
2279
- expect(sanitizeId("x-coredata://ABC123/ICAttachment/p1")).toBe("x-coredata://ABC123/ICAttachment/p1");
2280
- });
2281
- });
2282
- describe("escapeForAppleScript - injection prevention", () => {
2283
- it("escapes double quotes to prevent AppleScript string breakout", () => {
2284
- const malicious = 'Hello "World" end tell';
2285
- const escaped = escapeForAppleScript(malicious);
2286
- expect(escaped).toContain('\\"');
2287
- expect(escaped).not.toContain('"World"');
2288
- });
2289
- it("escapes backslashes to prevent escape sequence injection", () => {
2290
- const malicious = "path\\to\\file";
2291
- const escaped = escapeForAppleScript(malicious);
2292
- // Backslashes should be encoded as HTML entities (&#92;)
2293
- expect(escaped).toContain("&#92;");
2294
- });
2295
- it("handles combined injection payload", () => {
2296
- const payload = '" & do shell script "echo pwned" & "';
2297
- const escaped = escapeForAppleScript(payload);
2298
- // All double quotes must be escaped with backslash
2299
- // Count unescaped double quotes — there should be none
2300
- const unescapedQuotes = escaped.replace(/\\"/g, "").match(/"/g);
2301
- expect(unescapedQuotes).toBeNull();
2302
- });
2303
- });
2304
- describe("buildFolderReference - input validation", () => {
2305
- it("rejects empty folder paths", () => {
2306
- expect(() => buildFolderReference("")).toThrow("Folder path is empty");
2307
- });
2308
- it("rejects paths that are only slashes", () => {
2309
- expect(() => buildFolderReference("///")).toThrow("Folder path is empty");
2310
- });
2311
- it("rejects excessively deep folder nesting", () => {
2312
- const deepPath = Array(25).fill("folder").join("/");
2313
- expect(() => buildFolderReference(deepPath)).toThrow("maximum nesting depth");
2314
- });
2315
- it("rejects excessively long folder paths", () => {
2316
- const longPath = "a".repeat(1001);
2317
- expect(() => buildFolderReference(longPath)).toThrow("maximum length");
2318
- });
2319
- it("escapes folder names with double quotes", () => {
2320
- const result = buildFolderReference('My "Special" Folder');
2321
- expect(result).toContain('\\"');
2322
- expect(result).not.toContain('"Special"');
2323
- });
2324
- it("handles folder names with emoji", () => {
2325
- const result = buildFolderReference("Food & Drink/\uD83C\uDF72 Recipes");
2326
- expect(result).toContain("folder");
2327
- expect(result).toContain("of");
2328
- });
2329
- });
2330
- describe("ID-based operations sanitize input", () => {
2331
- it("getNoteById rejects malformed IDs", () => {
2332
- expect(() => {
2333
- manager.getNoteById('malicious" & do shell script "echo pwned');
2334
- }).toThrow("Invalid note ID format");
2335
- });
2336
- it("deleteNoteById rejects malformed IDs", () => {
2337
- expect(() => {
2338
- manager.deleteNoteById('x-coredata://test"; delete note 1 & "');
2339
- }).toThrow("Invalid note ID format");
2340
- });
2341
- it("getNoteContentById rejects malformed IDs", () => {
2342
- expect(() => {
2343
- manager.getNoteContentById("arbitrary string");
2344
- }).toThrow("Invalid note ID format");
2345
- });
2346
- it("updateNoteById rejects malformed IDs", () => {
2347
- expect(() => {
2348
- manager.updateNoteById("not-valid", undefined, "content");
2349
- }).toThrow("Invalid note ID format");
2350
- });
2351
- });
2352
- });
2353
- describe("htmlToPlaintext (export helper)", () => {
2354
- // htmlToPlaintext is a private, pure string transform used by exportNote; it
2355
- // touches no AppleScript, so we exercise it directly through a cast.
2356
- const toPlaintext = (html) => new AppleNotesManager().htmlToPlaintext(html);
2357
- it("decodes the basic HTML entities", () => {
2358
- expect(toPlaintext("a &amp; b")).toBe("a & b");
2359
- expect(toPlaintext("&lt;tag&gt;")).toBe("<tag>");
2360
- expect(toPlaintext("say &quot;hi&quot;")).toBe('say "hi"');
2361
- expect(toPlaintext("path&#92;file")).toBe("path\\file");
2362
- expect(toPlaintext("a&nbsp;b")).toBe("a b");
2363
- });
2364
- it("decodes &amp; last so encoded entities round-trip (no double-unescape)", () => {
2365
- // The literal text "&lt;" is stored in HTML as "&amp;lt;" and must decode
2366
- // back to "&lt;", NOT be double-unescaped to "<".
2367
- expect(toPlaintext("&amp;lt;")).toBe("&lt;");
2368
- expect(toPlaintext("&amp;gt;")).toBe("&gt;");
2369
- expect(toPlaintext("&amp;amp;")).toBe("&amp;");
2370
- expect(toPlaintext("&amp;nbsp;")).toBe("&nbsp;");
2371
- });
2372
- it("converts block/line tags to newlines and strips other tags", () => {
2373
- expect(toPlaintext("one<br>two")).toBe("one\ntwo");
2374
- expect(toPlaintext("<div>a</div><div>b</div>")).toBe("a\nb");
2375
- expect(toPlaintext("<p>x</p><p>y</p>")).toBe("x\ny");
2376
- expect(toPlaintext("<b>bold</b>")).toBe("bold");
2377
- });
2378
- it("collapses 3+ newlines and trims surrounding whitespace", () => {
2379
- expect(toPlaintext("a<br><br><br><br>b")).toBe("a\n\nb");
2380
- expect(toPlaintext(" <div>x</div> ")).toBe("x");
2381
- });
2382
- it("strips nested/overlapping tags without leaving a tag (iterated strip)", () => {
2383
- // A single pass can leave residue when removing one tag re-forms another;
2384
- // the loop keeps stripping until no <...> remains.
2385
- expect(toPlaintext("a<<i>>b")).not.toMatch(/<[^>]*>/);
2386
- expect(toPlaintext("<x<y>z>")).not.toMatch(/<[^>]*>/);
2387
- expect(toPlaintext("plain <b>text</b> here")).toBe("plain text here");
2388
- });
2389
- });