@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/README.md +104 -24
- package/package.json +10 -8
- package/src/config/claude.test.ts +47 -0
- package/src/config/claude.ts +106 -0
- package/src/config/constants.ts +11 -2
- package/src/config/paths.test.ts +40 -0
- package/src/config/paths.ts +86 -0
- package/src/db/arrow-fix.test.ts +101 -0
- package/src/db/lancedb.test.ts +209 -2
- package/src/db/lancedb.ts +345 -7
- package/src/embeddings/cache.test.ts +150 -0
- package/src/embeddings/cache.ts +204 -0
- package/src/embeddings/index.ts +21 -2
- package/src/embeddings/local.ts +61 -10
- package/src/embeddings/openrouter.ts +233 -11
- package/src/graph/export.test.ts +81 -0
- package/src/graph/export.ts +163 -0
- package/src/graph/extract.test.ts +90 -0
- package/src/graph/extract.ts +52 -0
- package/src/graph/queries.test.ts +156 -0
- package/src/graph/queries.ts +224 -0
- package/src/index.ts +249 -9
- package/src/notes/crud.test.ts +26 -2
- package/src/notes/crud.ts +43 -5
- package/src/notes/read.ts +83 -68
- package/src/search/chunk-indexer.test.ts +353 -0
- package/src/search/chunk-indexer.ts +207 -0
- package/src/search/chunk-search.test.ts +327 -0
- package/src/search/chunk-search.ts +298 -0
- package/src/search/indexer.ts +151 -109
- package/src/setup.ts +46 -67
- package/src/utils/chunker.test.ts +182 -0
- package/src/utils/chunker.ts +170 -0
- package/src/utils/content-filter.test.ts +225 -0
- package/src/utils/content-filter.ts +275 -0
- package/src/utils/runtime.test.ts +70 -0
- package/src/utils/runtime.ts +40 -0
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
|
|
331
|
-
|
|
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
|
-
|
|
371
|
-
|
|
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: "${
|
|
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: "${
|
|
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
|
}
|
package/src/notes/crud.test.ts
CHANGED
|
@@ -111,9 +111,33 @@ describe("updateNote", () => {
|
|
|
111
111
|
success: true,
|
|
112
112
|
note: { id: "123", title: "Test", folder: "Work" },
|
|
113
113
|
});
|
|
114
|
-
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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
|
|
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
|
-
|
|
375
|
-
|
|
351
|
+
debug(`Found note in folder: ${notes[0].folder}`);
|
|
352
|
+
return toNoteDetails(notes[0]);
|
|
353
|
+
}
|
|
376
354
|
|
|
377
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
/**
|