@disco_trooper/apple-notes-mcp 1.0.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/CLAUDE.md +56 -0
- package/LICENSE +21 -0
- package/README.md +216 -0
- package/package.json +61 -0
- package/src/config/constants.ts +41 -0
- package/src/config/env.test.ts +58 -0
- package/src/config/env.ts +25 -0
- package/src/db/lancedb.test.ts +141 -0
- package/src/db/lancedb.ts +263 -0
- package/src/db/validation.test.ts +76 -0
- package/src/db/validation.ts +57 -0
- package/src/embeddings/index.test.ts +54 -0
- package/src/embeddings/index.ts +111 -0
- package/src/embeddings/local.test.ts +70 -0
- package/src/embeddings/local.ts +191 -0
- package/src/embeddings/openrouter.test.ts +21 -0
- package/src/embeddings/openrouter.ts +285 -0
- package/src/index.ts +387 -0
- package/src/notes/crud.test.ts +199 -0
- package/src/notes/crud.ts +257 -0
- package/src/notes/read.test.ts +131 -0
- package/src/notes/read.ts +504 -0
- package/src/search/index.test.ts +52 -0
- package/src/search/index.ts +283 -0
- package/src/search/indexer.test.ts +42 -0
- package/src/search/indexer.ts +335 -0
- package/src/server.ts +386 -0
- package/src/setup.ts +540 -0
- package/src/types/index.ts +39 -0
- package/src/utils/debug.test.ts +41 -0
- package/src/utils/debug.ts +51 -0
- package/src/utils/errors.test.ts +29 -0
- package/src/utils/errors.ts +46 -0
- package/src/utils/text.ts +23 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import {
|
|
4
|
+
CallToolRequestSchema,
|
|
5
|
+
ListToolsRequestSchema,
|
|
6
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import "dotenv/config";
|
|
9
|
+
|
|
10
|
+
// Import constants
|
|
11
|
+
import { DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT } from "./config/constants.js";
|
|
12
|
+
import { validateEnv } from "./config/env.js";
|
|
13
|
+
|
|
14
|
+
// Import implementations
|
|
15
|
+
import { getVectorStore } from "./db/lancedb.js";
|
|
16
|
+
import { getNoteByTitle, getAllFolders } from "./notes/read.js";
|
|
17
|
+
import { createNote, updateNote, deleteNote, moveNote } from "./notes/crud.js";
|
|
18
|
+
import { searchNotes } from "./search/index.js";
|
|
19
|
+
import { indexNotes, reindexNote } from "./search/indexer.js";
|
|
20
|
+
|
|
21
|
+
// Debug logging and error handling
|
|
22
|
+
import { createDebugLogger } from "./utils/debug.js";
|
|
23
|
+
import { sanitizeErrorMessage } from "./utils/errors.js";
|
|
24
|
+
const debug = createDebugLogger("MCP");
|
|
25
|
+
|
|
26
|
+
// Tool parameter schemas
|
|
27
|
+
const SearchNotesSchema = z.object({
|
|
28
|
+
query: z.string().min(1, "Query cannot be empty"),
|
|
29
|
+
folder: z.string().optional(),
|
|
30
|
+
limit: z.number().min(1).max(MAX_SEARCH_LIMIT).default(DEFAULT_SEARCH_LIMIT),
|
|
31
|
+
mode: z.enum(["hybrid", "keyword", "semantic"]).default("hybrid"),
|
|
32
|
+
include_content: z.boolean().default(false),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const IndexNotesSchema = z.object({
|
|
36
|
+
mode: z.enum(["full", "incremental"]).default("incremental"),
|
|
37
|
+
force: z.boolean().default(false),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const ReindexNoteSchema = z.object({
|
|
41
|
+
title: z.string(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const GetNoteSchema = z.object({
|
|
45
|
+
title: z.string(),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const CreateNoteSchema = z.object({
|
|
49
|
+
title: z.string(),
|
|
50
|
+
content: z.string(),
|
|
51
|
+
folder: z.string().optional(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const UpdateNoteSchema = z.object({
|
|
55
|
+
title: z.string(),
|
|
56
|
+
content: z.string(),
|
|
57
|
+
reindex: z.boolean().default(true),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const DeleteNoteSchema = z.object({
|
|
61
|
+
title: z.string(),
|
|
62
|
+
confirm: z.boolean(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const MoveNoteSchema = z.object({
|
|
66
|
+
title: z.string(),
|
|
67
|
+
folder: z.string(),
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Create MCP server
|
|
71
|
+
const server = new Server(
|
|
72
|
+
{
|
|
73
|
+
name: "apple-notes-mcp",
|
|
74
|
+
version: "1.0.0",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
capabilities: {
|
|
78
|
+
tools: {},
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Helper to create text responses
|
|
84
|
+
function textResponse(text: string) {
|
|
85
|
+
return {
|
|
86
|
+
content: [{ type: "text" as const, text }],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function errorResponse(message: string) {
|
|
91
|
+
return {
|
|
92
|
+
content: [{ type: "text" as const, text: `Error: ${message}` }],
|
|
93
|
+
isError: true,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Register tool list
|
|
98
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
99
|
+
return {
|
|
100
|
+
tools: [
|
|
101
|
+
// Read tools
|
|
102
|
+
{
|
|
103
|
+
name: "search-notes",
|
|
104
|
+
description: "Search notes using hybrid vector + fulltext search",
|
|
105
|
+
inputSchema: {
|
|
106
|
+
type: "object",
|
|
107
|
+
properties: {
|
|
108
|
+
query: { type: "string", description: "Search query" },
|
|
109
|
+
folder: { type: "string", description: "Filter by folder (optional)" },
|
|
110
|
+
limit: { type: "number", description: "Max results (default: 20)" },
|
|
111
|
+
mode: {
|
|
112
|
+
type: "string",
|
|
113
|
+
enum: ["hybrid", "keyword", "semantic"],
|
|
114
|
+
description: "Search mode (default: hybrid)"
|
|
115
|
+
},
|
|
116
|
+
include_content: {
|
|
117
|
+
type: "boolean",
|
|
118
|
+
description: "Include full content instead of preview (default: false)"
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
required: ["query"],
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "index-notes",
|
|
126
|
+
description: "Index all notes for semantic search. Use mode='incremental' (default) to only process changed notes.",
|
|
127
|
+
inputSchema: {
|
|
128
|
+
type: "object",
|
|
129
|
+
properties: {
|
|
130
|
+
mode: {
|
|
131
|
+
type: "string",
|
|
132
|
+
enum: ["full", "incremental"],
|
|
133
|
+
description: "full = reindex everything, incremental = only changes (default)"
|
|
134
|
+
},
|
|
135
|
+
force: {
|
|
136
|
+
type: "boolean",
|
|
137
|
+
description: "Force reindex even if TTL hasn't expired (default: false)"
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
required: [],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
name: "reindex-note",
|
|
145
|
+
description: "Re-index a single note after manual edits",
|
|
146
|
+
inputSchema: {
|
|
147
|
+
type: "object",
|
|
148
|
+
properties: {
|
|
149
|
+
title: { type: "string", description: "Note title (use folder/title for disambiguation)" },
|
|
150
|
+
},
|
|
151
|
+
required: ["title"],
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "list-notes",
|
|
156
|
+
description: "Count how many notes are indexed",
|
|
157
|
+
inputSchema: {
|
|
158
|
+
type: "object",
|
|
159
|
+
properties: {},
|
|
160
|
+
required: [],
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: "get-note",
|
|
165
|
+
description: "Get full content of a note by title",
|
|
166
|
+
inputSchema: {
|
|
167
|
+
type: "object",
|
|
168
|
+
properties: {
|
|
169
|
+
title: { type: "string", description: "Note title (use folder/title for disambiguation)" },
|
|
170
|
+
},
|
|
171
|
+
required: ["title"],
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "list-folders",
|
|
176
|
+
description: "List all folders in Apple Notes",
|
|
177
|
+
inputSchema: {
|
|
178
|
+
type: "object",
|
|
179
|
+
properties: {},
|
|
180
|
+
required: [],
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
// Write tools
|
|
184
|
+
{
|
|
185
|
+
name: "create-note",
|
|
186
|
+
description: "Create a new note in Apple Notes",
|
|
187
|
+
inputSchema: {
|
|
188
|
+
type: "object",
|
|
189
|
+
properties: {
|
|
190
|
+
title: { type: "string", description: "Note title" },
|
|
191
|
+
content: { type: "string", description: "Note content (Markdown)" },
|
|
192
|
+
folder: { type: "string", description: "Target folder (optional, defaults to Notes)" },
|
|
193
|
+
},
|
|
194
|
+
required: ["title", "content"],
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
name: "update-note",
|
|
199
|
+
description: "Update an existing note",
|
|
200
|
+
inputSchema: {
|
|
201
|
+
type: "object",
|
|
202
|
+
properties: {
|
|
203
|
+
title: { type: "string", description: "Note title (use folder/title for disambiguation)" },
|
|
204
|
+
content: { type: "string", description: "New content (Markdown)" },
|
|
205
|
+
reindex: { type: "boolean", description: "Re-embed after update (default: true)" },
|
|
206
|
+
},
|
|
207
|
+
required: ["title", "content"],
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: "delete-note",
|
|
212
|
+
description: "Delete a note (requires confirm: true for safety)",
|
|
213
|
+
inputSchema: {
|
|
214
|
+
type: "object",
|
|
215
|
+
properties: {
|
|
216
|
+
title: { type: "string", description: "Note title (use folder/title for disambiguation)" },
|
|
217
|
+
confirm: { type: "boolean", description: "Must be true to confirm deletion" },
|
|
218
|
+
},
|
|
219
|
+
required: ["title", "confirm"],
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: "move-note",
|
|
224
|
+
description: "Move a note to a different folder",
|
|
225
|
+
inputSchema: {
|
|
226
|
+
type: "object",
|
|
227
|
+
properties: {
|
|
228
|
+
title: { type: "string", description: "Note title (use folder/title for disambiguation)" },
|
|
229
|
+
folder: { type: "string", description: "Target folder" },
|
|
230
|
+
},
|
|
231
|
+
required: ["title", "folder"],
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
],
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Handle tool calls
|
|
239
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
240
|
+
const { name, arguments: args } = request.params;
|
|
241
|
+
debug(`Tool called: ${name}`, args);
|
|
242
|
+
|
|
243
|
+
try {
|
|
244
|
+
switch (name) {
|
|
245
|
+
// Read tools
|
|
246
|
+
case "search-notes": {
|
|
247
|
+
const params = SearchNotesSchema.parse(args);
|
|
248
|
+
const results = await searchNotes(params.query, {
|
|
249
|
+
folder: params.folder,
|
|
250
|
+
limit: params.limit,
|
|
251
|
+
mode: params.mode,
|
|
252
|
+
include_content: params.include_content,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
if (results.length === 0) {
|
|
256
|
+
return textResponse("No notes found matching your query.");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return textResponse(JSON.stringify(results, null, 2));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
case "index-notes": {
|
|
263
|
+
const params = IndexNotesSchema.parse(args);
|
|
264
|
+
const result = await indexNotes(params.mode);
|
|
265
|
+
|
|
266
|
+
let message = `Indexed ${result.indexed} notes in ${(result.timeMs / 1000).toFixed(1)}s`;
|
|
267
|
+
|
|
268
|
+
if (result.breakdown) {
|
|
269
|
+
message += ` (added: ${result.breakdown.added}, updated: ${result.breakdown.updated}, deleted: ${result.breakdown.deleted}, skipped: ${result.breakdown.skipped})`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (result.errors > 0) {
|
|
273
|
+
message += `\n${result.errors} errors occurred.`;
|
|
274
|
+
if (result.failedNotes && result.failedNotes.length > 0) {
|
|
275
|
+
message += `\nFailed notes:\n${result.failedNotes.map(n => ` - ${n}`).join("\n")}`;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return textResponse(message);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
case "reindex-note": {
|
|
283
|
+
const params = ReindexNoteSchema.parse(args);
|
|
284
|
+
await reindexNote(params.title);
|
|
285
|
+
return textResponse(`Reindexed note: "${params.title}"`);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
case "list-notes": {
|
|
289
|
+
const store = getVectorStore();
|
|
290
|
+
const count = await store.count();
|
|
291
|
+
return textResponse(`${count} notes indexed. Run index-notes to update the index.`);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
case "get-note": {
|
|
295
|
+
const params = GetNoteSchema.parse(args);
|
|
296
|
+
const note = await getNoteByTitle(params.title);
|
|
297
|
+
|
|
298
|
+
if (!note) {
|
|
299
|
+
return errorResponse(`Note not found: "${params.title}"`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return textResponse(JSON.stringify({
|
|
303
|
+
title: note.title,
|
|
304
|
+
folder: note.folder,
|
|
305
|
+
content: note.content,
|
|
306
|
+
created: note.created,
|
|
307
|
+
modified: note.modified,
|
|
308
|
+
}, null, 2));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
case "list-folders": {
|
|
312
|
+
const folders = await getAllFolders();
|
|
313
|
+
return textResponse(JSON.stringify(folders, null, 2));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Write tools
|
|
317
|
+
case "create-note": {
|
|
318
|
+
const params = CreateNoteSchema.parse(args);
|
|
319
|
+
await createNote(params.title, params.content, params.folder);
|
|
320
|
+
const location = params.folder ? `${params.folder}/${params.title}` : params.title;
|
|
321
|
+
return textResponse(`Created note: "${location}"`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
case "update-note": {
|
|
325
|
+
const params = UpdateNoteSchema.parse(args);
|
|
326
|
+
await updateNote(params.title, params.content);
|
|
327
|
+
|
|
328
|
+
if (params.reindex) {
|
|
329
|
+
try {
|
|
330
|
+
await reindexNote(params.title);
|
|
331
|
+
return textResponse(`Updated and reindexed note: "${params.title}"`);
|
|
332
|
+
} catch (reindexError) {
|
|
333
|
+
debug("Reindex after update failed:", reindexError);
|
|
334
|
+
return textResponse(`Updated note: "${params.title}" (reindexing failed, run index-notes to update)`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return textResponse(`Updated note: "${params.title}"`);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
case "delete-note": {
|
|
342
|
+
const params = DeleteNoteSchema.parse(args);
|
|
343
|
+
if (!params.confirm) {
|
|
344
|
+
return errorResponse("Add confirm: true to delete the note");
|
|
345
|
+
}
|
|
346
|
+
await deleteNote(params.title);
|
|
347
|
+
return textResponse(`Deleted note: "${params.title}"`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
case "move-note": {
|
|
351
|
+
const params = MoveNoteSchema.parse(args);
|
|
352
|
+
await moveNote(params.title, params.folder);
|
|
353
|
+
return textResponse(`Moved note: "${params.title}" to folder "${params.folder}"`);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
default:
|
|
357
|
+
return errorResponse(`Unknown tool: ${name}`);
|
|
358
|
+
}
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (error instanceof z.ZodError) {
|
|
361
|
+
const issues = error.errors.map((e) => `${e.path.join(".")}: ${e.message}`).join(", ");
|
|
362
|
+
return errorResponse(`Invalid arguments: ${issues}`);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Handle other errors gracefully
|
|
366
|
+
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
367
|
+
debug("Tool error:", error);
|
|
368
|
+
return errorResponse(sanitizeErrorMessage(rawMessage));
|
|
369
|
+
}
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
// Start server
|
|
373
|
+
async function main() {
|
|
374
|
+
// Validate environment variables before anything else
|
|
375
|
+
validateEnv();
|
|
376
|
+
|
|
377
|
+
debug("Starting apple-notes-mcp server...");
|
|
378
|
+
const transport = new StdioServerTransport();
|
|
379
|
+
await server.connect(transport);
|
|
380
|
+
debug("Server connected");
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
main().catch((error) => {
|
|
384
|
+
const rawMessage = error instanceof Error ? error.message : String(error);
|
|
385
|
+
console.error("Fatal error:", sanitizeErrorMessage(rawMessage));
|
|
386
|
+
process.exit(1);
|
|
387
|
+
});
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock run-jxa before importing crud module
|
|
4
|
+
vi.mock("run-jxa", () => ({
|
|
5
|
+
runJxa: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
// Mock marked
|
|
9
|
+
vi.mock("marked", () => ({
|
|
10
|
+
marked: {
|
|
11
|
+
parse: vi.fn((text: string) => `<p>${text}</p>`),
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
// Mock read.js
|
|
16
|
+
vi.mock("./read.js", () => ({
|
|
17
|
+
resolveNoteTitle: vi.fn(),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// Mock debug utility
|
|
21
|
+
vi.mock("../utils/debug.js", () => ({
|
|
22
|
+
createDebugLogger: vi.fn(() => vi.fn()),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
import { runJxa } from "run-jxa";
|
|
26
|
+
import { checkReadOnly, createNote, updateNote, deleteNote, moveNote } from "./crud.js";
|
|
27
|
+
import { resolveNoteTitle } from "./read.js";
|
|
28
|
+
|
|
29
|
+
describe("checkReadOnly", () => {
|
|
30
|
+
const originalEnv = process.env.READONLY_MODE;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
delete process.env.READONLY_MODE;
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
if (originalEnv !== undefined) {
|
|
38
|
+
process.env.READONLY_MODE = originalEnv;
|
|
39
|
+
} else {
|
|
40
|
+
delete process.env.READONLY_MODE;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("should not throw when READONLY_MODE is not set", () => {
|
|
45
|
+
expect(() => checkReadOnly()).not.toThrow();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should throw when READONLY_MODE is true", () => {
|
|
49
|
+
process.env.READONLY_MODE = "true";
|
|
50
|
+
expect(() => checkReadOnly()).toThrow("read-only mode");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should not throw when READONLY_MODE is false", () => {
|
|
54
|
+
process.env.READONLY_MODE = "false";
|
|
55
|
+
expect(() => checkReadOnly()).not.toThrow();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("createNote", () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
vi.clearAllMocks();
|
|
62
|
+
delete process.env.READONLY_MODE;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("should throw if READONLY_MODE is enabled", async () => {
|
|
66
|
+
process.env.READONLY_MODE = "true";
|
|
67
|
+
await expect(createNote("Test", "Content")).rejects.toThrow("read-only mode");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should create note successfully", async () => {
|
|
71
|
+
vi.mocked(runJxa).mockResolvedValueOnce("ok");
|
|
72
|
+
|
|
73
|
+
await expect(createNote("New Note", "Content", "Work")).resolves.toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should allow duplicate titles (Apple Notes uses IDs)", async () => {
|
|
77
|
+
vi.mocked(runJxa).mockResolvedValueOnce("ok");
|
|
78
|
+
|
|
79
|
+
// Should not throw even if a note with same title exists
|
|
80
|
+
await expect(createNote("Existing Note", "Content")).resolves.toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("updateNote", () => {
|
|
85
|
+
beforeEach(() => {
|
|
86
|
+
vi.clearAllMocks();
|
|
87
|
+
delete process.env.READONLY_MODE;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("should throw if READONLY_MODE is enabled", async () => {
|
|
91
|
+
process.env.READONLY_MODE = "true";
|
|
92
|
+
await expect(updateNote("Test", "Content")).rejects.toThrow("read-only mode");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("should throw if note not found", async () => {
|
|
96
|
+
vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
|
|
97
|
+
success: false,
|
|
98
|
+
error: "Note not found",
|
|
99
|
+
});
|
|
100
|
+
await expect(updateNote("Missing Note", "Content")).rejects.toThrow("Note not found");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("should update note successfully", async () => {
|
|
104
|
+
vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
|
|
105
|
+
success: true,
|
|
106
|
+
note: { id: "123", title: "Test", folder: "Work" },
|
|
107
|
+
});
|
|
108
|
+
vi.mocked(runJxa).mockResolvedValueOnce("ok");
|
|
109
|
+
|
|
110
|
+
await expect(updateNote("Test", "New Content")).resolves.toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("should include suggestions in error when multiple notes found", async () => {
|
|
114
|
+
vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
|
|
115
|
+
success: false,
|
|
116
|
+
error: "Multiple notes found",
|
|
117
|
+
suggestions: ["Work/Note", "Personal/Note"],
|
|
118
|
+
});
|
|
119
|
+
await expect(updateNote("Note", "Content")).rejects.toThrow("Suggestions: Work/Note, Personal/Note");
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("deleteNote", () => {
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
vi.clearAllMocks();
|
|
126
|
+
delete process.env.READONLY_MODE;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("should throw if READONLY_MODE is enabled", async () => {
|
|
130
|
+
process.env.READONLY_MODE = "true";
|
|
131
|
+
await expect(deleteNote("Test")).rejects.toThrow("read-only mode");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should throw if note not found", async () => {
|
|
135
|
+
vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
|
|
136
|
+
success: false,
|
|
137
|
+
error: "Note not found",
|
|
138
|
+
});
|
|
139
|
+
await expect(deleteNote("Missing Note")).rejects.toThrow("Note not found");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("should delete note successfully", async () => {
|
|
143
|
+
vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
|
|
144
|
+
success: true,
|
|
145
|
+
note: { id: "123", title: "Test", folder: "Work" },
|
|
146
|
+
});
|
|
147
|
+
vi.mocked(runJxa).mockResolvedValueOnce("ok");
|
|
148
|
+
|
|
149
|
+
await expect(deleteNote("Test")).resolves.toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("should include suggestions in error when multiple notes found", async () => {
|
|
153
|
+
vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
|
|
154
|
+
success: false,
|
|
155
|
+
error: "Multiple notes found",
|
|
156
|
+
suggestions: ["Work/Note", "Personal/Note"],
|
|
157
|
+
});
|
|
158
|
+
await expect(deleteNote("Note")).rejects.toThrow("Suggestions: Work/Note, Personal/Note");
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe("moveNote", () => {
|
|
163
|
+
beforeEach(() => {
|
|
164
|
+
vi.clearAllMocks();
|
|
165
|
+
delete process.env.READONLY_MODE;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("should throw if READONLY_MODE is enabled", async () => {
|
|
169
|
+
process.env.READONLY_MODE = "true";
|
|
170
|
+
await expect(moveNote("Test", "NewFolder")).rejects.toThrow("read-only mode");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("should throw if note not found", async () => {
|
|
174
|
+
vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
|
|
175
|
+
success: false,
|
|
176
|
+
error: "Note not found",
|
|
177
|
+
});
|
|
178
|
+
await expect(moveNote("Missing Note", "NewFolder")).rejects.toThrow("Note not found");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("should move note successfully", async () => {
|
|
182
|
+
vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
|
|
183
|
+
success: true,
|
|
184
|
+
note: { id: "123", title: "Test", folder: "Work" },
|
|
185
|
+
});
|
|
186
|
+
vi.mocked(runJxa).mockResolvedValueOnce("ok");
|
|
187
|
+
|
|
188
|
+
await expect(moveNote("Test", "Personal")).resolves.toBeUndefined();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it("should include suggestions in error when multiple notes found", async () => {
|
|
192
|
+
vi.mocked(resolveNoteTitle).mockResolvedValueOnce({
|
|
193
|
+
success: false,
|
|
194
|
+
error: "Multiple notes found",
|
|
195
|
+
suggestions: ["Work/Note", "Personal/Note"],
|
|
196
|
+
});
|
|
197
|
+
await expect(moveNote("Note", "Archive")).rejects.toThrow("Suggestions: Work/Note, Personal/Note");
|
|
198
|
+
});
|
|
199
|
+
});
|