@disco_trooper/apple-notes-mcp 1.0.0
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/CLAUDE.md +56 -0
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/package.json +61 -0
- package/src/config/constants.ts +41 -0
- package/src/config/env.test.ts +58 -0
- package/src/config/env.ts +25 -0
- package/src/db/lancedb.test.ts +141 -0
- package/src/db/lancedb.ts +263 -0
- package/src/db/validation.test.ts +76 -0
- package/src/db/validation.ts +57 -0
- package/src/embeddings/index.test.ts +54 -0
- package/src/embeddings/index.ts +111 -0
- package/src/embeddings/local.test.ts +70 -0
- package/src/embeddings/local.ts +191 -0
- package/src/embeddings/openrouter.test.ts +21 -0
- package/src/embeddings/openrouter.ts +285 -0
- package/src/index.ts +387 -0
- package/src/notes/crud.test.ts +199 -0
- package/src/notes/crud.ts +257 -0
- package/src/notes/read.test.ts +131 -0
- package/src/notes/read.ts +504 -0
- package/src/search/index.test.ts +52 -0
- package/src/search/index.ts +283 -0
- package/src/search/indexer.test.ts +42 -0
- package/src/search/indexer.ts +335 -0
- package/src/server.ts +386 -0
- package/src/setup.ts +540 -0
- package/src/types/index.ts +39 -0
- package/src/utils/debug.test.ts +41 -0
- package/src/utils/debug.ts +51 -0
- package/src/utils/errors.test.ts +29 -0
- package/src/utils/errors.ts +46 -0
- package/src/utils/text.ts +23 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apple Notes CRUD operations using JXA (JavaScript for Automation).
|
|
3
|
+
*
|
|
4
|
+
* All write operations respect READONLY_MODE environment variable.
|
|
5
|
+
* Content is converted from Markdown to HTML before writing to Apple Notes.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { runJxa } from "run-jxa";
|
|
9
|
+
import { marked } from "marked";
|
|
10
|
+
import { resolveNoteTitle } from "./read.js";
|
|
11
|
+
import { createDebugLogger } from "../utils/debug.js";
|
|
12
|
+
|
|
13
|
+
// Debug logging
|
|
14
|
+
const debug = createDebugLogger("CRUD");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Check if READONLY_MODE is enabled and throw if so.
|
|
18
|
+
* Call this at the start of every write operation.
|
|
19
|
+
*
|
|
20
|
+
* @throws Error if READONLY_MODE=true
|
|
21
|
+
*/
|
|
22
|
+
export function checkReadOnly(): void {
|
|
23
|
+
if (process.env.READONLY_MODE === "true") {
|
|
24
|
+
throw new Error("Operation disabled in read-only mode");
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Convert Markdown content to HTML for Apple Notes.
|
|
30
|
+
*
|
|
31
|
+
* @param markdown - Markdown content
|
|
32
|
+
* @returns HTML string
|
|
33
|
+
*/
|
|
34
|
+
function markdownToHtml(markdown: string): string {
|
|
35
|
+
// Configure marked for clean HTML output
|
|
36
|
+
const html = marked.parse(markdown, {
|
|
37
|
+
async: false,
|
|
38
|
+
gfm: true,
|
|
39
|
+
breaks: true,
|
|
40
|
+
}) as string;
|
|
41
|
+
|
|
42
|
+
return html;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Create a new note in Apple Notes.
|
|
47
|
+
*
|
|
48
|
+
* @param title - Note title
|
|
49
|
+
* @param content - Note content (Markdown)
|
|
50
|
+
* @param folder - Optional target folder (defaults to Notes)
|
|
51
|
+
* @throws Error if READONLY_MODE is enabled
|
|
52
|
+
* @throws Error if note with same title already exists
|
|
53
|
+
*/
|
|
54
|
+
export async function createNote(
|
|
55
|
+
title: string,
|
|
56
|
+
content: string,
|
|
57
|
+
folder?: string
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
checkReadOnly();
|
|
60
|
+
|
|
61
|
+
debug(`Creating note: "${title}" in folder: "${folder || "Notes"}"`);
|
|
62
|
+
|
|
63
|
+
// Convert Markdown to HTML
|
|
64
|
+
const htmlContent = markdownToHtml(content);
|
|
65
|
+
const escapedTitle = JSON.stringify(title);
|
|
66
|
+
const escapedContent = JSON.stringify(htmlContent);
|
|
67
|
+
const escapedFolder = folder ? JSON.stringify(folder) : "null";
|
|
68
|
+
|
|
69
|
+
debug(`HTML content length: ${htmlContent.length}`);
|
|
70
|
+
|
|
71
|
+
await runJxa(`
|
|
72
|
+
const app = Application('Notes');
|
|
73
|
+
const title = ${escapedTitle};
|
|
74
|
+
const content = ${escapedContent};
|
|
75
|
+
const folderName = ${escapedFolder};
|
|
76
|
+
|
|
77
|
+
let targetFolder = null;
|
|
78
|
+
|
|
79
|
+
if (folderName) {
|
|
80
|
+
// Find the specified folder
|
|
81
|
+
const folders = app.folders.whose({name: folderName})();
|
|
82
|
+
if (folders.length === 0) {
|
|
83
|
+
throw new Error("Folder not found: " + folderName);
|
|
84
|
+
}
|
|
85
|
+
targetFolder = folders[0];
|
|
86
|
+
} else {
|
|
87
|
+
// Use default folder (first account's default folder)
|
|
88
|
+
targetFolder = app.defaultAccount().defaultFolder();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Create the note in the target folder
|
|
92
|
+
const note = app.Note({name: title, body: content});
|
|
93
|
+
targetFolder.notes.push(note);
|
|
94
|
+
|
|
95
|
+
return "ok";
|
|
96
|
+
`);
|
|
97
|
+
|
|
98
|
+
debug(`Note created: "${title}"`);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Update an existing note's content.
|
|
103
|
+
*
|
|
104
|
+
* @param title - Note title (supports folder prefix: "Work/My Note")
|
|
105
|
+
* @param content - New content (Markdown)
|
|
106
|
+
* @throws Error if READONLY_MODE is enabled
|
|
107
|
+
* @throws Error if note not found or duplicate titles without folder prefix
|
|
108
|
+
*/
|
|
109
|
+
export async function updateNote(title: string, content: string): Promise<void> {
|
|
110
|
+
checkReadOnly();
|
|
111
|
+
|
|
112
|
+
debug(`Updating note: "${title}"`);
|
|
113
|
+
|
|
114
|
+
// Resolve the note to get its ID
|
|
115
|
+
const resolved = await resolveNoteTitle(title);
|
|
116
|
+
|
|
117
|
+
if (!resolved.success || !resolved.note) {
|
|
118
|
+
if (resolved.suggestions && resolved.suggestions.length > 0) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`${resolved.error} Suggestions: ${resolved.suggestions.join(", ")}`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
throw new Error(resolved.error || `Note not found: "${title}"`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Convert Markdown to HTML
|
|
127
|
+
const htmlContent = markdownToHtml(content);
|
|
128
|
+
const escapedNoteId = JSON.stringify(resolved.note.id);
|
|
129
|
+
const escapedContent = JSON.stringify(htmlContent);
|
|
130
|
+
|
|
131
|
+
debug(`HTML content length: ${htmlContent.length}`);
|
|
132
|
+
|
|
133
|
+
await runJxa(`
|
|
134
|
+
const app = Application('Notes');
|
|
135
|
+
const noteId = ${escapedNoteId};
|
|
136
|
+
const content = ${escapedContent};
|
|
137
|
+
|
|
138
|
+
// Find the note by ID
|
|
139
|
+
const note = app.notes.byId(noteId);
|
|
140
|
+
|
|
141
|
+
if (!note.exists()) {
|
|
142
|
+
throw new Error("Note no longer exists");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Update the body
|
|
146
|
+
note.body = content;
|
|
147
|
+
|
|
148
|
+
return "ok";
|
|
149
|
+
`);
|
|
150
|
+
|
|
151
|
+
debug(`Note updated: "${title}"`);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Delete a note from Apple Notes.
|
|
156
|
+
*
|
|
157
|
+
* IMPORTANT: The caller must verify that confirm=true before calling this function.
|
|
158
|
+
* This function does NOT check the confirm parameter.
|
|
159
|
+
*
|
|
160
|
+
* @param title - Note title (supports folder prefix: "Work/My Note")
|
|
161
|
+
* @throws Error if READONLY_MODE is enabled
|
|
162
|
+
* @throws Error if note not found or duplicate titles without folder prefix
|
|
163
|
+
*/
|
|
164
|
+
export async function deleteNote(title: string): Promise<void> {
|
|
165
|
+
checkReadOnly();
|
|
166
|
+
|
|
167
|
+
debug(`Deleting note: "${title}"`);
|
|
168
|
+
|
|
169
|
+
// Resolve the note to get its ID
|
|
170
|
+
const resolved = await resolveNoteTitle(title);
|
|
171
|
+
|
|
172
|
+
if (!resolved.success || !resolved.note) {
|
|
173
|
+
if (resolved.suggestions && resolved.suggestions.length > 0) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`${resolved.error} Suggestions: ${resolved.suggestions.join(", ")}`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
throw new Error(resolved.error || `Note not found: "${title}"`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const escapedNoteId = JSON.stringify(resolved.note.id);
|
|
182
|
+
|
|
183
|
+
await runJxa(`
|
|
184
|
+
const app = Application('Notes');
|
|
185
|
+
const noteId = ${escapedNoteId};
|
|
186
|
+
|
|
187
|
+
// Find the note by ID
|
|
188
|
+
const note = app.notes.byId(noteId);
|
|
189
|
+
|
|
190
|
+
if (!note.exists()) {
|
|
191
|
+
throw new Error("Note no longer exists");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Delete the note
|
|
195
|
+
note.delete();
|
|
196
|
+
|
|
197
|
+
return "ok";
|
|
198
|
+
`);
|
|
199
|
+
|
|
200
|
+
debug(`Note deleted: "${title}"`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Move a note to a different folder.
|
|
205
|
+
*
|
|
206
|
+
* @param title - Note title (supports folder prefix: "Work/My Note")
|
|
207
|
+
* @param folder - Target folder name
|
|
208
|
+
* @throws Error if READONLY_MODE is enabled
|
|
209
|
+
* @throws Error if note not found or target folder not found
|
|
210
|
+
*/
|
|
211
|
+
export async function moveNote(title: string, folder: string): Promise<void> {
|
|
212
|
+
checkReadOnly();
|
|
213
|
+
|
|
214
|
+
debug(`Moving note: "${title}" to folder: "${folder}"`);
|
|
215
|
+
|
|
216
|
+
// Resolve the note to get its ID
|
|
217
|
+
const resolved = await resolveNoteTitle(title);
|
|
218
|
+
|
|
219
|
+
if (!resolved.success || !resolved.note) {
|
|
220
|
+
if (resolved.suggestions && resolved.suggestions.length > 0) {
|
|
221
|
+
throw new Error(
|
|
222
|
+
`${resolved.error} Suggestions: ${resolved.suggestions.join(", ")}`
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
throw new Error(resolved.error || `Note not found: "${title}"`);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const escapedNoteId = JSON.stringify(resolved.note.id);
|
|
229
|
+
const escapedFolder = JSON.stringify(folder);
|
|
230
|
+
|
|
231
|
+
await runJxa(`
|
|
232
|
+
const app = Application('Notes');
|
|
233
|
+
const noteId = ${escapedNoteId};
|
|
234
|
+
const folderName = ${escapedFolder};
|
|
235
|
+
|
|
236
|
+
// Find the target folder
|
|
237
|
+
const folders = app.folders.whose({name: folderName})();
|
|
238
|
+
if (folders.length === 0) {
|
|
239
|
+
throw new Error("Folder not found: " + folderName);
|
|
240
|
+
}
|
|
241
|
+
const targetFolder = folders[0];
|
|
242
|
+
|
|
243
|
+
// Find the note by ID
|
|
244
|
+
const note = app.notes.byId(noteId);
|
|
245
|
+
|
|
246
|
+
if (!note.exists()) {
|
|
247
|
+
throw new Error("Note no longer exists");
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Move the note to the target folder
|
|
251
|
+
note.move({to: targetFolder});
|
|
252
|
+
|
|
253
|
+
return "ok";
|
|
254
|
+
`);
|
|
255
|
+
|
|
256
|
+
debug(`Note moved: "${title}" -> "${folder}"`);
|
|
257
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock run-jxa before importing read module
|
|
4
|
+
vi.mock("run-jxa", () => ({
|
|
5
|
+
runJxa: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
import { runJxa } from "run-jxa";
|
|
9
|
+
import { getAllNotes, getNoteByTitle, getAllFolders, resolveNoteTitle } from "./read.js";
|
|
10
|
+
|
|
11
|
+
describe("getAllNotes", () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.clearAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should return empty array when no notes exist", async () => {
|
|
17
|
+
vi.mocked(runJxa).mockResolvedValueOnce("[]");
|
|
18
|
+
|
|
19
|
+
const notes = await getAllNotes();
|
|
20
|
+
expect(notes).toEqual([]);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should return notes with metadata", async () => {
|
|
24
|
+
const mockNotes = [
|
|
25
|
+
{ title: "Note 1", folder: "Work", created: "2024-01-01T00:00:00Z", modified: "2024-01-02T00:00:00Z" },
|
|
26
|
+
{ title: "Note 2", folder: "Personal", created: "2024-01-03T00:00:00Z", modified: "2024-01-04T00:00:00Z" },
|
|
27
|
+
];
|
|
28
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
|
|
29
|
+
|
|
30
|
+
const notes = await getAllNotes();
|
|
31
|
+
expect(notes).toHaveLength(2);
|
|
32
|
+
expect(notes[0].title).toBe("Note 1");
|
|
33
|
+
expect(notes[1].folder).toBe("Personal");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("getNoteByTitle", () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.clearAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("should return null when note not found", async () => {
|
|
43
|
+
vi.mocked(runJxa).mockResolvedValueOnce("[]");
|
|
44
|
+
|
|
45
|
+
const note = await getNoteByTitle("Missing Note");
|
|
46
|
+
expect(note).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should return note with content", async () => {
|
|
50
|
+
const mockNotes = [{
|
|
51
|
+
id: "123",
|
|
52
|
+
title: "My Note",
|
|
53
|
+
folder: "Work",
|
|
54
|
+
created: "2024-01-01T00:00:00Z",
|
|
55
|
+
modified: "2024-01-02T00:00:00Z",
|
|
56
|
+
htmlContent: "<p>Hello World</p>",
|
|
57
|
+
}];
|
|
58
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
|
|
59
|
+
|
|
60
|
+
const note = await getNoteByTitle("My Note");
|
|
61
|
+
expect(note).not.toBeNull();
|
|
62
|
+
expect(note?.title).toBe("My Note");
|
|
63
|
+
expect(note?.content).toContain("Hello World");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("should handle folder/title format", async () => {
|
|
67
|
+
const mockNotes = [{
|
|
68
|
+
id: "123",
|
|
69
|
+
title: "Note",
|
|
70
|
+
folder: "Work",
|
|
71
|
+
created: "2024-01-01T00:00:00Z",
|
|
72
|
+
modified: "2024-01-02T00:00:00Z",
|
|
73
|
+
htmlContent: "<p>Content</p>",
|
|
74
|
+
}];
|
|
75
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
|
|
76
|
+
|
|
77
|
+
const note = await getNoteByTitle("Work/Note");
|
|
78
|
+
expect(note).not.toBeNull();
|
|
79
|
+
expect(note?.folder).toBe("Work");
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("getAllFolders", () => {
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
vi.clearAllMocks();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("should return folder names", async () => {
|
|
89
|
+
const mockFolders = ["Work", "Personal", "Archive"];
|
|
90
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockFolders));
|
|
91
|
+
|
|
92
|
+
const folders = await getAllFolders();
|
|
93
|
+
expect(folders).toEqual(["Work", "Personal", "Archive"]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("resolveNoteTitle", () => {
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
vi.clearAllMocks();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("should return error when no notes found", async () => {
|
|
103
|
+
vi.mocked(runJxa).mockResolvedValueOnce("[]");
|
|
104
|
+
|
|
105
|
+
const result = await resolveNoteTitle("Missing");
|
|
106
|
+
expect(result.success).toBe(false);
|
|
107
|
+
expect(result.error).toContain("not found");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("should return note when exactly one match", async () => {
|
|
111
|
+
const mockNotes = [{ id: "123", title: "My Note", folder: "Work" }];
|
|
112
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
|
|
113
|
+
|
|
114
|
+
const result = await resolveNoteTitle("My Note");
|
|
115
|
+
expect(result.success).toBe(true);
|
|
116
|
+
expect(result.note?.id).toBe("123");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("should return suggestions when multiple matches", async () => {
|
|
120
|
+
const mockNotes = [
|
|
121
|
+
{ id: "123", title: "Note", folder: "Work" },
|
|
122
|
+
{ id: "456", title: "Note", folder: "Personal" },
|
|
123
|
+
];
|
|
124
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
|
|
125
|
+
|
|
126
|
+
const result = await resolveNoteTitle("Note");
|
|
127
|
+
expect(result.success).toBe(false);
|
|
128
|
+
expect(result.suggestions).toHaveLength(2);
|
|
129
|
+
expect(result.suggestions).toContain("Work/Note");
|
|
130
|
+
});
|
|
131
|
+
});
|