@disco_trooper/apple-notes-mcp 1.2.0 → 1.3.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/src/index.ts CHANGED
@@ -1,3 +1,11 @@
1
+ #!/usr/bin/env bun
2
+ import { hasConfig, getEnvPath } from "./config/paths.js";
3
+ import { checkBunRuntime, isTTY } from "./utils/runtime.js";
4
+ import * as dotenv from "dotenv";
5
+
6
+ // Load config from unified location
7
+ dotenv.config({ path: getEnvPath() });
8
+
1
9
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
11
  import {
@@ -5,7 +13,6 @@ import {
5
13
  ListToolsRequestSchema,
6
14
  } from "@modelcontextprotocol/sdk/types.js";
7
15
  import { z } from "zod";
8
- import "dotenv/config";
9
16
 
10
17
  // Import constants
11
18
  import {
@@ -17,17 +24,53 @@ import {
17
24
  import { validateEnv } from "./config/env.js";
18
25
 
19
26
  // Import implementations
20
- import { getVectorStore } from "./db/lancedb.js";
27
+ import { getVectorStore, getChunkStore } from "./db/lancedb.js";
21
28
  import { getNoteByTitle, getAllFolders } from "./notes/read.js";
22
29
  import { createNote, updateNote, deleteNote, moveNote, editTable } from "./notes/crud.js";
23
30
  import { searchNotes } from "./search/index.js";
24
31
  import { indexNotes, reindexNote } from "./search/indexer.js";
32
+ import { fullChunkIndex, hasChunkIndex } from "./search/chunk-indexer.js";
33
+ import { searchChunks } from "./search/chunk-search.js";
34
+ import { listTags, searchByTag, findRelatedNotes } from "./graph/queries.js";
35
+ import { exportGraph } from "./graph/export.js";
25
36
 
26
37
  // Debug logging and error handling
27
38
  import { createDebugLogger } from "./utils/debug.js";
28
39
  import { sanitizeErrorMessage } from "./utils/errors.js";
29
40
  const debug = createDebugLogger("MCP");
30
41
 
42
+ // Runtime and config checks
43
+ checkBunRuntime();
44
+
45
+ if (!hasConfig()) {
46
+ if (isTTY()) {
47
+ // Interactive terminal - run setup wizard
48
+ console.log("No configuration found. Starting setup wizard...\n");
49
+ const { spawn } = await import("node:child_process");
50
+ const setupPath = new URL("./setup.ts", import.meta.url).pathname;
51
+ const child = spawn("bun", ["run", setupPath], {
52
+ stdio: "inherit",
53
+ });
54
+ child.on("exit", (code) => process.exit(code ?? 0));
55
+ // Wait for setup to complete
56
+ await new Promise(() => {}); // Setup will exit the process
57
+ } else {
58
+ // Non-interactive (MCP server mode) - show error
59
+ console.error(`
60
+ ╭─────────────────────────────────────────────────────────────╮
61
+ │ apple-notes-mcp: Configuration required │
62
+ │ │
63
+ │ Run this command in your terminal first: │
64
+ │ │
65
+ │ apple-notes-mcp │
66
+ │ │
67
+ │ The setup wizard will guide you through configuration. │
68
+ ╰─────────────────────────────────────────────────────────────╯
69
+ `);
70
+ process.exit(1);
71
+ }
72
+ }
73
+
31
74
  // Tool parameter schemas
32
75
  const SearchNotesSchema = z.object({
33
76
  query: z.string().min(1, "Query cannot be empty").max(MAX_INPUT_LENGTH),
@@ -82,6 +125,26 @@ const EditTableSchema = z.object({
82
125
  })).min(1).max(100),
83
126
  });
84
127
 
128
+ // Knowledge Graph tool schemas
129
+ const ListTagsSchema = z.object({});
130
+
131
+ const SearchByTagSchema = z.object({
132
+ tag: z.string().min(1).max(100),
133
+ folder: z.string().max(200).optional(),
134
+ limit: z.number().min(1).max(MAX_SEARCH_LIMIT).default(DEFAULT_SEARCH_LIMIT),
135
+ });
136
+
137
+ const RelatedNotesSchema = z.object({
138
+ title: z.string().min(1).max(MAX_TITLE_LENGTH),
139
+ types: z.array(z.enum(["tag", "link", "similar"])).default(["tag", "link", "similar"]),
140
+ limit: z.number().min(1).max(50).default(10),
141
+ });
142
+
143
+ const ExportGraphSchema = z.object({
144
+ format: z.enum(["json", "graphml"]),
145
+ folder: z.string().max(200).optional(),
146
+ });
147
+
85
148
  // Create MCP server
86
149
  const server = new Server(
87
150
  {
@@ -271,6 +334,62 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
271
334
  required: ["title", "edits"],
272
335
  },
273
336
  },
337
+ // Knowledge Graph tools
338
+ {
339
+ name: "list-tags",
340
+ description: "List all tags with occurrence counts",
341
+ inputSchema: {
342
+ type: "object",
343
+ properties: {},
344
+ required: [],
345
+ },
346
+ },
347
+ {
348
+ name: "search-by-tag",
349
+ description: "Find notes with a specific tag",
350
+ inputSchema: {
351
+ type: "object",
352
+ properties: {
353
+ tag: { type: "string", description: "Tag to search for (without #)" },
354
+ folder: { type: "string", description: "Filter by folder (optional)" },
355
+ limit: { type: "number", description: "Max results (default: 20)" },
356
+ },
357
+ required: ["tag"],
358
+ },
359
+ },
360
+ {
361
+ name: "related-notes",
362
+ description: "Find notes related to a source note by tags, links, or semantic similarity",
363
+ inputSchema: {
364
+ type: "object",
365
+ properties: {
366
+ title: { type: "string", description: "Source note title (use folder/title or id:xxx)" },
367
+ types: {
368
+ type: "array",
369
+ items: { type: "string", enum: ["tag", "link", "similar"] },
370
+ description: "Relationship types to include (default: all)"
371
+ },
372
+ limit: { type: "number", description: "Max results (default: 10)" },
373
+ },
374
+ required: ["title"],
375
+ },
376
+ },
377
+ {
378
+ name: "export-graph",
379
+ description: "Export knowledge graph to JSON or GraphML format for visualization",
380
+ inputSchema: {
381
+ type: "object",
382
+ properties: {
383
+ format: {
384
+ type: "string",
385
+ enum: ["json", "graphml"],
386
+ description: "Export format: json (for D3.js, custom viz) or graphml (for Gephi, yEd)"
387
+ },
388
+ folder: { type: "string", description: "Filter by folder (optional)" },
389
+ },
390
+ required: ["format"],
391
+ },
392
+ },
274
393
  ],
275
394
  };
276
395
  });
@@ -285,6 +404,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
285
404
  // Read tools
286
405
  case "search-notes": {
287
406
  const params = SearchNotesSchema.parse(args);
407
+
408
+ // Use chunk-based search if chunk index exists (better for long notes)
409
+ const useChunkSearch = await hasChunkIndex();
410
+
411
+ if (useChunkSearch) {
412
+ debug("Using chunk-based search");
413
+ const chunkResults = await searchChunks(params.query, {
414
+ folder: params.folder,
415
+ limit: params.limit,
416
+ mode: params.mode,
417
+ });
418
+
419
+ if (chunkResults.length === 0) {
420
+ return textResponse("No notes found matching your query.");
421
+ }
422
+
423
+ // Transform chunk results to match expected format
424
+ const results = chunkResults.map((r) => ({
425
+ id: r.note_id,
426
+ title: r.note_title,
427
+ folder: r.folder,
428
+ preview: r.matchedChunk.slice(0, 200) + (r.matchedChunk.length > 200 ? "..." : ""),
429
+ modified: r.modified,
430
+ score: r.score,
431
+ matchedChunkIndex: r.matchedChunkIndex,
432
+ }));
433
+
434
+ return textResponse(JSON.stringify(results, null, 2));
435
+ }
436
+
437
+ // Fall back to legacy search if no chunk index
438
+ debug("Using legacy search (no chunk index)");
288
439
  const results = await searchNotes(params.query, {
289
440
  folder: params.folder,
290
441
  limit: params.limit,
@@ -316,6 +467,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
316
467
  }
317
468
  }
318
469
 
470
+ // Run chunk indexing for full mode (for semantic search on long notes)
471
+ if (params.mode === "full") {
472
+ debug("Running chunk indexing for full mode...");
473
+ const chunkResult = await fullChunkIndex();
474
+ message += `\nChunk index: ${chunkResult.totalChunks} chunks from ${chunkResult.totalNotes} notes in ${(chunkResult.timeMs / 1000).toFixed(1)}s`;
475
+ }
476
+
319
477
  return textResponse(message);
320
478
  }
321
479
 
@@ -327,8 +485,21 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
327
485
 
328
486
  case "list-notes": {
329
487
  const store = getVectorStore();
330
- const count = await store.count();
331
- return textResponse(`${count} notes indexed. Run index-notes to update the index.`);
488
+ const noteCount = await store.count();
489
+
490
+ let message = `${noteCount} notes indexed.`;
491
+
492
+ // Show chunk statistics if chunk index exists
493
+ const hasChunks = await hasChunkIndex();
494
+ if (hasChunks) {
495
+ const chunkStore = getChunkStore();
496
+ const chunkCount = await chunkStore.count();
497
+ message += ` ${chunkCount} chunks indexed for semantic search.`;
498
+ } else {
499
+ message += " Run index-notes with mode='full' to enable chunk-based search.";
500
+ }
501
+
502
+ return textResponse(message);
332
503
  }
333
504
 
334
505
  case "get-note": {
@@ -363,19 +534,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
363
534
 
364
535
  case "update-note": {
365
536
  const params = UpdateNoteSchema.parse(args);
366
- await updateNote(params.title, params.content);
537
+ const result = await updateNote(params.title, params.content);
538
+
539
+ // Build location string for messages
540
+ const location = `${result.folder}/${result.newTitle}`;
541
+ const renamedMsg = result.titleChanged
542
+ ? ` (renamed from "${result.originalTitle}")`
543
+ : "";
367
544
 
368
545
  if (params.reindex) {
369
546
  try {
370
- await reindexNote(params.title);
371
- return textResponse(`Updated and reindexed note: "${params.title}"`);
547
+ // Use new title for reindexing (Apple Notes may have renamed it)
548
+ const reindexTitle = `${result.folder}/${result.newTitle}`;
549
+ await reindexNote(reindexTitle);
550
+ return textResponse(`Updated and reindexed note: "${location}"${renamedMsg}`);
372
551
  } catch (reindexError) {
373
552
  debug("Reindex after update failed:", reindexError);
374
- return textResponse(`Updated note: "${params.title}" (reindexing failed, run index-notes to update)`);
553
+ return textResponse(`Updated note: "${location}"${renamedMsg} (reindexing failed, run index-notes to update)`);
375
554
  }
376
555
  }
377
556
 
378
- return textResponse(`Updated note: "${params.title}"`);
557
+ return textResponse(`Updated note: "${location}"${renamedMsg}`);
379
558
  }
380
559
 
381
560
  case "delete-note": {
@@ -399,6 +578,67 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
399
578
  return textResponse(`Updated ${params.edits.length} cell(s) in table ${params.table_index}`);
400
579
  }
401
580
 
581
+ // Knowledge Graph tools
582
+ case "list-tags": {
583
+ ListTagsSchema.parse(args);
584
+ const tags = await listTags();
585
+
586
+ if (tags.length === 0) {
587
+ return textResponse("No tags found. Add #tags to your notes and reindex.");
588
+ }
589
+
590
+ return textResponse(JSON.stringify(tags, null, 2));
591
+ }
592
+
593
+ case "search-by-tag": {
594
+ const params = SearchByTagSchema.parse(args);
595
+ const results = await searchByTag(params.tag, {
596
+ folder: params.folder,
597
+ limit: params.limit,
598
+ });
599
+
600
+ if (results.length === 0) {
601
+ return textResponse(`No notes found with tag: #${params.tag}`);
602
+ }
603
+
604
+ return textResponse(JSON.stringify(results, null, 2));
605
+ }
606
+
607
+ case "related-notes": {
608
+ const params = RelatedNotesSchema.parse(args);
609
+
610
+ // Resolve note to get ID
611
+ const note = await getNoteByTitle(params.title);
612
+ if (!note) {
613
+ return errorResponse(`Note not found: "${params.title}"`);
614
+ }
615
+
616
+ const results = await findRelatedNotes(note.id, {
617
+ types: params.types,
618
+ limit: params.limit,
619
+ });
620
+
621
+ if (results.length === 0) {
622
+ return textResponse("No related notes found.");
623
+ }
624
+
625
+ return textResponse(JSON.stringify(results, null, 2));
626
+ }
627
+
628
+ case "export-graph": {
629
+ const params = ExportGraphSchema.parse(args);
630
+ const result = await exportGraph({
631
+ format: params.format,
632
+ folder: params.folder,
633
+ });
634
+
635
+ if (typeof result === "string") {
636
+ return textResponse(result);
637
+ }
638
+
639
+ return textResponse(JSON.stringify(result, null, 2));
640
+ }
641
+
402
642
  default:
403
643
  return errorResponse(`Unknown tool: ${name}`);
404
644
  }
@@ -111,9 +111,33 @@ describe("updateNote", () => {
111
111
  success: true,
112
112
  note: { id: "123", title: "Test", folder: "Work" },
113
113
  });
114
- vi.mocked(runJxa).mockResolvedValueOnce("ok");
114
+ // Mock JXA returning the new title (same as original in this case)
115
+ vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify({ newTitle: "Test" }));
116
+
117
+ const result = await updateNote("Test", "New Content");
118
+ expect(result).toEqual({
119
+ originalTitle: "Test",
120
+ newTitle: "Test",
121
+ folder: "Work",
122
+ titleChanged: false,
123
+ });
124
+ });
115
125
 
116
- await expect(updateNote("Test", "New Content")).resolves.toBeUndefined();
126
+ it("should detect when Apple Notes renames the note", async () => {
127
+ vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
128
+ success: true,
129
+ note: { id: "123", title: "Original Title", folder: "Work" },
130
+ });
131
+ // Mock JXA returning a different title (Apple Notes renamed it)
132
+ vi.mocked(runJxa).mockResolvedValueOnce(JSON.stringify({ newTitle: "New Heading" }));
133
+
134
+ const result = await updateNote("Original Title", "# New Heading\n\nContent");
135
+ expect(result).toEqual({
136
+ originalTitle: "Original Title",
137
+ newTitle: "New Heading",
138
+ folder: "Work",
139
+ titleChanged: true,
140
+ });
117
141
  });
118
142
 
119
143
  it("should include suggestions in error when multiple notes found", async () => {
package/src/notes/crud.ts CHANGED
@@ -115,20 +115,42 @@ export async function createNote(
115
115
  debug(`Note created: "${title}"`);
116
116
  }
117
117
 
118
+ /**
119
+ * Result of an update operation.
120
+ * Apple Notes may rename the note based on content (first h1 heading).
121
+ */
122
+ export interface UpdateResult {
123
+ /** Original title before update */
124
+ originalTitle: string;
125
+ /** Current title after update (may differ if Apple Notes renamed it) */
126
+ newTitle: string;
127
+ /** Folder containing the note */
128
+ folder: string;
129
+ /** Whether the title changed */
130
+ titleChanged: boolean;
131
+ }
132
+
118
133
  /**
119
134
  * Update an existing note's content.
120
135
  *
136
+ * Note: Apple Notes may automatically rename the note based on the first
137
+ * heading in the content. The returned UpdateResult contains the actual
138
+ * title after the update.
139
+ *
121
140
  * @param title - Note title (supports folder prefix: "Work/My Note")
122
141
  * @param content - New content (Markdown)
142
+ * @returns UpdateResult with original and new title
123
143
  * @throws Error if READONLY_MODE is enabled
124
144
  * @throws Error if note not found or duplicate titles without folder prefix
125
145
  */
126
- export async function updateNote(title: string, content: string): Promise<void> {
146
+ export async function updateNote(title: string, content: string): Promise<UpdateResult> {
127
147
  checkReadOnly();
128
148
 
129
149
  debug(`Updating note: "${title}"`);
130
150
 
131
151
  const note = await resolveNoteOrThrow(title);
152
+ const originalTitle = note.title;
153
+ const folder = note.folder;
132
154
 
133
155
  // Convert Markdown to HTML
134
156
  const htmlContent = markdownToHtml(content);
@@ -137,7 +159,8 @@ export async function updateNote(title: string, content: string): Promise<void>
137
159
 
138
160
  debug(`HTML content length: ${htmlContent.length}`);
139
161
 
140
- await runJxa(`
162
+ // Update the note and get its new title (Apple Notes may rename it)
163
+ const result = await runJxa(`
141
164
  const app = Application('Notes');
142
165
  const noteId = ${escapedNoteId};
143
166
  const content = ${escapedContent};
@@ -152,10 +175,25 @@ export async function updateNote(title: string, content: string): Promise<void>
152
175
  // Update the body
153
176
  note.body = content;
154
177
 
155
- return "ok";
156
- `);
178
+ // Return the current title (may have changed)
179
+ return JSON.stringify({ newTitle: note.name() });
180
+ `) as string;
181
+
182
+ const { newTitle } = JSON.parse(result);
183
+ const titleChanged = newTitle !== originalTitle;
184
+
185
+ if (titleChanged) {
186
+ debug(`Note renamed by Apple Notes: "${originalTitle}" -> "${newTitle}"`);
187
+ } else {
188
+ debug(`Note updated: "${title}"`);
189
+ }
157
190
 
158
- debug(`Note updated: "${title}"`);
191
+ return {
192
+ originalTitle,
193
+ newTitle,
194
+ folder,
195
+ titleChanged,
196
+ };
159
197
  }
160
198
 
161
199
  /**
package/src/notes/read.ts CHANGED
@@ -40,9 +40,19 @@ export interface NoteDetails extends NoteInfo {
40
40
  }
41
41
 
42
42
  // -----------------------------------------------------------------------------
43
- // Internal JXA helpers
43
+ // Internal types and helpers
44
44
  // -----------------------------------------------------------------------------
45
45
 
46
+ /** Raw note data from JXA before markdown conversion */
47
+ interface RawNoteData {
48
+ id: string;
49
+ title: string;
50
+ folder: string;
51
+ created: string;
52
+ modified: string;
53
+ htmlContent: string;
54
+ }
55
+
46
56
  /**
47
57
  * Execute JXA code safely with error handling
48
58
  */
@@ -58,6 +68,21 @@ async function executeJxa<T>(code: string): Promise<T> {
58
68
  }
59
69
  }
60
70
 
71
+ /**
72
+ * Convert raw JXA note data to NoteDetails with markdown content
73
+ */
74
+ function toNoteDetails(raw: RawNoteData): NoteDetails {
75
+ return {
76
+ id: raw.id,
77
+ title: raw.title,
78
+ folder: raw.folder,
79
+ created: raw.created,
80
+ modified: raw.modified,
81
+ content: htmlToMarkdown(raw.htmlContent),
82
+ htmlContent: raw.htmlContent,
83
+ };
84
+ }
85
+
61
86
  // -----------------------------------------------------------------------------
62
87
  // Public API
63
88
  // -----------------------------------------------------------------------------
@@ -180,14 +205,7 @@ export async function getNoteByTitle(
180
205
  `;
181
206
 
182
207
  const result = await executeJxa<string>(jxaCode);
183
- const notes = JSON.parse(result) as Array<{
184
- id: string;
185
- title: string;
186
- folder: string;
187
- created: string;
188
- modified: string;
189
- htmlContent: string;
190
- }>;
208
+ const notes = JSON.parse(result) as RawNoteData[];
191
209
 
192
210
  if (notes.length === 0) {
193
211
  debug("Note not found");
@@ -196,27 +214,11 @@ export async function getNoteByTitle(
196
214
 
197
215
  if (notes.length > 1) {
198
216
  debug(`Multiple notes found with title: ${targetTitle}`);
199
- // If folder wasn't specified and multiple exist, return the first one
200
- // but log a warning
201
- debug(
202
- "Returning first match. Use folder/title format for disambiguation."
203
- );
217
+ debug("Returning first match. Use folder/title format for disambiguation.");
204
218
  }
205
219
 
206
- const note = notes[0];
207
- const content = htmlToMarkdown(note.htmlContent);
208
-
209
- debug(`Found note in folder: ${note.folder}`);
210
-
211
- return {
212
- id: note.id,
213
- title: note.title,
214
- folder: note.folder,
215
- created: note.created,
216
- modified: note.modified,
217
- content,
218
- htmlContent: note.htmlContent,
219
- };
220
+ debug(`Found note in folder: ${notes[0].folder}`);
221
+ return toNoteDetails(notes[0]);
220
222
  }
221
223
 
222
224
  /**
@@ -268,33 +270,15 @@ export async function getNoteById(id: string): Promise<NoteDetails | null> {
268
270
  `;
269
271
 
270
272
  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;
273
+ const note = JSON.parse(result) as RawNoteData | null;
279
274
 
280
275
  if (!note) {
281
276
  debug("Note not found by ID");
282
277
  return null;
283
278
  }
284
279
 
285
- const content = htmlToMarkdown(note.htmlContent);
286
-
287
280
  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
- };
281
+ return toNoteDetails(note);
298
282
  }
299
283
 
300
284
  /**
@@ -357,34 +341,65 @@ export async function getNoteByFolderAndTitle(
357
341
  `;
358
342
 
359
343
  const result = await executeJxa<string>(jxaCode);
360
- const notes = JSON.parse(result) as Array<{
361
- id: string;
362
- title: string;
363
- folder: string;
364
- created: string;
365
- modified: string;
366
- htmlContent: string;
367
- }>;
344
+ const notes = JSON.parse(result) as RawNoteData[];
368
345
 
369
346
  if (notes.length === 0) {
370
347
  debug("Note not found");
371
348
  return null;
372
349
  }
373
350
 
374
- const note = notes[0];
375
- const content = htmlToMarkdown(note.htmlContent);
351
+ debug(`Found note in folder: ${notes[0].folder}`);
352
+ return toNoteDetails(notes[0]);
353
+ }
376
354
 
377
- debug(`Found note in folder: ${note.folder}`);
355
+ /**
356
+ * Get all notes with full content in a single JXA call.
357
+ * This is much faster than calling getNoteByFolderAndTitle for each note
358
+ * because it avoids the JXA process spawn overhead per note.
359
+ *
360
+ * @returns Array of note details with content
361
+ */
362
+ export async function getAllNotesWithContent(): Promise<NoteDetails[]> {
363
+ debug("Getting all notes with content (single JXA call)...");
378
364
 
379
- return {
380
- id: note.id,
381
- title: note.title,
382
- folder: note.folder,
383
- created: note.created,
384
- modified: note.modified,
385
- content,
386
- htmlContent: note.htmlContent,
387
- };
365
+ const jxaCode = `
366
+ const app = Application('Notes');
367
+ app.includeStandardAdditions = true;
368
+
369
+ const allNotes = [];
370
+ const folders = app.folders();
371
+
372
+ for (const folder of folders) {
373
+ const folderName = folder.name();
374
+ const notes = folder.notes();
375
+
376
+ for (let i = 0; i < notes.length; i++) {
377
+ try {
378
+ const note = notes[i];
379
+ const props = note.properties();
380
+ allNotes.push({
381
+ id: note.id(),
382
+ title: props.name || '',
383
+ folder: folderName,
384
+ created: props.creationDate ? props.creationDate.toISOString() : '',
385
+ modified: props.modificationDate ? props.modificationDate.toISOString() : '',
386
+ htmlContent: note.body()
387
+ });
388
+ } catch (e) {
389
+ // Skip notes that can't be accessed
390
+ }
391
+ }
392
+ }
393
+
394
+ return JSON.stringify(allNotes);
395
+ `;
396
+
397
+ const result = await executeJxa<string>(jxaCode);
398
+ const notes = JSON.parse(result) as RawNoteData[];
399
+
400
+ debug(`Fetched ${notes.length} notes with content`);
401
+
402
+ return notes.map(toNoteDetails);
388
403
  }
389
404
 
390
405
  /**