@disco_trooper/apple-notes-mcp 1.2.0 → 1.4.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 +136 -24
- package/package.json +13 -9
- 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 +373 -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 +376 -10
- package/src/notes/crud.test.ts +148 -3
- package/src/notes/crud.ts +250 -5
- package/src/notes/read.ts +83 -68
- package/src/search/chunk-indexer.test.ts +353 -0
- package/src/search/chunk-indexer.ts +254 -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/search/refresh.test.ts +173 -0
- package/src/search/refresh.ts +151 -0
- 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,54 @@ 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
|
-
import { createNote, updateNote, deleteNote, moveNote, editTable } from "./notes/crud.js";
|
|
29
|
+
import { createNote, updateNote, deleteNote, moveNote, editTable, batchDelete, batchMove } 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 { refreshIfNeeded } from "./search/refresh.js";
|
|
35
|
+
import { listTags, searchByTag, findRelatedNotes } from "./graph/queries.js";
|
|
36
|
+
import { exportGraph } from "./graph/export.js";
|
|
25
37
|
|
|
26
38
|
// Debug logging and error handling
|
|
27
39
|
import { createDebugLogger } from "./utils/debug.js";
|
|
28
40
|
import { sanitizeErrorMessage } from "./utils/errors.js";
|
|
29
41
|
const debug = createDebugLogger("MCP");
|
|
30
42
|
|
|
43
|
+
// Runtime and config checks
|
|
44
|
+
checkBunRuntime();
|
|
45
|
+
|
|
46
|
+
if (!hasConfig()) {
|
|
47
|
+
if (isTTY()) {
|
|
48
|
+
// Interactive terminal - run setup wizard
|
|
49
|
+
console.log("No configuration found. Starting setup wizard...\n");
|
|
50
|
+
const { spawn } = await import("node:child_process");
|
|
51
|
+
const setupPath = new URL("./setup.ts", import.meta.url).pathname;
|
|
52
|
+
const child = spawn("bun", ["run", setupPath], {
|
|
53
|
+
stdio: "inherit",
|
|
54
|
+
});
|
|
55
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
|
56
|
+
// Wait for setup to complete
|
|
57
|
+
await new Promise(() => {}); // Setup will exit the process
|
|
58
|
+
} else {
|
|
59
|
+
// Non-interactive (MCP server mode) - show error
|
|
60
|
+
console.error(`
|
|
61
|
+
╭─────────────────────────────────────────────────────────────╮
|
|
62
|
+
│ apple-notes-mcp: Configuration required │
|
|
63
|
+
│ │
|
|
64
|
+
│ Run this command in your terminal first: │
|
|
65
|
+
│ │
|
|
66
|
+
│ apple-notes-mcp │
|
|
67
|
+
│ │
|
|
68
|
+
│ The setup wizard will guide you through configuration. │
|
|
69
|
+
╰─────────────────────────────────────────────────────────────╯
|
|
70
|
+
`);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
31
75
|
// Tool parameter schemas
|
|
32
76
|
const SearchNotesSchema = z.object({
|
|
33
77
|
query: z.string().min(1, "Query cannot be empty").max(MAX_INPUT_LENGTH),
|
|
@@ -82,6 +126,48 @@ const EditTableSchema = z.object({
|
|
|
82
126
|
})).min(1).max(100),
|
|
83
127
|
});
|
|
84
128
|
|
|
129
|
+
const BatchDeleteSchema = z.object({
|
|
130
|
+
titles: z.array(z.string().max(MAX_TITLE_LENGTH)).optional(),
|
|
131
|
+
folder: z.string().max(200).optional(),
|
|
132
|
+
confirm: z.literal(true),
|
|
133
|
+
}).refine(
|
|
134
|
+
(data) => (data.titles && !data.folder) || (!data.titles && data.folder),
|
|
135
|
+
{ message: "Specify either titles or folder, not both" }
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const BatchMoveSchema = z.object({
|
|
139
|
+
titles: z.array(z.string().max(MAX_TITLE_LENGTH)).optional(),
|
|
140
|
+
sourceFolder: z.string().max(200).optional(),
|
|
141
|
+
targetFolder: z.string().min(1).max(200),
|
|
142
|
+
}).refine(
|
|
143
|
+
(data) => (data.titles && !data.sourceFolder) || (!data.titles && data.sourceFolder),
|
|
144
|
+
{ message: "Specify either titles or sourceFolder, not both" }
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const PurgeIndexSchema = z.object({
|
|
148
|
+
confirm: z.literal(true),
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Knowledge Graph tool schemas
|
|
152
|
+
const ListTagsSchema = z.object({});
|
|
153
|
+
|
|
154
|
+
const SearchByTagSchema = z.object({
|
|
155
|
+
tag: z.string().min(1).max(100),
|
|
156
|
+
folder: z.string().max(200).optional(),
|
|
157
|
+
limit: z.number().min(1).max(MAX_SEARCH_LIMIT).default(DEFAULT_SEARCH_LIMIT),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const RelatedNotesSchema = z.object({
|
|
161
|
+
title: z.string().min(1).max(MAX_TITLE_LENGTH),
|
|
162
|
+
types: z.array(z.enum(["tag", "link", "similar"])).default(["tag", "link", "similar"]),
|
|
163
|
+
limit: z.number().min(1).max(50).default(10),
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const ExportGraphSchema = z.object({
|
|
167
|
+
format: z.enum(["json", "graphml"]),
|
|
168
|
+
folder: z.string().max(200).optional(),
|
|
169
|
+
});
|
|
170
|
+
|
|
85
171
|
// Create MCP server
|
|
86
172
|
const server = new Server(
|
|
87
173
|
{
|
|
@@ -166,6 +252,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
166
252
|
required: ["title"],
|
|
167
253
|
},
|
|
168
254
|
},
|
|
255
|
+
{
|
|
256
|
+
name: "purge-index",
|
|
257
|
+
description: "Delete all indexed data (notes and chunks). Use when switching embedding models or to fix corrupted index.",
|
|
258
|
+
inputSchema: {
|
|
259
|
+
type: "object",
|
|
260
|
+
properties: {
|
|
261
|
+
confirm: { type: "boolean", description: "Must be true to confirm deletion" },
|
|
262
|
+
},
|
|
263
|
+
required: ["confirm"],
|
|
264
|
+
},
|
|
265
|
+
},
|
|
169
266
|
{
|
|
170
267
|
name: "list-notes",
|
|
171
268
|
description: "Count how many notes are indexed",
|
|
@@ -271,6 +368,108 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
271
368
|
required: ["title", "edits"],
|
|
272
369
|
},
|
|
273
370
|
},
|
|
371
|
+
{
|
|
372
|
+
name: "batch-delete",
|
|
373
|
+
description: "Delete multiple notes at once. Requires confirm: true for safety.",
|
|
374
|
+
inputSchema: {
|
|
375
|
+
type: "object",
|
|
376
|
+
properties: {
|
|
377
|
+
titles: {
|
|
378
|
+
type: "array",
|
|
379
|
+
items: { type: "string" },
|
|
380
|
+
description: "Note titles to delete (supports folder/title and id:xxx formats)",
|
|
381
|
+
},
|
|
382
|
+
folder: {
|
|
383
|
+
type: "string",
|
|
384
|
+
description: "Delete ALL notes in this folder",
|
|
385
|
+
},
|
|
386
|
+
confirm: {
|
|
387
|
+
type: "boolean",
|
|
388
|
+
description: "Must be true to confirm deletion",
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
required: ["confirm"],
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
{
|
|
395
|
+
name: "batch-move",
|
|
396
|
+
description: "Move multiple notes to a target folder at once.",
|
|
397
|
+
inputSchema: {
|
|
398
|
+
type: "object",
|
|
399
|
+
properties: {
|
|
400
|
+
titles: {
|
|
401
|
+
type: "array",
|
|
402
|
+
items: { type: "string" },
|
|
403
|
+
description: "Note titles to move (supports folder/title and id:xxx formats)",
|
|
404
|
+
},
|
|
405
|
+
sourceFolder: {
|
|
406
|
+
type: "string",
|
|
407
|
+
description: "Move ALL notes from this folder",
|
|
408
|
+
},
|
|
409
|
+
targetFolder: {
|
|
410
|
+
type: "string",
|
|
411
|
+
description: "Target folder to move notes to",
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
required: ["targetFolder"],
|
|
415
|
+
},
|
|
416
|
+
},
|
|
417
|
+
// Knowledge Graph tools
|
|
418
|
+
{
|
|
419
|
+
name: "list-tags",
|
|
420
|
+
description: "List all tags with occurrence counts",
|
|
421
|
+
inputSchema: {
|
|
422
|
+
type: "object",
|
|
423
|
+
properties: {},
|
|
424
|
+
required: [],
|
|
425
|
+
},
|
|
426
|
+
},
|
|
427
|
+
{
|
|
428
|
+
name: "search-by-tag",
|
|
429
|
+
description: "Find notes with a specific tag",
|
|
430
|
+
inputSchema: {
|
|
431
|
+
type: "object",
|
|
432
|
+
properties: {
|
|
433
|
+
tag: { type: "string", description: "Tag to search for (without #)" },
|
|
434
|
+
folder: { type: "string", description: "Filter by folder (optional)" },
|
|
435
|
+
limit: { type: "number", description: "Max results (default: 20)" },
|
|
436
|
+
},
|
|
437
|
+
required: ["tag"],
|
|
438
|
+
},
|
|
439
|
+
},
|
|
440
|
+
{
|
|
441
|
+
name: "related-notes",
|
|
442
|
+
description: "Find notes related to a source note by tags, links, or semantic similarity",
|
|
443
|
+
inputSchema: {
|
|
444
|
+
type: "object",
|
|
445
|
+
properties: {
|
|
446
|
+
title: { type: "string", description: "Source note title (use folder/title or id:xxx)" },
|
|
447
|
+
types: {
|
|
448
|
+
type: "array",
|
|
449
|
+
items: { type: "string", enum: ["tag", "link", "similar"] },
|
|
450
|
+
description: "Relationship types to include (default: all)"
|
|
451
|
+
},
|
|
452
|
+
limit: { type: "number", description: "Max results (default: 10)" },
|
|
453
|
+
},
|
|
454
|
+
required: ["title"],
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
name: "export-graph",
|
|
459
|
+
description: "Export knowledge graph to JSON or GraphML format for visualization",
|
|
460
|
+
inputSchema: {
|
|
461
|
+
type: "object",
|
|
462
|
+
properties: {
|
|
463
|
+
format: {
|
|
464
|
+
type: "string",
|
|
465
|
+
enum: ["json", "graphml"],
|
|
466
|
+
description: "Export format: json (for D3.js, custom viz) or graphml (for Gephi, yEd)"
|
|
467
|
+
},
|
|
468
|
+
folder: { type: "string", description: "Filter by folder (optional)" },
|
|
469
|
+
},
|
|
470
|
+
required: ["format"],
|
|
471
|
+
},
|
|
472
|
+
},
|
|
274
473
|
],
|
|
275
474
|
};
|
|
276
475
|
});
|
|
@@ -285,6 +484,44 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
285
484
|
// Read tools
|
|
286
485
|
case "search-notes": {
|
|
287
486
|
const params = SearchNotesSchema.parse(args);
|
|
487
|
+
|
|
488
|
+
// Smart refresh: check for changes before search
|
|
489
|
+
const refreshed = await refreshIfNeeded();
|
|
490
|
+
if (refreshed) {
|
|
491
|
+
debug("Index refreshed before search");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Use chunk-based search if chunk index exists (better for long notes)
|
|
495
|
+
const useChunkSearch = await hasChunkIndex();
|
|
496
|
+
|
|
497
|
+
if (useChunkSearch) {
|
|
498
|
+
debug("Using chunk-based search");
|
|
499
|
+
const chunkResults = await searchChunks(params.query, {
|
|
500
|
+
folder: params.folder,
|
|
501
|
+
limit: params.limit,
|
|
502
|
+
mode: params.mode,
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
if (chunkResults.length === 0) {
|
|
506
|
+
return textResponse("No notes found matching your query.");
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Transform chunk results to match expected format
|
|
510
|
+
const results = chunkResults.map((r) => ({
|
|
511
|
+
id: r.note_id,
|
|
512
|
+
title: r.note_title,
|
|
513
|
+
folder: r.folder,
|
|
514
|
+
preview: r.matchedChunk.slice(0, 200) + (r.matchedChunk.length > 200 ? "..." : ""),
|
|
515
|
+
modified: r.modified,
|
|
516
|
+
score: r.score,
|
|
517
|
+
matchedChunkIndex: r.matchedChunkIndex,
|
|
518
|
+
}));
|
|
519
|
+
|
|
520
|
+
return textResponse(JSON.stringify(results, null, 2));
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Fall back to legacy search if no chunk index
|
|
524
|
+
debug("Using legacy search (no chunk index)");
|
|
288
525
|
const results = await searchNotes(params.query, {
|
|
289
526
|
folder: params.folder,
|
|
290
527
|
limit: params.limit,
|
|
@@ -316,6 +553,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
316
553
|
}
|
|
317
554
|
}
|
|
318
555
|
|
|
556
|
+
// Run chunk indexing for full mode (for semantic search on long notes)
|
|
557
|
+
if (params.mode === "full") {
|
|
558
|
+
debug("Running chunk indexing for full mode...");
|
|
559
|
+
const chunkResult = await fullChunkIndex();
|
|
560
|
+
message += `\nChunk index: ${chunkResult.totalChunks} chunks from ${chunkResult.totalNotes} notes in ${(chunkResult.timeMs / 1000).toFixed(1)}s`;
|
|
561
|
+
}
|
|
562
|
+
|
|
319
563
|
return textResponse(message);
|
|
320
564
|
}
|
|
321
565
|
|
|
@@ -325,10 +569,34 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
325
569
|
return textResponse(`Reindexed note: "${params.title}"`);
|
|
326
570
|
}
|
|
327
571
|
|
|
572
|
+
case "purge-index": {
|
|
573
|
+
PurgeIndexSchema.parse(args);
|
|
574
|
+
const store = getVectorStore();
|
|
575
|
+
const chunkStore = getChunkStore();
|
|
576
|
+
|
|
577
|
+
await store.clear();
|
|
578
|
+
await chunkStore.clear();
|
|
579
|
+
|
|
580
|
+
return textResponse("Index purged. Run index-notes to rebuild.");
|
|
581
|
+
}
|
|
582
|
+
|
|
328
583
|
case "list-notes": {
|
|
329
584
|
const store = getVectorStore();
|
|
330
|
-
const
|
|
331
|
-
|
|
585
|
+
const noteCount = await store.count();
|
|
586
|
+
|
|
587
|
+
let message = `${noteCount} notes indexed.`;
|
|
588
|
+
|
|
589
|
+
// Show chunk statistics if chunk index exists
|
|
590
|
+
const hasChunks = await hasChunkIndex();
|
|
591
|
+
if (hasChunks) {
|
|
592
|
+
const chunkStore = getChunkStore();
|
|
593
|
+
const chunkCount = await chunkStore.count();
|
|
594
|
+
message += ` ${chunkCount} chunks indexed for semantic search.`;
|
|
595
|
+
} else {
|
|
596
|
+
message += " Run index-notes with mode='full' to enable chunk-based search.";
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
return textResponse(message);
|
|
332
600
|
}
|
|
333
601
|
|
|
334
602
|
case "get-note": {
|
|
@@ -363,19 +631,27 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
363
631
|
|
|
364
632
|
case "update-note": {
|
|
365
633
|
const params = UpdateNoteSchema.parse(args);
|
|
366
|
-
await updateNote(params.title, params.content);
|
|
634
|
+
const result = await updateNote(params.title, params.content);
|
|
635
|
+
|
|
636
|
+
// Build location string for messages
|
|
637
|
+
const location = `${result.folder}/${result.newTitle}`;
|
|
638
|
+
const renamedMsg = result.titleChanged
|
|
639
|
+
? ` (renamed from "${result.originalTitle}")`
|
|
640
|
+
: "";
|
|
367
641
|
|
|
368
642
|
if (params.reindex) {
|
|
369
643
|
try {
|
|
370
|
-
|
|
371
|
-
|
|
644
|
+
// Use new title for reindexing (Apple Notes may have renamed it)
|
|
645
|
+
const reindexTitle = `${result.folder}/${result.newTitle}`;
|
|
646
|
+
await reindexNote(reindexTitle);
|
|
647
|
+
return textResponse(`Updated and reindexed note: "${location}"${renamedMsg}`);
|
|
372
648
|
} catch (reindexError) {
|
|
373
649
|
debug("Reindex after update failed:", reindexError);
|
|
374
|
-
return textResponse(`Updated note: "${
|
|
650
|
+
return textResponse(`Updated note: "${location}"${renamedMsg} (reindexing failed, run index-notes to update)`);
|
|
375
651
|
}
|
|
376
652
|
}
|
|
377
653
|
|
|
378
|
-
return textResponse(`Updated note: "${
|
|
654
|
+
return textResponse(`Updated note: "${location}"${renamedMsg}`);
|
|
379
655
|
}
|
|
380
656
|
|
|
381
657
|
case "delete-note": {
|
|
@@ -399,6 +675,96 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
399
675
|
return textResponse(`Updated ${params.edits.length} cell(s) in table ${params.table_index}`);
|
|
400
676
|
}
|
|
401
677
|
|
|
678
|
+
case "batch-delete": {
|
|
679
|
+
const params = BatchDeleteSchema.parse(args);
|
|
680
|
+
const result = await batchDelete({
|
|
681
|
+
titles: params.titles,
|
|
682
|
+
folder: params.folder,
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
let message = `Deleted ${result.deleted} notes.`;
|
|
686
|
+
if (result.failed.length > 0) {
|
|
687
|
+
message += `\nFailed to delete: ${result.failed.join(", ")}`;
|
|
688
|
+
}
|
|
689
|
+
return textResponse(message);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
case "batch-move": {
|
|
693
|
+
const params = BatchMoveSchema.parse(args);
|
|
694
|
+
const result = await batchMove({
|
|
695
|
+
titles: params.titles,
|
|
696
|
+
sourceFolder: params.sourceFolder,
|
|
697
|
+
targetFolder: params.targetFolder,
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
let message = `Moved ${result.moved} notes to "${params.targetFolder}".`;
|
|
701
|
+
if (result.failed.length > 0) {
|
|
702
|
+
message += `\nFailed to move: ${result.failed.join(", ")}`;
|
|
703
|
+
}
|
|
704
|
+
return textResponse(message);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Knowledge Graph tools
|
|
708
|
+
case "list-tags": {
|
|
709
|
+
ListTagsSchema.parse(args);
|
|
710
|
+
const tags = await listTags();
|
|
711
|
+
|
|
712
|
+
if (tags.length === 0) {
|
|
713
|
+
return textResponse("No tags found. Add #tags to your notes and reindex.");
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
return textResponse(JSON.stringify(tags, null, 2));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
case "search-by-tag": {
|
|
720
|
+
const params = SearchByTagSchema.parse(args);
|
|
721
|
+
const results = await searchByTag(params.tag, {
|
|
722
|
+
folder: params.folder,
|
|
723
|
+
limit: params.limit,
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
if (results.length === 0) {
|
|
727
|
+
return textResponse(`No notes found with tag: #${params.tag}`);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
return textResponse(JSON.stringify(results, null, 2));
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
case "related-notes": {
|
|
734
|
+
const params = RelatedNotesSchema.parse(args);
|
|
735
|
+
|
|
736
|
+
// Resolve note to get ID
|
|
737
|
+
const note = await getNoteByTitle(params.title);
|
|
738
|
+
if (!note) {
|
|
739
|
+
return errorResponse(`Note not found: "${params.title}"`);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const results = await findRelatedNotes(note.id, {
|
|
743
|
+
types: params.types,
|
|
744
|
+
limit: params.limit,
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
if (results.length === 0) {
|
|
748
|
+
return textResponse("No related notes found.");
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return textResponse(JSON.stringify(results, null, 2));
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
case "export-graph": {
|
|
755
|
+
const params = ExportGraphSchema.parse(args);
|
|
756
|
+
const result = await exportGraph({
|
|
757
|
+
format: params.format,
|
|
758
|
+
folder: params.folder,
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
if (typeof result === "string") {
|
|
762
|
+
return textResponse(result);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return textResponse(JSON.stringify(result, null, 2));
|
|
766
|
+
}
|
|
767
|
+
|
|
402
768
|
default:
|
|
403
769
|
return errorResponse(`Unknown tool: ${name}`);
|
|
404
770
|
}
|
package/src/notes/crud.test.ts
CHANGED
|
@@ -28,7 +28,7 @@ vi.mock("./tables.js", () => ({
|
|
|
28
28
|
}));
|
|
29
29
|
|
|
30
30
|
import { runJxa } from "run-jxa";
|
|
31
|
-
import { checkReadOnly, createNote, updateNote, deleteNote, moveNote, editTable } from "./crud.js";
|
|
31
|
+
import { checkReadOnly, createNote, updateNote, deleteNote, moveNote, editTable, batchDelete, batchMove } from "./crud.js";
|
|
32
32
|
import { resolveNoteTitle } from "./read.js";
|
|
33
33
|
import { findTables, updateTableCell } from "./tables.js";
|
|
34
34
|
|
|
@@ -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 () => {
|
|
@@ -266,3 +290,124 @@ describe("editTable", () => {
|
|
|
266
290
|
);
|
|
267
291
|
});
|
|
268
292
|
});
|
|
293
|
+
|
|
294
|
+
describe("batchDelete", () => {
|
|
295
|
+
beforeEach(() => {
|
|
296
|
+
vi.clearAllMocks();
|
|
297
|
+
delete process.env.READONLY_MODE;
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("should throw if both titles and folder provided", async () => {
|
|
301
|
+
await expect(
|
|
302
|
+
batchDelete({ titles: ["Note"], folder: "Folder" })
|
|
303
|
+
).rejects.toThrow("Specify either titles or folder, not both");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("should throw if neither titles nor folder provided", async () => {
|
|
307
|
+
await expect(batchDelete({})).rejects.toThrow("Specify either titles or folder");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("should throw in readonly mode", async () => {
|
|
311
|
+
process.env.READONLY_MODE = "true";
|
|
312
|
+
await expect(batchDelete({ titles: ["Note"] })).rejects.toThrow("read-only mode");
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("should delete all notes in a folder", async () => {
|
|
316
|
+
vi.mocked(runJxa).mockResolvedValue(JSON.stringify({ deletedCount: 5 }));
|
|
317
|
+
|
|
318
|
+
const result = await batchDelete({ folder: "Old Project" });
|
|
319
|
+
|
|
320
|
+
expect(result.deleted).toBe(5);
|
|
321
|
+
expect(result.failed).toEqual([]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("should delete individual notes by title", async () => {
|
|
325
|
+
vi.mocked(resolveNoteTitle)
|
|
326
|
+
.mockResolvedValueOnce({ success: true, note: { id: "1", title: "Note 1", folder: "Work" } })
|
|
327
|
+
.mockResolvedValueOnce({ success: true, note: { id: "2", title: "Note 2", folder: "Work" } });
|
|
328
|
+
vi.mocked(runJxa).mockResolvedValue("ok");
|
|
329
|
+
|
|
330
|
+
const result = await batchDelete({ titles: ["Note 1", "Note 2"] });
|
|
331
|
+
|
|
332
|
+
expect(result.deleted).toBe(2);
|
|
333
|
+
expect(result.failed).toEqual([]);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("should track failed deletions", async () => {
|
|
337
|
+
vi.mocked(resolveNoteTitle)
|
|
338
|
+
.mockResolvedValueOnce({ success: true, note: { id: "1", title: "Note 1", folder: "Work" } })
|
|
339
|
+
.mockResolvedValueOnce({ success: false, error: "Note not found" });
|
|
340
|
+
vi.mocked(runJxa).mockResolvedValue("ok");
|
|
341
|
+
|
|
342
|
+
const result = await batchDelete({ titles: ["Note 1", "Missing Note"] });
|
|
343
|
+
|
|
344
|
+
expect(result.deleted).toBe(1);
|
|
345
|
+
expect(result.failed).toEqual(["Missing Note"]);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe("batchMove", () => {
|
|
350
|
+
beforeEach(() => {
|
|
351
|
+
vi.clearAllMocks();
|
|
352
|
+
delete process.env.READONLY_MODE;
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("should throw if targetFolder missing", async () => {
|
|
356
|
+
await expect(
|
|
357
|
+
batchMove({ titles: ["Note"], targetFolder: "" })
|
|
358
|
+
).rejects.toThrow("targetFolder is required");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("should throw if both titles and sourceFolder provided", async () => {
|
|
362
|
+
await expect(
|
|
363
|
+
batchMove({ titles: ["Note"], sourceFolder: "Folder", targetFolder: "Archive" })
|
|
364
|
+
).rejects.toThrow("Specify either titles or sourceFolder, not both");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("should throw if neither titles nor sourceFolder provided", async () => {
|
|
368
|
+
await expect(
|
|
369
|
+
batchMove({ targetFolder: "Archive" })
|
|
370
|
+
).rejects.toThrow("Specify either titles or sourceFolder");
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("should throw in readonly mode", async () => {
|
|
374
|
+
process.env.READONLY_MODE = "true";
|
|
375
|
+
await expect(batchMove({ sourceFolder: "Temp", targetFolder: "Archive" })).rejects.toThrow("read-only mode");
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("should move all notes from source folder", async () => {
|
|
379
|
+
vi.mocked(runJxa).mockResolvedValue(JSON.stringify({ movedCount: 3 }));
|
|
380
|
+
|
|
381
|
+
const result = await batchMove({
|
|
382
|
+
sourceFolder: "Temp",
|
|
383
|
+
targetFolder: "Archive",
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
expect(result.moved).toBe(3);
|
|
387
|
+
expect(result.failed).toEqual([]);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("should move individual notes by title", async () => {
|
|
391
|
+
vi.mocked(resolveNoteTitle)
|
|
392
|
+
.mockResolvedValueOnce({ success: true, note: { id: "1", title: "Note 1", folder: "Work" } })
|
|
393
|
+
.mockResolvedValueOnce({ success: true, note: { id: "2", title: "Note 2", folder: "Work" } });
|
|
394
|
+
vi.mocked(runJxa).mockResolvedValue("ok");
|
|
395
|
+
|
|
396
|
+
const result = await batchMove({ titles: ["Note 1", "Note 2"], targetFolder: "Archive" });
|
|
397
|
+
|
|
398
|
+
expect(result.moved).toBe(2);
|
|
399
|
+
expect(result.failed).toEqual([]);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("should track failed moves", async () => {
|
|
403
|
+
vi.mocked(resolveNoteTitle)
|
|
404
|
+
.mockResolvedValueOnce({ success: true, note: { id: "1", title: "Note 1", folder: "Work" } })
|
|
405
|
+
.mockResolvedValueOnce({ success: false, error: "Note not found" });
|
|
406
|
+
vi.mocked(runJxa).mockResolvedValue("ok");
|
|
407
|
+
|
|
408
|
+
const result = await batchMove({ titles: ["Note 1", "Missing Note"], targetFolder: "Archive" });
|
|
409
|
+
|
|
410
|
+
expect(result.moved).toBe(1);
|
|
411
|
+
expect(result.failed).toEqual(["Missing Note"]);
|
|
412
|
+
});
|
|
413
|
+
});
|