apple-notes-mcp 2.5.7 → 2.5.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -5
- package/build/index.js +42669 -1077
- 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,259 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Apple Notes Checklist State Parser
|
|
3
|
-
*
|
|
4
|
-
* Reads checklist done/undone state by querying the NoteStore SQLite database
|
|
5
|
-
* and decoding the protobuf-encoded note content. This bypasses the AppleScript
|
|
6
|
-
* limitation where `body of note` strips checklist state information.
|
|
7
|
-
*
|
|
8
|
-
* Data flow:
|
|
9
|
-
* 1. Query NoteStore.sqlite for the gzipped protobuf blob (ZICNOTEDATA.ZDATA)
|
|
10
|
-
* 2. Decompress with gzip
|
|
11
|
-
* 3. Decode protobuf to extract text and attribute runs
|
|
12
|
-
* 4. Walk attribute runs to identify checklist items and their done state
|
|
13
|
-
*
|
|
14
|
-
* Protobuf field path:
|
|
15
|
-
* Document (root) → field 2 (Note) → field 3 (Note body)
|
|
16
|
-
* → field 2 (note_text: plain text)
|
|
17
|
-
* → field 5 (attribute_run: repeated styling runs)
|
|
18
|
-
* → field 1 (length)
|
|
19
|
-
* → field 2 (paragraph_style)
|
|
20
|
-
* → field 1 (style_type: 103 = checklist)
|
|
21
|
-
* → field 5 (checklist)
|
|
22
|
-
* → field 2 (done: 0 = unchecked, 1 = checked)
|
|
23
|
-
*
|
|
24
|
-
* @module utils/checklistParser
|
|
25
|
-
* @see https://github.com/sweetrb/apple-notes-mcp/issues/2
|
|
26
|
-
*/
|
|
27
|
-
import { execFileSync } from "child_process";
|
|
28
|
-
import * as zlib from "zlib";
|
|
29
|
-
import * as fs from "fs";
|
|
30
|
-
import * as path from "path";
|
|
31
|
-
import * as os from "os";
|
|
32
|
-
import { decodeMessage, getField, getFields, varintValue, stringValue, embeddedMessage, } from "../utils/protobuf.js";
|
|
33
|
-
/** Style type value for checklist items in Apple Notes protobuf format. */
|
|
34
|
-
const CHECKLIST_STYLE_TYPE = 103;
|
|
35
|
-
const NOTES_DB_PATH = path.join(os.homedir(), "Library/Group Containers/group.com.apple.notes/NoteStore.sqlite");
|
|
36
|
-
/**
|
|
37
|
-
* Checks whether the NoteStore database is accessible (Full Disk Access).
|
|
38
|
-
*
|
|
39
|
-
* @returns true if the database file exists and can be read
|
|
40
|
-
*/
|
|
41
|
-
export function hasFullDiskAccess() {
|
|
42
|
-
try {
|
|
43
|
-
if (!fs.existsSync(NOTES_DB_PATH))
|
|
44
|
-
return false;
|
|
45
|
-
// Try to open the database with a simple query
|
|
46
|
-
execFileSync("sqlite3", ["-readonly", NOTES_DB_PATH, "SELECT 1;"], {
|
|
47
|
-
encoding: "utf8",
|
|
48
|
-
timeout: 3000,
|
|
49
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
50
|
-
});
|
|
51
|
-
return true;
|
|
52
|
-
}
|
|
53
|
-
catch {
|
|
54
|
-
return false;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
/**
|
|
58
|
-
* Queries the NoteStore SQLite database for a note's raw ZDATA blob.
|
|
59
|
-
*
|
|
60
|
-
* Uses the note's CoreData identifier to find the corresponding protobuf data.
|
|
61
|
-
* The identifier is extracted from the full CoreData URL format:
|
|
62
|
-
* x-coredata://DEVICE-UUID/ICNote/pXXXX → pXXXX
|
|
63
|
-
*
|
|
64
|
-
* @param noteId - CoreData URL identifier (e.g., "x-coredata://ABC/ICNote/p123")
|
|
65
|
-
* @returns Object with hex data or error classification
|
|
66
|
-
*/
|
|
67
|
-
function queryNoteData(noteId) {
|
|
68
|
-
// Extract the primary key suffix (e.g., "p123" from "x-coredata://ABC/ICNote/p123")
|
|
69
|
-
const pkMatch = noteId.match(/\/p(\d+)$/);
|
|
70
|
-
if (!pkMatch) {
|
|
71
|
-
console.error(`Invalid note ID format: ${noteId}`);
|
|
72
|
-
return { hex: null, error: "invalid_id" };
|
|
73
|
-
}
|
|
74
|
-
const pk = pkMatch[1];
|
|
75
|
-
// Query for the gzipped protobuf data, output as hex for safe transport
|
|
76
|
-
const query = `SELECT hex(nd.ZDATA) FROM ZICNOTEDATA nd JOIN ZICCLOUDSYNCINGOBJECT n ON nd.ZNOTE = n.Z_PK WHERE n.Z_PK = ${pk};`;
|
|
77
|
-
try {
|
|
78
|
-
const result = execFileSync("sqlite3", ["-readonly", NOTES_DB_PATH, query], {
|
|
79
|
-
encoding: "utf8",
|
|
80
|
-
timeout: 5000,
|
|
81
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
82
|
-
});
|
|
83
|
-
const hex = result.trim();
|
|
84
|
-
if (!hex)
|
|
85
|
-
return { hex: null };
|
|
86
|
-
return { hex };
|
|
87
|
-
}
|
|
88
|
-
catch (error) {
|
|
89
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
90
|
-
console.error(`Failed to query NoteStore database: ${message}`);
|
|
91
|
-
// Detect Full Disk Access denial
|
|
92
|
-
if (message.includes("authorization denied") || message.includes("unable to open database")) {
|
|
93
|
-
return { hex: null, error: "no_fda" };
|
|
94
|
-
}
|
|
95
|
-
return { hex: null };
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
/**
|
|
99
|
-
* Converts a hex string to a Uint8Array.
|
|
100
|
-
*/
|
|
101
|
-
function hexToBytes(hex) {
|
|
102
|
-
const bytes = new Uint8Array(hex.length / 2);
|
|
103
|
-
for (let i = 0; i < hex.length; i += 2) {
|
|
104
|
-
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
|
105
|
-
}
|
|
106
|
-
return bytes;
|
|
107
|
-
}
|
|
108
|
-
/**
|
|
109
|
-
* Extracts checklist items from a protobuf-encoded note.
|
|
110
|
-
*
|
|
111
|
-
* Navigates the protobuf structure:
|
|
112
|
-
* Document → Note (field 2) → Note body (field 3)
|
|
113
|
-
* → note_text (field 2): plain text with \n line separators
|
|
114
|
-
* → attribute_run (field 5): repeated, sequential styling runs
|
|
115
|
-
*
|
|
116
|
-
* Walks attribute runs sequentially, tracking character position in the plain
|
|
117
|
-
* text. When a run has style_type == 103 (checklist), extracts the line text
|
|
118
|
-
* and done state.
|
|
119
|
-
*
|
|
120
|
-
* @param data - Decompressed protobuf bytes
|
|
121
|
-
* @returns Array of checklist items, or null if parsing fails
|
|
122
|
-
*/
|
|
123
|
-
function parseChecklistFromProtobuf(data) {
|
|
124
|
-
try {
|
|
125
|
-
// Document root
|
|
126
|
-
const docFields = decodeMessage(data);
|
|
127
|
-
// Field 2 = Note (Version/Document wrapper)
|
|
128
|
-
const noteWrapper = getField(docFields, 2);
|
|
129
|
-
const noteWrapperFields = embeddedMessage(noteWrapper);
|
|
130
|
-
if (!noteWrapperFields)
|
|
131
|
-
return null;
|
|
132
|
-
// Field 3 = Note body (the actual content)
|
|
133
|
-
const noteBody = getField(noteWrapperFields, 3);
|
|
134
|
-
const noteBodyFields = embeddedMessage(noteBody);
|
|
135
|
-
if (!noteBodyFields)
|
|
136
|
-
return null;
|
|
137
|
-
// Field 2 = note_text (plain text content)
|
|
138
|
-
const noteTextField = getField(noteBodyFields, 2);
|
|
139
|
-
const noteText = stringValue(noteTextField);
|
|
140
|
-
if (!noteText)
|
|
141
|
-
return null;
|
|
142
|
-
// Field 5 = attribute_run (repeated)
|
|
143
|
-
const attributeRuns = getFields(noteBodyFields, 5);
|
|
144
|
-
if (attributeRuns.length === 0)
|
|
145
|
-
return null;
|
|
146
|
-
// Split text into lines for mapping
|
|
147
|
-
const lines = noteText.split("\n");
|
|
148
|
-
// Walk attribute runs, tracking position in the text
|
|
149
|
-
const items = [];
|
|
150
|
-
let charPos = 0;
|
|
151
|
-
// Track which lines we've already added (multiple runs can cover the same line)
|
|
152
|
-
const seenLines = new Set();
|
|
153
|
-
for (const run of attributeRuns) {
|
|
154
|
-
const runFields = embeddedMessage(run);
|
|
155
|
-
if (!runFields)
|
|
156
|
-
continue;
|
|
157
|
-
// Field 1 = length (character count this run covers)
|
|
158
|
-
const lengthField = getField(runFields, 1);
|
|
159
|
-
const runLength = varintValue(lengthField) ?? 0;
|
|
160
|
-
// Field 2 = paragraph_style
|
|
161
|
-
const paragraphStyle = getField(runFields, 2);
|
|
162
|
-
const styleFields = embeddedMessage(paragraphStyle);
|
|
163
|
-
if (styleFields) {
|
|
164
|
-
// Field 1 = style_type
|
|
165
|
-
const styleType = varintValue(getField(styleFields, 1));
|
|
166
|
-
if (styleType === CHECKLIST_STYLE_TYPE) {
|
|
167
|
-
// Field 5 = checklist info
|
|
168
|
-
const checklistField = getField(styleFields, 5);
|
|
169
|
-
const checklistFields = embeddedMessage(checklistField);
|
|
170
|
-
// Field 2 = done (0 = unchecked, 1 = checked)
|
|
171
|
-
const done = checklistFields ? (varintValue(getField(checklistFields, 2)) ?? 0) : 0;
|
|
172
|
-
// Find which line this position corresponds to
|
|
173
|
-
let lineStart = 0;
|
|
174
|
-
for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) {
|
|
175
|
-
const lineEnd = lineStart + lines[lineIdx].length;
|
|
176
|
-
if (charPos >= lineStart && charPos < lineEnd + 1 && !seenLines.has(lineIdx)) {
|
|
177
|
-
seenLines.add(lineIdx);
|
|
178
|
-
items.push({
|
|
179
|
-
text: lines[lineIdx],
|
|
180
|
-
done: done === 1,
|
|
181
|
-
});
|
|
182
|
-
break;
|
|
183
|
-
}
|
|
184
|
-
lineStart = lineEnd + 1; // +1 for the \n
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
charPos += runLength;
|
|
189
|
-
}
|
|
190
|
-
return items;
|
|
191
|
-
}
|
|
192
|
-
catch (error) {
|
|
193
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
194
|
-
console.error(`Failed to parse protobuf checklist data: ${message}`);
|
|
195
|
-
return null;
|
|
196
|
-
}
|
|
197
|
-
}
|
|
198
|
-
/**
|
|
199
|
-
* Gets the checklist state for a note by its CoreData ID.
|
|
200
|
-
*
|
|
201
|
-
* This reads directly from the NoteStore SQLite database, bypassing
|
|
202
|
-
* AppleScript's limitation of stripping checklist state from `body of note`.
|
|
203
|
-
*
|
|
204
|
-
* Requires Full Disk Access to read the Notes database.
|
|
205
|
-
*
|
|
206
|
-
* @param noteId - CoreData URL identifier (e.g., "x-coredata://ABC/ICNote/p123")
|
|
207
|
-
* @returns Structured result with items, error type, and message
|
|
208
|
-
*/
|
|
209
|
-
export function getChecklistItems(noteId) {
|
|
210
|
-
// Query the database for raw note data
|
|
211
|
-
const { hex: hexData, error: queryError } = queryNoteData(noteId);
|
|
212
|
-
if (queryError === "invalid_id") {
|
|
213
|
-
return {
|
|
214
|
-
items: null,
|
|
215
|
-
error: "invalid_id",
|
|
216
|
-
message: `Invalid note ID format: "${noteId}". Expected format: x-coredata://UUID/ICNote/pNNN`,
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
if (queryError === "no_fda") {
|
|
220
|
-
return {
|
|
221
|
-
items: null,
|
|
222
|
-
error: "no_fda",
|
|
223
|
-
message: "Full Disk Access is required to read checklist state. " +
|
|
224
|
-
"Grant access in System Settings > Privacy & Security > Full Disk Access, " +
|
|
225
|
-
"then add and restart this application.",
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
if (!hexData) {
|
|
229
|
-
return {
|
|
230
|
-
items: null,
|
|
231
|
-
error: "no_checklists",
|
|
232
|
-
message: "No data found for this note in the database.",
|
|
233
|
-
};
|
|
234
|
-
}
|
|
235
|
-
// Convert hex to bytes and decompress
|
|
236
|
-
const compressedData = hexToBytes(hexData);
|
|
237
|
-
let decompressed;
|
|
238
|
-
try {
|
|
239
|
-
decompressed = zlib.gunzipSync(compressedData);
|
|
240
|
-
}
|
|
241
|
-
catch {
|
|
242
|
-
console.error("Failed to decompress note data — may not be gzip format");
|
|
243
|
-
return {
|
|
244
|
-
items: null,
|
|
245
|
-
error: "parse_error",
|
|
246
|
-
message: "Failed to decompress note data.",
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
// Parse protobuf and extract checklist items
|
|
250
|
-
const items = parseChecklistFromProtobuf(new Uint8Array(decompressed));
|
|
251
|
-
if (!items || items.length === 0) {
|
|
252
|
-
return {
|
|
253
|
-
items: null,
|
|
254
|
-
error: "no_checklists",
|
|
255
|
-
message: "This note does not contain any checklist items.",
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
return { items };
|
|
259
|
-
}
|
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the Apple Notes checklist state parser.
|
|
3
|
-
*
|
|
4
|
-
* These tests mock the SQLite database access and test the protobuf
|
|
5
|
-
* parsing logic with realistic test fixtures.
|
|
6
|
-
*/
|
|
7
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
8
|
-
import * as zlib from "zlib";
|
|
9
|
-
import { getChecklistItems, hasFullDiskAccess } from "./checklistParser.js";
|
|
10
|
-
// Mock child_process to avoid actual database access
|
|
11
|
-
vi.mock("child_process", () => ({
|
|
12
|
-
execFileSync: vi.fn(),
|
|
13
|
-
spawnSync: vi.fn(() => ({ error: null })),
|
|
14
|
-
}));
|
|
15
|
-
// Mock fs for database existence checks
|
|
16
|
-
vi.mock("fs", () => ({
|
|
17
|
-
existsSync: vi.fn(() => true),
|
|
18
|
-
statSync: vi.fn(() => ({ mtimeMs: Date.now() })),
|
|
19
|
-
}));
|
|
20
|
-
import { execFileSync } from "child_process";
|
|
21
|
-
import { existsSync } from "fs";
|
|
22
|
-
const mockExecSync = vi.mocked(execFileSync);
|
|
23
|
-
const mockExistsSync = vi.mocked(existsSync);
|
|
24
|
-
/**
|
|
25
|
-
* Builds a minimal Apple Notes protobuf structure with checklist items.
|
|
26
|
-
*
|
|
27
|
-
* Structure:
|
|
28
|
-
* Document (root)
|
|
29
|
-
* field 2 (Note wrapper)
|
|
30
|
-
* field 3 (Note body)
|
|
31
|
-
* field 2 (note_text: plain text)
|
|
32
|
-
* field 5 (attribute_run) - repeated
|
|
33
|
-
*/
|
|
34
|
-
function buildChecklistProtobuf(items) {
|
|
35
|
-
// Helper to encode a varint
|
|
36
|
-
function encodeVarint(value) {
|
|
37
|
-
const bytes = [];
|
|
38
|
-
while (value > 0x7f) {
|
|
39
|
-
bytes.push((value & 0x7f) | 0x80);
|
|
40
|
-
value >>>= 7;
|
|
41
|
-
}
|
|
42
|
-
bytes.push(value & 0x7f);
|
|
43
|
-
return bytes;
|
|
44
|
-
}
|
|
45
|
-
// Helper to encode a tag
|
|
46
|
-
function encodeTag(fieldNumber, wireType) {
|
|
47
|
-
return encodeVarint((fieldNumber << 3) | wireType);
|
|
48
|
-
}
|
|
49
|
-
// Helper to wrap bytes as a length-delimited field
|
|
50
|
-
function lengthDelimited(fieldNumber, data) {
|
|
51
|
-
const bytes = data instanceof Uint8Array ? Array.from(data) : data;
|
|
52
|
-
return [...encodeTag(fieldNumber, 2), ...encodeVarint(bytes.length), ...bytes];
|
|
53
|
-
}
|
|
54
|
-
// Helper to encode a varint field
|
|
55
|
-
function varintField(fieldNumber, value) {
|
|
56
|
-
return [...encodeTag(fieldNumber, 0), ...encodeVarint(value)];
|
|
57
|
-
}
|
|
58
|
-
// Build the plain text: "Title\nitem1\nitem2\n..."
|
|
59
|
-
const textLines = ["Checklist Note", ...items.map((i) => i.text)];
|
|
60
|
-
const noteText = textLines.join("\n");
|
|
61
|
-
const encoder = new TextEncoder();
|
|
62
|
-
// Build attribute runs
|
|
63
|
-
// First run: title line (not a checklist)
|
|
64
|
-
const titleLength = textLines[0].length + 1; // +1 for \n
|
|
65
|
-
const titleRun = lengthDelimited(5, [
|
|
66
|
-
...varintField(1, titleLength), // length
|
|
67
|
-
// No paragraph_style with checklist — just a regular paragraph
|
|
68
|
-
]);
|
|
69
|
-
// Checklist runs
|
|
70
|
-
const checklistRuns = [];
|
|
71
|
-
for (const item of items) {
|
|
72
|
-
const runLength = item.text.length + 1; // +1 for \n (or end of text)
|
|
73
|
-
// Build checklist info: field 2 = done
|
|
74
|
-
const checklistInfo = lengthDelimited(5, varintField(2, item.done ? 1 : 0));
|
|
75
|
-
// Build paragraph_style: field 1 = 103 (checklist), field 5 = checklist info
|
|
76
|
-
const paragraphStyle = lengthDelimited(2, [...varintField(1, 103), ...checklistInfo]);
|
|
77
|
-
// Build attribute run: field 1 = length, field 2 = paragraph_style
|
|
78
|
-
const run = lengthDelimited(5, [...varintField(1, runLength), ...paragraphStyle]);
|
|
79
|
-
checklistRuns.push(...run);
|
|
80
|
-
}
|
|
81
|
-
// Build note body (field 3):
|
|
82
|
-
// field 2 = note_text, field 5 = attribute_runs (already encoded above)
|
|
83
|
-
const noteTextField = lengthDelimited(2, encoder.encode(noteText));
|
|
84
|
-
const noteBody = lengthDelimited(3, [...noteTextField, ...titleRun, ...checklistRuns]);
|
|
85
|
-
// Build note wrapper (field 2 of document)
|
|
86
|
-
const noteWrapper = lengthDelimited(2, noteBody);
|
|
87
|
-
return new Uint8Array(noteWrapper);
|
|
88
|
-
}
|
|
89
|
-
describe("hasFullDiskAccess", () => {
|
|
90
|
-
beforeEach(() => {
|
|
91
|
-
vi.clearAllMocks();
|
|
92
|
-
});
|
|
93
|
-
it("returns true when database is accessible", () => {
|
|
94
|
-
mockExistsSync.mockReturnValue(true);
|
|
95
|
-
mockExecSync.mockReturnValue("1\n");
|
|
96
|
-
expect(hasFullDiskAccess()).toBe(true);
|
|
97
|
-
});
|
|
98
|
-
it("returns false when database file does not exist", () => {
|
|
99
|
-
mockExistsSync.mockReturnValue(false);
|
|
100
|
-
expect(hasFullDiskAccess()).toBe(false);
|
|
101
|
-
});
|
|
102
|
-
it("returns false when sqlite3 query fails", () => {
|
|
103
|
-
mockExistsSync.mockReturnValue(true);
|
|
104
|
-
mockExecSync.mockImplementation(() => {
|
|
105
|
-
throw new Error("authorization denied");
|
|
106
|
-
});
|
|
107
|
-
expect(hasFullDiskAccess()).toBe(false);
|
|
108
|
-
});
|
|
109
|
-
});
|
|
110
|
-
describe("getChecklistItems", () => {
|
|
111
|
-
beforeEach(() => {
|
|
112
|
-
vi.clearAllMocks();
|
|
113
|
-
});
|
|
114
|
-
it("returns error for invalid note ID format", () => {
|
|
115
|
-
const result = getChecklistItems("invalid-id");
|
|
116
|
-
expect(result.items).toBeNull();
|
|
117
|
-
expect(result.error).toBe("invalid_id");
|
|
118
|
-
});
|
|
119
|
-
it("returns no_checklists when database query returns empty", () => {
|
|
120
|
-
mockExecSync.mockReturnValue("");
|
|
121
|
-
const result = getChecklistItems("x-coredata://ABC/ICNote/p123");
|
|
122
|
-
expect(result.items).toBeNull();
|
|
123
|
-
expect(result.error).toBe("no_checklists");
|
|
124
|
-
});
|
|
125
|
-
it("parses checklist with mixed done/undone items", () => {
|
|
126
|
-
const items = [
|
|
127
|
-
{ text: "Buy milk", done: true },
|
|
128
|
-
{ text: "Walk dog", done: false },
|
|
129
|
-
{ text: "Send email", done: true },
|
|
130
|
-
];
|
|
131
|
-
const protobuf = buildChecklistProtobuf(items);
|
|
132
|
-
const compressed = zlib.gzipSync(Buffer.from(protobuf));
|
|
133
|
-
const hex = Buffer.from(compressed).toString("hex").toUpperCase();
|
|
134
|
-
mockExecSync.mockReturnValue((hex + "\n"));
|
|
135
|
-
const result = getChecklistItems("x-coredata://ABC/ICNote/p123");
|
|
136
|
-
expect(result.items).not.toBeNull();
|
|
137
|
-
expect(result.items).toHaveLength(3);
|
|
138
|
-
expect(result.items[0]).toEqual({ text: "Buy milk", done: true });
|
|
139
|
-
expect(result.items[1]).toEqual({ text: "Walk dog", done: false });
|
|
140
|
-
expect(result.items[2]).toEqual({ text: "Send email", done: true });
|
|
141
|
-
expect(result.error).toBeUndefined();
|
|
142
|
-
});
|
|
143
|
-
it("parses checklist with all items unchecked", () => {
|
|
144
|
-
const items = [
|
|
145
|
-
{ text: "Task A", done: false },
|
|
146
|
-
{ text: "Task B", done: false },
|
|
147
|
-
];
|
|
148
|
-
const protobuf = buildChecklistProtobuf(items);
|
|
149
|
-
const compressed = zlib.gzipSync(Buffer.from(protobuf));
|
|
150
|
-
const hex = Buffer.from(compressed).toString("hex").toUpperCase();
|
|
151
|
-
mockExecSync.mockReturnValue((hex + "\n"));
|
|
152
|
-
const result = getChecklistItems("x-coredata://ABC/ICNote/p456");
|
|
153
|
-
expect(result.items).not.toBeNull();
|
|
154
|
-
expect(result.items).toHaveLength(2);
|
|
155
|
-
expect(result.items.every((i) => !i.done)).toBe(true);
|
|
156
|
-
});
|
|
157
|
-
it("parses checklist with all items checked", () => {
|
|
158
|
-
const items = [
|
|
159
|
-
{ text: "Done 1", done: true },
|
|
160
|
-
{ text: "Done 2", done: true },
|
|
161
|
-
];
|
|
162
|
-
const protobuf = buildChecklistProtobuf(items);
|
|
163
|
-
const compressed = zlib.gzipSync(Buffer.from(protobuf));
|
|
164
|
-
const hex = Buffer.from(compressed).toString("hex").toUpperCase();
|
|
165
|
-
mockExecSync.mockReturnValue((hex + "\n"));
|
|
166
|
-
const result = getChecklistItems("x-coredata://ABC/ICNote/p789");
|
|
167
|
-
expect(result.items).not.toBeNull();
|
|
168
|
-
expect(result.items).toHaveLength(2);
|
|
169
|
-
expect(result.items.every((i) => i.done)).toBe(true);
|
|
170
|
-
});
|
|
171
|
-
it("returns null when note has no checklist items", () => {
|
|
172
|
-
// Build a protobuf with no checklist style_type
|
|
173
|
-
const encoder = new TextEncoder();
|
|
174
|
-
// Minimal note with just text, no checklist runs
|
|
175
|
-
// Field 2 (note text) = "Just a regular note"
|
|
176
|
-
const noteText = encoder.encode("Just a regular note");
|
|
177
|
-
const noteTextField = [0x12, noteText.length, ...noteText]; // field 2, length-delimited
|
|
178
|
-
// A non-checklist attribute run (style_type = 0, regular paragraph)
|
|
179
|
-
const regularRun = [
|
|
180
|
-
0x2a, // field 5, length-delimited (attribute_run)
|
|
181
|
-
0x04, // length 4
|
|
182
|
-
0x08,
|
|
183
|
-
0x13, // field 1 (length) = 19
|
|
184
|
-
0x12,
|
|
185
|
-
0x00, // field 2 (paragraph_style) = empty
|
|
186
|
-
];
|
|
187
|
-
const noteBody = [
|
|
188
|
-
0x1a, // field 3, length-delimited
|
|
189
|
-
noteTextField.length + regularRun.length,
|
|
190
|
-
...noteTextField,
|
|
191
|
-
...regularRun,
|
|
192
|
-
];
|
|
193
|
-
const noteWrapper = [0x12, noteBody.length, ...noteBody]; // field 2
|
|
194
|
-
const compressed = zlib.gzipSync(Buffer.from(new Uint8Array(noteWrapper)));
|
|
195
|
-
const hex = Buffer.from(compressed).toString("hex").toUpperCase();
|
|
196
|
-
mockExecSync.mockReturnValue((hex + "\n"));
|
|
197
|
-
const result = getChecklistItems("x-coredata://ABC/ICNote/p100");
|
|
198
|
-
expect(result.items).toBeNull();
|
|
199
|
-
expect(result.error).toBe("no_checklists");
|
|
200
|
-
});
|
|
201
|
-
it("returns null when sqlite3 command fails", () => {
|
|
202
|
-
mockExecSync.mockImplementation(() => {
|
|
203
|
-
throw new Error("database is locked");
|
|
204
|
-
});
|
|
205
|
-
const result = getChecklistItems("x-coredata://ABC/ICNote/p123");
|
|
206
|
-
expect(result.items).toBeNull();
|
|
207
|
-
});
|
|
208
|
-
it("returns no_fda error when authorization is denied", () => {
|
|
209
|
-
mockExecSync.mockImplementation(() => {
|
|
210
|
-
throw new Error("unable to open database: authorization denied");
|
|
211
|
-
});
|
|
212
|
-
const result = getChecklistItems("x-coredata://ABC/ICNote/p123");
|
|
213
|
-
expect(result.items).toBeNull();
|
|
214
|
-
expect(result.error).toBe("no_fda");
|
|
215
|
-
expect(result.message).toContain("Full Disk Access");
|
|
216
|
-
expect(result.message).toContain("System Settings");
|
|
217
|
-
});
|
|
218
|
-
it("returns parse_error for non-gzip data", () => {
|
|
219
|
-
// Return valid hex that isn't gzip
|
|
220
|
-
mockExecSync.mockReturnValue("DEADBEEF\n");
|
|
221
|
-
const result = getChecklistItems("x-coredata://ABC/ICNote/p123");
|
|
222
|
-
expect(result.items).toBeNull();
|
|
223
|
-
expect(result.error).toBe("parse_error");
|
|
224
|
-
});
|
|
225
|
-
it("extracts correct primary key from note ID", () => {
|
|
226
|
-
mockExecSync.mockReturnValue("");
|
|
227
|
-
getChecklistItems("x-coredata://12345-ABCDE/ICNote/p42");
|
|
228
|
-
expect(mockExecSync).toHaveBeenCalledWith("sqlite3", expect.arrayContaining([expect.stringContaining("Z_PK = 42")]), expect.any(Object));
|
|
229
|
-
});
|
|
230
|
-
});
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Content Warnings for create-note / update-note
|
|
3
|
-
*
|
|
4
|
-
* Detects content patterns that look like the user is trying to do something
|
|
5
|
-
* Apple Notes via AppleScript cannot actually render — so the response can
|
|
6
|
-
* carry a clear warning instead of silently producing a broken note.
|
|
7
|
-
*
|
|
8
|
-
* @module utils/contentWarnings
|
|
9
|
-
*/
|
|
10
|
-
/**
|
|
11
|
-
* Detects checklist-like syntax in note content.
|
|
12
|
-
*
|
|
13
|
-
* Apple Notes checklists are a paragraph style stored in a protobuf blob in
|
|
14
|
-
* the NoteStore SQLite database. AppleScript's `body of note` setter does not
|
|
15
|
-
* expose paragraph styles: `<input type="checkbox">` is stripped, a
|
|
16
|
-
* `class="checklist"` on `<ul>` is dropped, and markdown `- [ ]` lines in
|
|
17
|
-
* `plaintext` mode arrive as literal text. There is no input that produces a
|
|
18
|
-
* real checklist.
|
|
19
|
-
*
|
|
20
|
-
* @param content - The user-supplied note body (HTML or plaintext)
|
|
21
|
-
* @returns A user-facing warning string, or null when no checklist-like
|
|
22
|
-
* patterns are present
|
|
23
|
-
*/
|
|
24
|
-
export function detectChecklistAttempt(content) {
|
|
25
|
-
if (!content)
|
|
26
|
-
return null;
|
|
27
|
-
// HTML checkbox input — `<input type="checkbox" ...>` in either quoting style.
|
|
28
|
-
const htmlCheckbox = /<input\b[^>]*\btype\s*=\s*["']checkbox["']/i.test(content);
|
|
29
|
-
// Markdown-style checklist: lines starting with optional whitespace, then
|
|
30
|
-
// `-` or `*`, a space, and `[ ]` / `[x]` / `[X]`.
|
|
31
|
-
const markdownCheckbox = /^[ \t]*[-*]\s+\[[ xX]\]/m.test(content);
|
|
32
|
-
// CSS class hint — some clients try `<ul class="checklist">` or
|
|
33
|
-
// `<li class="todo">`. AppleScript drops these classes too.
|
|
34
|
-
const checklistClass = /class\s*=\s*["'][^"']*\b(?:checklist|todo)\b/i.test(content);
|
|
35
|
-
if (!htmlCheckbox && !markdownCheckbox && !checklistClass)
|
|
36
|
-
return null;
|
|
37
|
-
return ("\n\n⚠️ Your content looks like a checklist, but Apple Notes checklists " +
|
|
38
|
-
'cannot be created via AppleScript — `<input type="checkbox">` is ' +
|
|
39
|
-
"stripped, checklist CSS classes are dropped, and markdown `- [ ]` lines " +
|
|
40
|
-
"arrive as literal text. The note was created with the surrounding " +
|
|
41
|
-
"structure (list items or paragraphs) intact. To convert it to a real " +
|
|
42
|
-
"Apple Notes checklist, open the note, select the items, and press " +
|
|
43
|
-
"⇧⌘L (Format → Checklist).");
|
|
44
|
-
}
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tests for the content-warning detectors.
|
|
3
|
-
*/
|
|
4
|
-
import { describe, it, expect } from "vitest";
|
|
5
|
-
import { detectChecklistAttempt } from "./contentWarnings.js";
|
|
6
|
-
describe("detectChecklistAttempt", () => {
|
|
7
|
-
it("returns null for plain text without checklist syntax", () => {
|
|
8
|
-
expect(detectChecklistAttempt("Just a regular note.")).toBeNull();
|
|
9
|
-
});
|
|
10
|
-
it("returns null for empty content", () => {
|
|
11
|
-
expect(detectChecklistAttempt("")).toBeNull();
|
|
12
|
-
});
|
|
13
|
-
it("returns null for HTML lists that are not checklists", () => {
|
|
14
|
-
expect(detectChecklistAttempt("<ul><li>Apple</li><li>Banana</li></ul>")).toBeNull();
|
|
15
|
-
});
|
|
16
|
-
it('warns on <input type="checkbox"> (double-quoted)', () => {
|
|
17
|
-
const w = detectChecklistAttempt('<input type="checkbox"> Buy milk');
|
|
18
|
-
expect(w).not.toBeNull();
|
|
19
|
-
expect(w).toContain("⚠️");
|
|
20
|
-
expect(w).toContain("⇧⌘L");
|
|
21
|
-
});
|
|
22
|
-
it("warns on <input type='checkbox'> (single-quoted)", () => {
|
|
23
|
-
expect(detectChecklistAttempt("<input type='checkbox'> Buy milk")).not.toBeNull();
|
|
24
|
-
});
|
|
25
|
-
it("warns on <input> with extra attributes before type", () => {
|
|
26
|
-
expect(detectChecklistAttempt('<input id="x" type="checkbox"> Item')).not.toBeNull();
|
|
27
|
-
});
|
|
28
|
-
it('warns on <INPUT TYPE="CHECKBOX"> (case-insensitive)', () => {
|
|
29
|
-
expect(detectChecklistAttempt('<INPUT TYPE="CHECKBOX"> Item')).not.toBeNull();
|
|
30
|
-
});
|
|
31
|
-
it("warns on markdown `- [ ]` syntax", () => {
|
|
32
|
-
expect(detectChecklistAttempt("- [ ] todo 1\n- [x] done 1")).not.toBeNull();
|
|
33
|
-
});
|
|
34
|
-
it("warns on markdown `* [ ]` syntax", () => {
|
|
35
|
-
expect(detectChecklistAttempt("* [ ] todo 1")).not.toBeNull();
|
|
36
|
-
});
|
|
37
|
-
it("warns on markdown checklist with leading whitespace", () => {
|
|
38
|
-
expect(detectChecklistAttempt(" - [ ] indented todo")).not.toBeNull();
|
|
39
|
-
});
|
|
40
|
-
it('warns on <ul class="checklist">', () => {
|
|
41
|
-
expect(detectChecklistAttempt('<ul class="checklist"><li>a</li></ul>')).not.toBeNull();
|
|
42
|
-
});
|
|
43
|
-
it('warns on <li class="todo">', () => {
|
|
44
|
-
expect(detectChecklistAttempt('<ul><li class="todo">a</li></ul>')).not.toBeNull();
|
|
45
|
-
});
|
|
46
|
-
it("does not warn on the word 'checklist' in prose", () => {
|
|
47
|
-
expect(detectChecklistAttempt("My checklist of things to do tomorrow.")).toBeNull();
|
|
48
|
-
});
|
|
49
|
-
it("does not warn on a literal `[ ]` not at start of a list line", () => {
|
|
50
|
-
expect(detectChecklistAttempt("The brackets [ ] are not a checklist.")).toBeNull();
|
|
51
|
-
});
|
|
52
|
-
});
|
package/build/utils/hashtags.js
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Inline hashtag extraction for Apple Notes.
|
|
3
|
-
*
|
|
4
|
-
* Apple Notes "tags" are not a first-class AppleScript property — they are
|
|
5
|
-
* inline `#hashtag` tokens typed into the note body. Notes stores the tag
|
|
6
|
-
* relationship in its private Core Data store, which AppleScript does not
|
|
7
|
-
* expose, so the only way to surface a note's tags is to parse them back out
|
|
8
|
-
* of the body text. This module does exactly that.
|
|
9
|
-
*
|
|
10
|
-
* See docs/APPLESCRIPT-LIMITATIONS.md and issue #29.
|
|
11
|
-
*/
|
|
12
|
-
/**
|
|
13
|
-
* Strip HTML tags from a Notes body and neutralise numeric character
|
|
14
|
-
* references so they can't masquerade as hashtags (e.g. `’`).
|
|
15
|
-
*/
|
|
16
|
-
function htmlToText(html) {
|
|
17
|
-
return html
|
|
18
|
-
.replace(/<[^>]*>/g, " ") // drop tags
|
|
19
|
-
.replace(/&#x?[0-9a-f]+;/gi, " ") // neutralise numeric entities (’)
|
|
20
|
-
.replace(/&[a-z]+;/gi, " "); // neutralise named entities (& )
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* A hashtag token: `#` followed by a run of letters/digits/underscores that
|
|
24
|
-
* contains at least one letter. This matches Apple Notes' own rule — a purely
|
|
25
|
-
* numeric token like `#123` is NOT treated as a tag. The token must not be
|
|
26
|
-
* preceded by a word character, so `foo#bar` and URL fragments like
|
|
27
|
-
* `page.html#section` (preceded by a letter) are ignored.
|
|
28
|
-
*/
|
|
29
|
-
const HASHTAG_RE = /(?<![\p{L}\p{N}_])#([\p{L}\p{N}_]*\p{L}[\p{L}\p{N}_]*)/gu;
|
|
30
|
-
/**
|
|
31
|
-
* Extract inline `#hashtag` tokens from a note body (HTML or plain text).
|
|
32
|
-
*
|
|
33
|
-
* - HTML is stripped first, so tags inside `<div>#work</div>` are found.
|
|
34
|
-
* - Pure-number tokens (`#123`) are ignored, matching Notes' behaviour.
|
|
35
|
-
* - Results are de-duplicated case-insensitively, preserving the first-seen
|
|
36
|
-
* casing and document order. The leading `#` is not included.
|
|
37
|
-
*
|
|
38
|
-
* @param body - The note body, as HTML or plain text.
|
|
39
|
-
* @returns Ordered, de-duplicated tag names without the leading `#`.
|
|
40
|
-
*/
|
|
41
|
-
export function parseHashtags(body) {
|
|
42
|
-
if (!body)
|
|
43
|
-
return [];
|
|
44
|
-
const text = htmlToText(body);
|
|
45
|
-
const seen = new Set();
|
|
46
|
-
const result = [];
|
|
47
|
-
for (const match of text.matchAll(HASHTAG_RE)) {
|
|
48
|
-
const tag = match[1];
|
|
49
|
-
const key = tag.toLowerCase();
|
|
50
|
-
if (!seen.has(key)) {
|
|
51
|
-
seen.add(key);
|
|
52
|
-
result.push(tag);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return result;
|
|
56
|
-
}
|