@disco_trooper/apple-notes-mcp 1.0.1 → 1.2.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/package.json +2 -5
- package/src/db/lancedb.test.ts +45 -0
- package/src/db/lancedb.ts +40 -31
- package/src/embeddings/index.ts +1 -2
- package/src/embeddings/local.ts +0 -11
- package/src/errors/index.test.ts +64 -0
- package/src/errors/index.ts +62 -0
- package/src/index.ts +60 -14
- package/src/notes/conversion.ts +62 -0
- package/src/notes/crud.test.ts +76 -7
- package/src/notes/crud.ts +95 -46
- package/src/notes/read.test.ts +58 -3
- package/src/notes/read.ts +90 -173
- package/src/notes/resolve.ts +174 -0
- package/src/notes/tables.test.ts +70 -0
- package/src/notes/tables.ts +175 -0
- package/src/search/index.ts +4 -6
- package/src/search/indexer.ts +16 -3
- package/src/types/index.ts +4 -0
- package/src/utils/debug.ts +0 -2
- package/src/utils/text.test.ts +32 -0
- package/CLAUDE.md +0 -56
- package/src/server.ts +0 -386
package/src/notes/crud.test.ts
CHANGED
|
@@ -22,9 +22,15 @@ vi.mock("../utils/debug.js", () => ({
|
|
|
22
22
|
createDebugLogger: vi.fn(() => vi.fn()),
|
|
23
23
|
}));
|
|
24
24
|
|
|
25
|
+
vi.mock("./tables.js", () => ({
|
|
26
|
+
findTables: vi.fn(),
|
|
27
|
+
updateTableCell: vi.fn(),
|
|
28
|
+
}));
|
|
29
|
+
|
|
25
30
|
import { runJxa } from "run-jxa";
|
|
26
|
-
import { checkReadOnly, createNote, updateNote, deleteNote, moveNote } from "./crud.js";
|
|
31
|
+
import { checkReadOnly, createNote, updateNote, deleteNote, moveNote, editTable } from "./crud.js";
|
|
27
32
|
import { resolveNoteTitle } from "./read.js";
|
|
33
|
+
import { findTables, updateTableCell } from "./tables.js";
|
|
28
34
|
|
|
29
35
|
describe("checkReadOnly", () => {
|
|
30
36
|
const originalEnv = process.env.READONLY_MODE;
|
|
@@ -114,9 +120,12 @@ describe("updateNote", () => {
|
|
|
114
120
|
vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
|
|
115
121
|
success: false,
|
|
116
122
|
error: "Multiple notes found",
|
|
117
|
-
suggestions: [
|
|
123
|
+
suggestions: [
|
|
124
|
+
{ id: "id-1", folder: "Work", title: "Note", created: "2026-01-09T10:00:00.000Z" },
|
|
125
|
+
{ id: "id-2", folder: "Personal", title: "Note", created: "2026-01-09T11:00:00.000Z" },
|
|
126
|
+
],
|
|
118
127
|
});
|
|
119
|
-
await expect(updateNote("Note", "Content")).rejects.toThrow("
|
|
128
|
+
await expect(updateNote("Note", "Content")).rejects.toThrow("Use ID prefix");
|
|
120
129
|
});
|
|
121
130
|
});
|
|
122
131
|
|
|
@@ -153,9 +162,12 @@ describe("deleteNote", () => {
|
|
|
153
162
|
vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
|
|
154
163
|
success: false,
|
|
155
164
|
error: "Multiple notes found",
|
|
156
|
-
suggestions: [
|
|
165
|
+
suggestions: [
|
|
166
|
+
{ id: "id-1", folder: "Work", title: "Note", created: "2026-01-09T10:00:00.000Z" },
|
|
167
|
+
{ id: "id-2", folder: "Personal", title: "Note", created: "2026-01-09T11:00:00.000Z" },
|
|
168
|
+
],
|
|
157
169
|
});
|
|
158
|
-
await expect(deleteNote("Note")).rejects.toThrow("
|
|
170
|
+
await expect(deleteNote("Note")).rejects.toThrow("Use ID prefix");
|
|
159
171
|
});
|
|
160
172
|
});
|
|
161
173
|
|
|
@@ -192,8 +204,65 @@ describe("moveNote", () => {
|
|
|
192
204
|
vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
|
|
193
205
|
success: false,
|
|
194
206
|
error: "Multiple notes found",
|
|
195
|
-
suggestions: [
|
|
207
|
+
suggestions: [
|
|
208
|
+
{ id: "id-1", folder: "Work", title: "Note", created: "2026-01-09T10:00:00.000Z" },
|
|
209
|
+
{ id: "id-2", folder: "Personal", title: "Note", created: "2026-01-09T11:00:00.000Z" },
|
|
210
|
+
],
|
|
211
|
+
});
|
|
212
|
+
await expect(moveNote("Note", "Archive")).rejects.toThrow("Use ID prefix");
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("editTable", () => {
|
|
217
|
+
beforeEach(() => {
|
|
218
|
+
vi.clearAllMocks();
|
|
219
|
+
delete process.env.READONLY_MODE;
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("should throw if READONLY_MODE is enabled", async () => {
|
|
223
|
+
process.env.READONLY_MODE = "true";
|
|
224
|
+
await expect(editTable("Test", 0, [])).rejects.toThrow("read-only mode");
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("should throw if note not found", async () => {
|
|
228
|
+
vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
|
|
229
|
+
success: false,
|
|
230
|
+
error: "Note not found",
|
|
231
|
+
});
|
|
232
|
+
await expect(editTable("Missing", 0, [{ row: 0, column: 0, value: "x" }])).rejects.toThrow("Note not found");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("should throw if table index out of bounds", async () => {
|
|
236
|
+
vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
|
|
237
|
+
success: true,
|
|
238
|
+
note: { id: "123", title: "Test", folder: "Work" },
|
|
239
|
+
});
|
|
240
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify({ html: "<div>No tables</div>" }));
|
|
241
|
+
vi.mocked(findTables).mockReturnValueOnce([]);
|
|
242
|
+
|
|
243
|
+
await expect(editTable("Test", 0, [{ row: 0, column: 0, value: "x" }]))
|
|
244
|
+
.rejects.toThrow("Table index 0 out of bounds");
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("should update table cells and save", async () => {
|
|
248
|
+
vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
|
|
249
|
+
success: true,
|
|
250
|
+
note: { id: "123", title: "Test", folder: "Work" },
|
|
196
251
|
});
|
|
197
|
-
|
|
252
|
+
vi.mocked(runJxa)
|
|
253
|
+
.mockResolvedValueOnce(JSON.stringify({ html: "<div><object><table></table></object></div>" }))
|
|
254
|
+
.mockResolvedValueOnce("ok");
|
|
255
|
+
vi.mocked(findTables).mockReturnValueOnce(["<object><table></table></object>"]);
|
|
256
|
+
vi.mocked(updateTableCell).mockReturnValueOnce("<object><table>updated</table></object>");
|
|
257
|
+
|
|
258
|
+
await expect(editTable("Test", 0, [{ row: 1, column: 0, value: "✅ Done" }]))
|
|
259
|
+
.resolves.toBeUndefined();
|
|
260
|
+
|
|
261
|
+
expect(updateTableCell).toHaveBeenCalledWith(
|
|
262
|
+
"<object><table></table></object>",
|
|
263
|
+
1,
|
|
264
|
+
0,
|
|
265
|
+
"✅ Done"
|
|
266
|
+
);
|
|
198
267
|
});
|
|
199
268
|
});
|
package/src/notes/crud.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { runJxa } from "run-jxa";
|
|
|
9
9
|
import { marked } from "marked";
|
|
10
10
|
import { resolveNoteTitle } from "./read.js";
|
|
11
11
|
import { createDebugLogger } from "../utils/debug.js";
|
|
12
|
+
import { findTables, updateTableCell } from "./tables.js";
|
|
13
|
+
import { ReadOnlyModeError, NoteNotFoundError, DuplicateNoteError } from "../errors/index.js";
|
|
12
14
|
|
|
13
15
|
// Debug logging
|
|
14
16
|
const debug = createDebugLogger("CRUD");
|
|
@@ -21,25 +23,40 @@ const debug = createDebugLogger("CRUD");
|
|
|
21
23
|
*/
|
|
22
24
|
export function checkReadOnly(): void {
|
|
23
25
|
if (process.env.READONLY_MODE === "true") {
|
|
24
|
-
throw new
|
|
26
|
+
throw new ReadOnlyModeError();
|
|
25
27
|
}
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
/**
|
|
29
|
-
*
|
|
31
|
+
* Resolve note title and throw if not found.
|
|
32
|
+
* Consolidates the repeated error handling pattern.
|
|
30
33
|
*
|
|
31
|
-
* @param
|
|
32
|
-
* @returns
|
|
34
|
+
* @param title - Note title (supports folder prefix)
|
|
35
|
+
* @returns The resolved note with id, title, and folder
|
|
36
|
+
* @throws Error if note not found or duplicates exist without folder prefix
|
|
37
|
+
*/
|
|
38
|
+
async function resolveNoteOrThrow(title: string): Promise<{ id: string; title: string; folder: string }> {
|
|
39
|
+
const resolved = await resolveNoteTitle(title);
|
|
40
|
+
|
|
41
|
+
if (!resolved.success || !resolved.note) {
|
|
42
|
+
if (resolved.suggestions && resolved.suggestions.length > 0) {
|
|
43
|
+
throw new DuplicateNoteError(title, resolved.suggestions);
|
|
44
|
+
}
|
|
45
|
+
throw new NoteNotFoundError(title);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return resolved.note;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Convert Markdown content to HTML for Apple Notes.
|
|
33
53
|
*/
|
|
34
54
|
function markdownToHtml(markdown: string): string {
|
|
35
|
-
|
|
36
|
-
const html = marked.parse(markdown, {
|
|
55
|
+
return marked.parse(markdown, {
|
|
37
56
|
async: false,
|
|
38
57
|
gfm: true,
|
|
39
58
|
breaks: true,
|
|
40
59
|
}) as string;
|
|
41
|
-
|
|
42
|
-
return html;
|
|
43
60
|
}
|
|
44
61
|
|
|
45
62
|
/**
|
|
@@ -111,21 +128,11 @@ export async function updateNote(title: string, content: string): Promise<void>
|
|
|
111
128
|
|
|
112
129
|
debug(`Updating note: "${title}"`);
|
|
113
130
|
|
|
114
|
-
|
|
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
|
-
}
|
|
131
|
+
const note = await resolveNoteOrThrow(title);
|
|
125
132
|
|
|
126
133
|
// Convert Markdown to HTML
|
|
127
134
|
const htmlContent = markdownToHtml(content);
|
|
128
|
-
const escapedNoteId = JSON.stringify(
|
|
135
|
+
const escapedNoteId = JSON.stringify(note.id);
|
|
129
136
|
const escapedContent = JSON.stringify(htmlContent);
|
|
130
137
|
|
|
131
138
|
debug(`HTML content length: ${htmlContent.length}`);
|
|
@@ -166,19 +173,8 @@ export async function deleteNote(title: string): Promise<void> {
|
|
|
166
173
|
|
|
167
174
|
debug(`Deleting note: "${title}"`);
|
|
168
175
|
|
|
169
|
-
|
|
170
|
-
const
|
|
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);
|
|
176
|
+
const note = await resolveNoteOrThrow(title);
|
|
177
|
+
const escapedNoteId = JSON.stringify(note.id);
|
|
182
178
|
|
|
183
179
|
await runJxa(`
|
|
184
180
|
const app = Application('Notes');
|
|
@@ -213,19 +209,8 @@ export async function moveNote(title: string, folder: string): Promise<void> {
|
|
|
213
209
|
|
|
214
210
|
debug(`Moving note: "${title}" to folder: "${folder}"`);
|
|
215
211
|
|
|
216
|
-
|
|
217
|
-
const
|
|
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);
|
|
212
|
+
const note = await resolveNoteOrThrow(title);
|
|
213
|
+
const escapedNoteId = JSON.stringify(note.id);
|
|
229
214
|
const escapedFolder = JSON.stringify(folder);
|
|
230
215
|
|
|
231
216
|
await runJxa(`
|
|
@@ -255,3 +240,67 @@ export async function moveNote(title: string, folder: string): Promise<void> {
|
|
|
255
240
|
|
|
256
241
|
debug(`Note moved: "${title}" -> "${folder}"`);
|
|
257
242
|
}
|
|
243
|
+
|
|
244
|
+
export interface TableEdit {
|
|
245
|
+
row: number;
|
|
246
|
+
column: number;
|
|
247
|
+
value: string;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Edit cells in a table within a note.
|
|
252
|
+
*
|
|
253
|
+
* @param title - Note title (supports folder prefix)
|
|
254
|
+
* @param tableIndex - Which table to edit (0-based)
|
|
255
|
+
* @param edits - Array of cell edits to apply
|
|
256
|
+
*/
|
|
257
|
+
export async function editTable(
|
|
258
|
+
title: string,
|
|
259
|
+
tableIndex: number,
|
|
260
|
+
edits: TableEdit[]
|
|
261
|
+
): Promise<void> {
|
|
262
|
+
checkReadOnly();
|
|
263
|
+
|
|
264
|
+
debug(`Editing table ${tableIndex} in note: "${title}"`);
|
|
265
|
+
|
|
266
|
+
const note = await resolveNoteOrThrow(title);
|
|
267
|
+
const escapedNoteId = JSON.stringify(note.id);
|
|
268
|
+
const htmlResult = await runJxa(`
|
|
269
|
+
const app = Application('Notes');
|
|
270
|
+
const note = app.notes.byId(${escapedNoteId});
|
|
271
|
+
if (!note.exists()) {
|
|
272
|
+
throw new Error("Note no longer exists");
|
|
273
|
+
}
|
|
274
|
+
return JSON.stringify({ html: note.body() });
|
|
275
|
+
`);
|
|
276
|
+
|
|
277
|
+
const { html } = JSON.parse(htmlResult as string);
|
|
278
|
+
|
|
279
|
+
// Find all tables
|
|
280
|
+
const tables = findTables(html);
|
|
281
|
+
if (tableIndex >= tables.length) {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`Table index ${tableIndex} out of bounds (note has ${tables.length} tables)`
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Apply edits to the target table
|
|
288
|
+
let updatedTable = tables[tableIndex];
|
|
289
|
+
for (const edit of edits) {
|
|
290
|
+
updatedTable = updateTableCell(updatedTable, edit.row, edit.column, edit.value);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Replace the table in the full HTML
|
|
294
|
+
const updatedHtml = html.replace(tables[tableIndex], updatedTable);
|
|
295
|
+
|
|
296
|
+
// Save back to Apple Notes
|
|
297
|
+
const escapedHtml = JSON.stringify(updatedHtml);
|
|
298
|
+
await runJxa(`
|
|
299
|
+
const app = Application('Notes');
|
|
300
|
+
const note = app.notes.byId(${escapedNoteId});
|
|
301
|
+
note.body = ${escapedHtml};
|
|
302
|
+
return "ok";
|
|
303
|
+
`);
|
|
304
|
+
|
|
305
|
+
debug(`Table ${tableIndex} updated in note: "${title}"`);
|
|
306
|
+
}
|
package/src/notes/read.test.ts
CHANGED
|
@@ -118,14 +118,69 @@ describe("resolveNoteTitle", () => {
|
|
|
118
118
|
|
|
119
119
|
it("should return suggestions when multiple matches", async () => {
|
|
120
120
|
const mockNotes = [
|
|
121
|
-
{ id: "123", title: "Note", folder: "Work" },
|
|
122
|
-
{ id: "456", title: "Note", folder: "Personal" },
|
|
121
|
+
{ id: "123", title: "Note", folder: "Work", created: "2026-01-09T10:00:00.000Z" },
|
|
122
|
+
{ id: "456", title: "Note", folder: "Personal", created: "2026-01-09T11:00:00.000Z" },
|
|
123
123
|
];
|
|
124
124
|
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNotes));
|
|
125
125
|
|
|
126
126
|
const result = await resolveNoteTitle("Note");
|
|
127
127
|
expect(result.success).toBe(false);
|
|
128
128
|
expect(result.suggestions).toHaveLength(2);
|
|
129
|
-
expect(result.suggestions).
|
|
129
|
+
expect(result.suggestions?.[0].id).toBe("123");
|
|
130
|
+
expect(result.suggestions?.[0].folder).toBe("Work");
|
|
131
|
+
expect(result.suggestions?.[1].id).toBe("456");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should resolve note by ID prefix", async () => {
|
|
135
|
+
const mockNote = {
|
|
136
|
+
id: "x-coredata://123",
|
|
137
|
+
title: "ID Note",
|
|
138
|
+
folder: "Work",
|
|
139
|
+
created: "2026-01-09T10:00:00.000Z",
|
|
140
|
+
modified: "2026-01-09T11:00:00.000Z",
|
|
141
|
+
htmlContent: "<p>Content</p>",
|
|
142
|
+
};
|
|
143
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNote));
|
|
144
|
+
|
|
145
|
+
const result = await resolveNoteTitle("id:x-coredata://123");
|
|
146
|
+
expect(result.success).toBe(true);
|
|
147
|
+
expect(result.note?.id).toBe("x-coredata://123");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("should return error for invalid ID", async () => {
|
|
151
|
+
vi.mocked(runJxa).mockResolvedValueOnce("null");
|
|
152
|
+
|
|
153
|
+
const result = await resolveNoteTitle("id:invalid-id");
|
|
154
|
+
expect(result.success).toBe(false);
|
|
155
|
+
expect(result.error).toContain("not found");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("getNoteByTitle with ID prefix", () => {
|
|
160
|
+
beforeEach(() => {
|
|
161
|
+
vi.clearAllMocks();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("should route id: prefix to ID lookup", async () => {
|
|
165
|
+
const mockNote = {
|
|
166
|
+
id: "x-coredata://abc",
|
|
167
|
+
title: "My Note",
|
|
168
|
+
folder: "Work",
|
|
169
|
+
created: "2026-01-09T10:00:00.000Z",
|
|
170
|
+
modified: "2026-01-09T11:00:00.000Z",
|
|
171
|
+
htmlContent: "<p>Hello</p>",
|
|
172
|
+
};
|
|
173
|
+
vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify(mockNote));
|
|
174
|
+
|
|
175
|
+
const note = await getNoteByTitle("id:x-coredata://abc");
|
|
176
|
+
expect(note).not.toBeNull();
|
|
177
|
+
expect(note?.id).toBe("x-coredata://abc");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("should return null for non-existent ID", async () => {
|
|
181
|
+
vi.mocked(runJxa).mockResolvedValueOnce("null");
|
|
182
|
+
|
|
183
|
+
const note = await getNoteByTitle("id:nonexistent");
|
|
184
|
+
expect(note).toBeNull();
|
|
130
185
|
});
|
|
131
186
|
});
|
package/src/notes/read.ts
CHANGED
|
@@ -6,39 +6,15 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { runJxa } from "run-jxa";
|
|
9
|
-
import TurndownService from "turndown";
|
|
10
9
|
import { createDebugLogger } from "../utils/debug.js";
|
|
10
|
+
import { htmlToMarkdown } from "./conversion.js";
|
|
11
|
+
|
|
12
|
+
// Re-export for backwards compatibility
|
|
13
|
+
export { resolveNoteTitle, type ResolvedNote } from "./resolve.js";
|
|
11
14
|
|
|
12
15
|
// Debug logging
|
|
13
16
|
const debug = createDebugLogger("NOTES");
|
|
14
17
|
|
|
15
|
-
// Initialize Turndown for HTML to Markdown conversion
|
|
16
|
-
const turndownService = new TurndownService({
|
|
17
|
-
headingStyle: "atx",
|
|
18
|
-
codeBlockStyle: "fenced",
|
|
19
|
-
bulletListMarker: "-",
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
// Add custom rule to handle Apple Notes attachment placeholders
|
|
23
|
-
turndownService.addRule("attachments", {
|
|
24
|
-
filter: (node) => {
|
|
25
|
-
// Apple Notes uses object tags for attachments
|
|
26
|
-
return node.nodeName === "OBJECT" || node.nodeName === "IMG";
|
|
27
|
-
},
|
|
28
|
-
replacement: (_content, node) => {
|
|
29
|
-
// Turndown uses its own Node type, cast to access attributes
|
|
30
|
-
const element = node as unknown as {
|
|
31
|
-
getAttribute: (name: string) => string | null;
|
|
32
|
-
};
|
|
33
|
-
const filename =
|
|
34
|
-
element.getAttribute("data-filename") ||
|
|
35
|
-
element.getAttribute("alt") ||
|
|
36
|
-
element.getAttribute("src")?.split("/").pop() ||
|
|
37
|
-
"unknown";
|
|
38
|
-
return `[Attachment: ${filename}]`;
|
|
39
|
-
},
|
|
40
|
-
});
|
|
41
|
-
|
|
42
18
|
// -----------------------------------------------------------------------------
|
|
43
19
|
// Types
|
|
44
20
|
// -----------------------------------------------------------------------------
|
|
@@ -63,21 +39,6 @@ export interface NoteDetails extends NoteInfo {
|
|
|
63
39
|
id: string;
|
|
64
40
|
}
|
|
65
41
|
|
|
66
|
-
export interface ResolvedNote {
|
|
67
|
-
/** Whether resolution was successful */
|
|
68
|
-
success: boolean;
|
|
69
|
-
/** The matched note (if exactly one match) */
|
|
70
|
-
note?: {
|
|
71
|
-
title: string;
|
|
72
|
-
folder: string;
|
|
73
|
-
id: string;
|
|
74
|
-
};
|
|
75
|
-
/** Error message if resolution failed */
|
|
76
|
-
error?: string;
|
|
77
|
-
/** Suggestions when multiple matches found */
|
|
78
|
-
suggestions?: string[];
|
|
79
|
-
}
|
|
80
|
-
|
|
81
42
|
// -----------------------------------------------------------------------------
|
|
82
43
|
// Internal JXA helpers
|
|
83
44
|
// -----------------------------------------------------------------------------
|
|
@@ -97,36 +58,6 @@ async function executeJxa<T>(code: string): Promise<T> {
|
|
|
97
58
|
}
|
|
98
59
|
}
|
|
99
60
|
|
|
100
|
-
/**
|
|
101
|
-
* Convert HTML content to Markdown, handling Apple Notes specifics
|
|
102
|
-
*/
|
|
103
|
-
function htmlToMarkdown(html: string): string {
|
|
104
|
-
if (!html) return "";
|
|
105
|
-
|
|
106
|
-
// Pre-process: handle Apple Notes specific markup
|
|
107
|
-
let processed = html;
|
|
108
|
-
|
|
109
|
-
// Replace attachment objects with placeholder text before Turndown
|
|
110
|
-
processed = processed.replace(
|
|
111
|
-
/<object[^>]*data-filename="([^"]*)"[^>]*>.*?<\/object>/gi,
|
|
112
|
-
"[Attachment: $1]"
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
// Handle inline images
|
|
116
|
-
processed = processed.replace(
|
|
117
|
-
/<img[^>]*(?:alt="([^"]*)")?[^>]*>/gi,
|
|
118
|
-
(_match, alt) => {
|
|
119
|
-
const filename = alt || "image";
|
|
120
|
-
return `[Attachment: ${filename}]`;
|
|
121
|
-
}
|
|
122
|
-
);
|
|
123
|
-
|
|
124
|
-
// Convert to Markdown
|
|
125
|
-
const markdown = turndownService.turndown(processed);
|
|
126
|
-
|
|
127
|
-
return markdown.trim();
|
|
128
|
-
}
|
|
129
|
-
|
|
130
61
|
// -----------------------------------------------------------------------------
|
|
131
62
|
// Public API
|
|
132
63
|
// -----------------------------------------------------------------------------
|
|
@@ -178,7 +109,7 @@ export async function getAllNotes(): Promise<NoteInfo[]> {
|
|
|
178
109
|
/**
|
|
179
110
|
* Get a note by its title, with full content
|
|
180
111
|
*
|
|
181
|
-
* @param title - The note title (can be "folder/title"
|
|
112
|
+
* @param title - The note title (can be "folder/title" or "id:xxx" format)
|
|
182
113
|
* @returns Note details with content, or null if not found
|
|
183
114
|
*/
|
|
184
115
|
export async function getNoteByTitle(
|
|
@@ -186,6 +117,13 @@ export async function getNoteByTitle(
|
|
|
186
117
|
): Promise<NoteDetails | null> {
|
|
187
118
|
debug(`Getting note by title: ${title}`);
|
|
188
119
|
|
|
120
|
+
// Check for id:xxx format for direct ID lookup
|
|
121
|
+
if (title.startsWith("id:")) {
|
|
122
|
+
const noteId = title.slice(3);
|
|
123
|
+
debug(`ID prefix detected, looking up note by ID: ${noteId}`);
|
|
124
|
+
return getNoteById(noteId);
|
|
125
|
+
}
|
|
126
|
+
|
|
189
127
|
// Check for folder/title format
|
|
190
128
|
let targetFolder: string | null = null;
|
|
191
129
|
let targetTitle = title;
|
|
@@ -281,6 +219,84 @@ export async function getNoteByTitle(
|
|
|
281
219
|
};
|
|
282
220
|
}
|
|
283
221
|
|
|
222
|
+
/**
|
|
223
|
+
* Get a note by its Apple Notes ID.
|
|
224
|
+
* Use this for precise access when title-based lookup is ambiguous.
|
|
225
|
+
*
|
|
226
|
+
* @param id - The Apple Notes unique identifier
|
|
227
|
+
* @returns Note details with content, or null if not found
|
|
228
|
+
*/
|
|
229
|
+
export async function getNoteById(id: string): Promise<NoteDetails | null> {
|
|
230
|
+
debug(`Getting note by ID: ${id}`);
|
|
231
|
+
|
|
232
|
+
const escapedId = JSON.stringify(id);
|
|
233
|
+
|
|
234
|
+
const jxaCode = `
|
|
235
|
+
const app = Application('Notes');
|
|
236
|
+
app.includeStandardAdditions = true;
|
|
237
|
+
|
|
238
|
+
const targetId = ${escapedId};
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
const note = app.notes.byId(targetId);
|
|
242
|
+
const props = note.properties();
|
|
243
|
+
|
|
244
|
+
// Find the folder this note belongs to
|
|
245
|
+
let folderName = 'Notes';
|
|
246
|
+
const folders = app.folders();
|
|
247
|
+
for (const folder of folders) {
|
|
248
|
+
const notes = folder.notes();
|
|
249
|
+
for (let i = 0; i < notes.length; i++) {
|
|
250
|
+
if (notes[i].id() === targetId) {
|
|
251
|
+
folderName = folder.name();
|
|
252
|
+
break;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return JSON.stringify({
|
|
258
|
+
id: note.id(),
|
|
259
|
+
title: props.name || '',
|
|
260
|
+
folder: folderName,
|
|
261
|
+
created: props.creationDate ? props.creationDate.toISOString() : '',
|
|
262
|
+
modified: props.modificationDate ? props.modificationDate.toISOString() : '',
|
|
263
|
+
htmlContent: note.body()
|
|
264
|
+
});
|
|
265
|
+
} catch (e) {
|
|
266
|
+
return JSON.stringify(null);
|
|
267
|
+
}
|
|
268
|
+
`;
|
|
269
|
+
|
|
270
|
+
const result = await executeJxa<string>(jxaCode);
|
|
271
|
+
const note = JSON.parse(result) as {
|
|
272
|
+
id: string;
|
|
273
|
+
title: string;
|
|
274
|
+
folder: string;
|
|
275
|
+
created: string;
|
|
276
|
+
modified: string;
|
|
277
|
+
htmlContent: string;
|
|
278
|
+
} | null;
|
|
279
|
+
|
|
280
|
+
if (!note) {
|
|
281
|
+
debug("Note not found by ID");
|
|
282
|
+
return null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const content = htmlToMarkdown(note.htmlContent);
|
|
286
|
+
|
|
287
|
+
debug(`Found note: ${note.title} in folder: ${note.folder}`);
|
|
288
|
+
|
|
289
|
+
return {
|
|
290
|
+
id: note.id,
|
|
291
|
+
title: note.title,
|
|
292
|
+
folder: note.folder,
|
|
293
|
+
created: note.created,
|
|
294
|
+
modified: note.modified,
|
|
295
|
+
content,
|
|
296
|
+
htmlContent: note.htmlContent,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
284
300
|
/**
|
|
285
301
|
* Get a note by explicit folder and title (no "/" parsing).
|
|
286
302
|
* Use this when you have folder and title separately to avoid
|
|
@@ -403,102 +419,3 @@ export async function getAllFolders(): Promise<string[]> {
|
|
|
403
419
|
debug(`Found ${folders.length} folders`);
|
|
404
420
|
return folders;
|
|
405
421
|
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Resolve a note title input to a unique note
|
|
409
|
-
*
|
|
410
|
-
* Handles:
|
|
411
|
-
* - Exact title match
|
|
412
|
-
* - "folder/title" format for disambiguation
|
|
413
|
-
* - Returns suggestions when multiple matches exist
|
|
414
|
-
*
|
|
415
|
-
* @param input - The note title or "folder/title" string
|
|
416
|
-
* @returns Resolution result with success status, note info, or suggestions
|
|
417
|
-
*/
|
|
418
|
-
export async function resolveNoteTitle(input: string): Promise<ResolvedNote> {
|
|
419
|
-
debug(`Resolving note title: ${input}`);
|
|
420
|
-
|
|
421
|
-
// Check for folder/title format
|
|
422
|
-
let targetFolder: string | null = null;
|
|
423
|
-
let targetTitle = input;
|
|
424
|
-
|
|
425
|
-
if (input.includes("/")) {
|
|
426
|
-
const parts = input.split("/");
|
|
427
|
-
targetFolder = parts.slice(0, -1).join("/");
|
|
428
|
-
targetTitle = parts[parts.length - 1];
|
|
429
|
-
debug(`Parsed folder: ${targetFolder}, title: ${targetTitle}`);
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
const escapedTitle = JSON.stringify(targetTitle);
|
|
433
|
-
const escapedFolder = targetFolder ? JSON.stringify(targetFolder) : "null";
|
|
434
|
-
|
|
435
|
-
const jxaCode = `
|
|
436
|
-
const app = Application('Notes');
|
|
437
|
-
app.includeStandardAdditions = true;
|
|
438
|
-
|
|
439
|
-
const targetTitle = ${escapedTitle};
|
|
440
|
-
const targetFolder = ${escapedFolder};
|
|
441
|
-
|
|
442
|
-
let foundNotes = [];
|
|
443
|
-
const folders = app.folders();
|
|
444
|
-
|
|
445
|
-
for (const folder of folders) {
|
|
446
|
-
const folderName = folder.name();
|
|
447
|
-
|
|
448
|
-
// Skip if folder filter is specified and doesn't match
|
|
449
|
-
if (targetFolder !== null && folderName !== targetFolder) {
|
|
450
|
-
continue;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
const notes = folder.notes.whose({ name: targetTitle });
|
|
454
|
-
|
|
455
|
-
for (let i = 0; i < notes.length; i++) {
|
|
456
|
-
try {
|
|
457
|
-
const note = notes[i];
|
|
458
|
-
foundNotes.push({
|
|
459
|
-
id: note.id(),
|
|
460
|
-
title: note.name(),
|
|
461
|
-
folder: folderName
|
|
462
|
-
});
|
|
463
|
-
} catch (e) {
|
|
464
|
-
// Skip notes that can't be accessed
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
return JSON.stringify(foundNotes);
|
|
470
|
-
`;
|
|
471
|
-
|
|
472
|
-
const result = await executeJxa<string>(jxaCode);
|
|
473
|
-
const notes = JSON.parse(result) as Array<{
|
|
474
|
-
id: string;
|
|
475
|
-
title: string;
|
|
476
|
-
folder: string;
|
|
477
|
-
}>;
|
|
478
|
-
|
|
479
|
-
if (notes.length === 0) {
|
|
480
|
-
debug("No matching notes found");
|
|
481
|
-
return {
|
|
482
|
-
success: false,
|
|
483
|
-
error: `Note not found: "${input}"`,
|
|
484
|
-
};
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
if (notes.length === 1) {
|
|
488
|
-
debug(`Resolved to unique note: ${notes[0].folder}/${notes[0].title}`);
|
|
489
|
-
return {
|
|
490
|
-
success: true,
|
|
491
|
-
note: notes[0],
|
|
492
|
-
};
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
// Multiple matches - return suggestions
|
|
496
|
-
const suggestions = notes.map((n) => `${n.folder}/${n.title}`);
|
|
497
|
-
debug(`Multiple matches found: ${suggestions.join(", ")}`);
|
|
498
|
-
|
|
499
|
-
return {
|
|
500
|
-
success: false,
|
|
501
|
-
error: `Multiple notes found with title "${targetTitle}". Please specify the folder.`,
|
|
502
|
-
suggestions,
|
|
503
|
-
};
|
|
504
|
-
}
|