@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.
Files changed (39) hide show
  1. package/README.md +136 -24
  2. package/package.json +13 -9
  3. package/src/config/claude.test.ts +47 -0
  4. package/src/config/claude.ts +106 -0
  5. package/src/config/constants.ts +11 -2
  6. package/src/config/paths.test.ts +40 -0
  7. package/src/config/paths.ts +86 -0
  8. package/src/db/arrow-fix.test.ts +101 -0
  9. package/src/db/lancedb.test.ts +209 -2
  10. package/src/db/lancedb.ts +373 -7
  11. package/src/embeddings/cache.test.ts +150 -0
  12. package/src/embeddings/cache.ts +204 -0
  13. package/src/embeddings/index.ts +21 -2
  14. package/src/embeddings/local.ts +61 -10
  15. package/src/embeddings/openrouter.ts +233 -11
  16. package/src/graph/export.test.ts +81 -0
  17. package/src/graph/export.ts +163 -0
  18. package/src/graph/extract.test.ts +90 -0
  19. package/src/graph/extract.ts +52 -0
  20. package/src/graph/queries.test.ts +156 -0
  21. package/src/graph/queries.ts +224 -0
  22. package/src/index.ts +376 -10
  23. package/src/notes/crud.test.ts +148 -3
  24. package/src/notes/crud.ts +250 -5
  25. package/src/notes/read.ts +83 -68
  26. package/src/search/chunk-indexer.test.ts +353 -0
  27. package/src/search/chunk-indexer.ts +254 -0
  28. package/src/search/chunk-search.test.ts +327 -0
  29. package/src/search/chunk-search.ts +298 -0
  30. package/src/search/indexer.ts +151 -109
  31. package/src/search/refresh.test.ts +173 -0
  32. package/src/search/refresh.ts +151 -0
  33. package/src/setup.ts +46 -67
  34. package/src/utils/chunker.test.ts +182 -0
  35. package/src/utils/chunker.ts +170 -0
  36. package/src/utils/content-filter.test.ts +225 -0
  37. package/src/utils/content-filter.ts +275 -0
  38. package/src/utils/runtime.test.ts +70 -0
  39. 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 count = await store.count();
331
- return textResponse(`${count} notes indexed. Run index-notes to update the index.`);
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
- await reindexNote(params.title);
371
- return textResponse(`Updated and reindexed note: "${params.title}"`);
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: "${params.title}" (reindexing failed, run index-notes to update)`);
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: "${params.title}"`);
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
  }
@@ -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
- 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 () => {
@@ -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
+ });