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.
Files changed (34) hide show
  1. package/README.md +8 -5
  2. package/build/index.js +42669 -1077
  3. package/package.json +3 -3
  4. package/build/index.test.js +0 -446
  5. package/build/services/__fixtures__/notesNormalizedHtml.js +0 -32
  6. package/build/services/appleNotesManager.js +0 -2634
  7. package/build/services/appleNotesManager.test.js +0 -2416
  8. package/build/services/attachmentSave.test.js +0 -85
  9. package/build/services/fileConfig.js +0 -51
  10. package/build/services/fileConfig.test.js +0 -48
  11. package/build/services/notesHtmlMarkdown.test.js +0 -55
  12. package/build/tools/doctor.js +0 -50
  13. package/build/tools/doctor.test.js +0 -42
  14. package/build/tools/resourcesAndPrompts.js +0 -70
  15. package/build/tools/resourcesAndPrompts.test.js +0 -63
  16. package/build/types.js +0 -13
  17. package/build/utils/applescript.js +0 -421
  18. package/build/utils/applescript.test.js +0 -342
  19. package/build/utils/attachmentFs.js +0 -97
  20. package/build/utils/attachmentFs.test.js +0 -69
  21. package/build/utils/checklistParser.js +0 -259
  22. package/build/utils/checklistParser.test.js +0 -230
  23. package/build/utils/contentWarnings.js +0 -44
  24. package/build/utils/contentWarnings.test.js +0 -52
  25. package/build/utils/hashtags.js +0 -56
  26. package/build/utils/hashtags.test.js +0 -45
  27. package/build/utils/jxa.js +0 -139
  28. package/build/utils/jxa.test.js +0 -134
  29. package/build/utils/noteMetadata.js +0 -135
  30. package/build/utils/noteMetadata.test.js +0 -106
  31. package/build/utils/protobuf.js +0 -151
  32. package/build/utils/protobuf.test.js +0 -138
  33. package/build/utils/syncDetection.js +0 -242
  34. package/build/utils/syncDetection.test.js +0 -228
@@ -1,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
- });
@@ -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. `&#8217;`).
15
- */
16
- function htmlToText(html) {
17
- return html
18
- .replace(/<[^>]*>/g, " ") // drop tags
19
- .replace(/&#x?[0-9a-f]+;/gi, " ") // neutralise numeric entities (&#8217;)
20
- .replace(/&[a-z]+;/gi, " "); // neutralise named entities (&amp; &nbsp;)
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
- }