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