@mauricio.wolff/mcp-obsidian 0.6.0 → 0.6.1
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/dist/server.js +1 -1
- package/dist/src/filesystem.js +15 -0
- package/dist/src/filesystem.test.js +515 -0
- package/dist/src/frontmatter.test.js +86 -0
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -18,7 +18,7 @@ const fileSystem = new FileSystemService(vaultPath, pathFilter, frontmatterHandl
|
|
|
18
18
|
const searchService = new SearchService(vaultPath, pathFilter);
|
|
19
19
|
const server = new Server({
|
|
20
20
|
name: "mcp-obsidian",
|
|
21
|
-
version: "0.6.
|
|
21
|
+
version: "0.6.1"
|
|
22
22
|
}, {
|
|
23
23
|
capabilities: {
|
|
24
24
|
tools: {},
|
package/dist/src/filesystem.js
CHANGED
|
@@ -138,6 +138,21 @@ export class FileSystemService {
|
|
|
138
138
|
message: `Access denied: ${path}`
|
|
139
139
|
};
|
|
140
140
|
}
|
|
141
|
+
// Validate that strings are not empty
|
|
142
|
+
if (!oldString || oldString.trim() === '') {
|
|
143
|
+
return {
|
|
144
|
+
success: false,
|
|
145
|
+
path,
|
|
146
|
+
message: 'oldString cannot be empty'
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
if (newString === '') {
|
|
150
|
+
return {
|
|
151
|
+
success: false,
|
|
152
|
+
path,
|
|
153
|
+
message: 'newString cannot be empty'
|
|
154
|
+
};
|
|
155
|
+
}
|
|
141
156
|
// Validate that oldString and newString are different
|
|
142
157
|
if (oldString === newString) {
|
|
143
158
|
return {
|
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import { test, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { FileSystemService } from "./filesystem.js";
|
|
3
|
+
import { writeFile, mkdir, rmdir } from "fs/promises";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
const testVaultPath = "/tmp/test-vault-filesystem";
|
|
6
|
+
let fileSystem;
|
|
7
|
+
beforeEach(async () => {
|
|
8
|
+
await mkdir(testVaultPath, { recursive: true });
|
|
9
|
+
fileSystem = new FileSystemService(testVaultPath);
|
|
10
|
+
});
|
|
11
|
+
afterEach(async () => {
|
|
12
|
+
try {
|
|
13
|
+
await rmdir(testVaultPath, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
catch (error) {
|
|
16
|
+
// Ignore cleanup errors
|
|
17
|
+
}
|
|
18
|
+
});
|
|
19
|
+
// ============================================================================
|
|
20
|
+
// PATCH TESTS
|
|
21
|
+
// ============================================================================
|
|
22
|
+
test("patch note with single occurrence", async () => {
|
|
23
|
+
const testPath = "test-note.md";
|
|
24
|
+
const content = "# Test Note\n\nThis is the old content.\n\nMore text here.";
|
|
25
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
26
|
+
const result = await fileSystem.patchNote({
|
|
27
|
+
path: testPath,
|
|
28
|
+
oldString: "old content",
|
|
29
|
+
newString: "new content",
|
|
30
|
+
replaceAll: false
|
|
31
|
+
});
|
|
32
|
+
expect(result.success).toBe(true);
|
|
33
|
+
expect(result.matchCount).toBe(1);
|
|
34
|
+
expect(result.message).toContain("Successfully replaced 1 occurrence");
|
|
35
|
+
const updatedNote = await fileSystem.readNote(testPath);
|
|
36
|
+
expect(updatedNote.content).toContain("new content");
|
|
37
|
+
expect(updatedNote.content).not.toContain("old content");
|
|
38
|
+
});
|
|
39
|
+
test("patch note with multiple occurrences requires replaceAll", async () => {
|
|
40
|
+
const testPath = "test-note.md";
|
|
41
|
+
const content = "# Test\n\nrepeat word repeat word repeat";
|
|
42
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
43
|
+
const result = await fileSystem.patchNote({
|
|
44
|
+
path: testPath,
|
|
45
|
+
oldString: "repeat",
|
|
46
|
+
newString: "unique",
|
|
47
|
+
replaceAll: false
|
|
48
|
+
});
|
|
49
|
+
expect(result.success).toBe(false);
|
|
50
|
+
expect(result.matchCount).toBe(3);
|
|
51
|
+
expect(result.message).toContain("Found 3 occurrences");
|
|
52
|
+
expect(result.message).toContain("Use replaceAll=true");
|
|
53
|
+
});
|
|
54
|
+
test("patch note with replaceAll replaces all occurrences", async () => {
|
|
55
|
+
const testPath = "test-note.md";
|
|
56
|
+
const content = "# Test\n\nrepeat word repeat word repeat";
|
|
57
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
58
|
+
const result = await fileSystem.patchNote({
|
|
59
|
+
path: testPath,
|
|
60
|
+
oldString: "repeat",
|
|
61
|
+
newString: "unique",
|
|
62
|
+
replaceAll: true
|
|
63
|
+
});
|
|
64
|
+
expect(result.success).toBe(true);
|
|
65
|
+
expect(result.matchCount).toBe(3);
|
|
66
|
+
expect(result.message).toContain("Successfully replaced 3 occurrences");
|
|
67
|
+
const updatedNote = await fileSystem.readNote(testPath);
|
|
68
|
+
expect(updatedNote.content).not.toContain("repeat");
|
|
69
|
+
expect(updatedNote.content.match(/unique/g)?.length).toBe(3);
|
|
70
|
+
});
|
|
71
|
+
test("patch note fails when string not found", async () => {
|
|
72
|
+
const testPath = "test-note.md";
|
|
73
|
+
const content = "# Test Note\n\nSome content here.";
|
|
74
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
75
|
+
const result = await fileSystem.patchNote({
|
|
76
|
+
path: testPath,
|
|
77
|
+
oldString: "non-existent string",
|
|
78
|
+
newString: "replacement",
|
|
79
|
+
replaceAll: false
|
|
80
|
+
});
|
|
81
|
+
expect(result.success).toBe(false);
|
|
82
|
+
expect(result.matchCount).toBe(0);
|
|
83
|
+
expect(result.message).toContain("String not found");
|
|
84
|
+
});
|
|
85
|
+
test("patch note with multiline replacement", async () => {
|
|
86
|
+
const testPath = "test-note.md";
|
|
87
|
+
const content = "# Test\n\n## Section A\nOld content\nOld lines\n\n## Section B\nOther content";
|
|
88
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
89
|
+
const result = await fileSystem.patchNote({
|
|
90
|
+
path: testPath,
|
|
91
|
+
oldString: "## Section A\nOld content\nOld lines",
|
|
92
|
+
newString: "## Section A\nNew content\nNew improved lines",
|
|
93
|
+
replaceAll: false
|
|
94
|
+
});
|
|
95
|
+
expect(result.success).toBe(true);
|
|
96
|
+
expect(result.matchCount).toBe(1);
|
|
97
|
+
const updatedNote = await fileSystem.readNote(testPath);
|
|
98
|
+
expect(updatedNote.content).toContain("New content");
|
|
99
|
+
expect(updatedNote.content).toContain("New improved lines");
|
|
100
|
+
expect(updatedNote.content).not.toContain("Old content");
|
|
101
|
+
});
|
|
102
|
+
test("patch note with frontmatter preserved", async () => {
|
|
103
|
+
const testPath = "test-note.md";
|
|
104
|
+
const content = `---
|
|
105
|
+
title: My Note
|
|
106
|
+
tags: [test]
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
# Content
|
|
110
|
+
|
|
111
|
+
Old text here.`;
|
|
112
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
113
|
+
const result = await fileSystem.patchNote({
|
|
114
|
+
path: testPath,
|
|
115
|
+
oldString: "Old text here.",
|
|
116
|
+
newString: "New text here.",
|
|
117
|
+
replaceAll: false
|
|
118
|
+
});
|
|
119
|
+
expect(result.success).toBe(true);
|
|
120
|
+
const updatedNote = await fileSystem.readNote(testPath);
|
|
121
|
+
expect(updatedNote.frontmatter.title).toBe("My Note");
|
|
122
|
+
expect(updatedNote.frontmatter.tags).toEqual(["test"]);
|
|
123
|
+
expect(updatedNote.content).toContain("New text here.");
|
|
124
|
+
});
|
|
125
|
+
test("patch note fails when oldString equals newString", async () => {
|
|
126
|
+
const testPath = "test-note.md";
|
|
127
|
+
const content = "# Test\n\nSome content";
|
|
128
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
129
|
+
const result = await fileSystem.patchNote({
|
|
130
|
+
path: testPath,
|
|
131
|
+
oldString: "same",
|
|
132
|
+
newString: "same",
|
|
133
|
+
replaceAll: false
|
|
134
|
+
});
|
|
135
|
+
expect(result.success).toBe(false);
|
|
136
|
+
expect(result.message).toContain("must be different");
|
|
137
|
+
});
|
|
138
|
+
test("patch note fails for filtered paths", async () => {
|
|
139
|
+
const testPath = ".obsidian/config.json";
|
|
140
|
+
const result = await fileSystem.patchNote({
|
|
141
|
+
path: testPath,
|
|
142
|
+
oldString: "old",
|
|
143
|
+
newString: "new",
|
|
144
|
+
replaceAll: false
|
|
145
|
+
});
|
|
146
|
+
expect(result.success).toBe(false);
|
|
147
|
+
expect(result.message).toContain("Access denied");
|
|
148
|
+
});
|
|
149
|
+
test("patch note fails when file doesn't exist", async () => {
|
|
150
|
+
const testPath = "non-existent-note.md";
|
|
151
|
+
const result = await fileSystem.patchNote({
|
|
152
|
+
path: testPath,
|
|
153
|
+
oldString: "old",
|
|
154
|
+
newString: "new",
|
|
155
|
+
replaceAll: false
|
|
156
|
+
});
|
|
157
|
+
expect(result.success).toBe(false);
|
|
158
|
+
expect(result.message).toContain("File not found");
|
|
159
|
+
});
|
|
160
|
+
test("patch note fails with empty oldString", async () => {
|
|
161
|
+
const testPath = "test-note.md";
|
|
162
|
+
const content = "# Test Note\n\nSome content.";
|
|
163
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
164
|
+
const result = await fileSystem.patchNote({
|
|
165
|
+
path: testPath,
|
|
166
|
+
oldString: "",
|
|
167
|
+
newString: "new",
|
|
168
|
+
replaceAll: false
|
|
169
|
+
});
|
|
170
|
+
expect(result.success).toBe(false);
|
|
171
|
+
expect(result.message).toMatch(/empty|filled|required/i);
|
|
172
|
+
});
|
|
173
|
+
test("patch note fails with empty newString", async () => {
|
|
174
|
+
const testPath = "test-note.md";
|
|
175
|
+
const content = "# Test Note\n\nSome content.";
|
|
176
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
177
|
+
const result = await fileSystem.patchNote({
|
|
178
|
+
path: testPath,
|
|
179
|
+
oldString: "content",
|
|
180
|
+
newString: "",
|
|
181
|
+
replaceAll: false
|
|
182
|
+
});
|
|
183
|
+
expect(result.success).toBe(false);
|
|
184
|
+
expect(result.message).toMatch(/empty|filled|required/i);
|
|
185
|
+
});
|
|
186
|
+
test("patch note handles regex special characters literally", async () => {
|
|
187
|
+
const testPath = "test-note.md";
|
|
188
|
+
const content = "Price: $10.50 (special)";
|
|
189
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
190
|
+
const result = await fileSystem.patchNote({
|
|
191
|
+
path: testPath,
|
|
192
|
+
oldString: "$10.50",
|
|
193
|
+
newString: "$15.75",
|
|
194
|
+
replaceAll: false
|
|
195
|
+
});
|
|
196
|
+
expect(result.success).toBe(true);
|
|
197
|
+
const updatedNote = await fileSystem.readNote(testPath);
|
|
198
|
+
expect(updatedNote.content).toContain("$15.75");
|
|
199
|
+
expect(updatedNote.content).not.toContain("$10.50");
|
|
200
|
+
});
|
|
201
|
+
test("patch note preserves tabs and spaces", async () => {
|
|
202
|
+
const testPath = "test-note.md";
|
|
203
|
+
const content = "Line with\ttabs\n Line with spaces\n\tTabbed line";
|
|
204
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
205
|
+
const result = await fileSystem.patchNote({
|
|
206
|
+
path: testPath,
|
|
207
|
+
oldString: "tabs",
|
|
208
|
+
newString: "TABS",
|
|
209
|
+
replaceAll: false
|
|
210
|
+
});
|
|
211
|
+
expect(result.success).toBe(true);
|
|
212
|
+
const updatedNote = await fileSystem.readNote(testPath);
|
|
213
|
+
expect(updatedNote.content).toContain("Line with\tTABS");
|
|
214
|
+
expect(updatedNote.content).toContain("\tTabbed line");
|
|
215
|
+
expect(updatedNote.content).toContain(" Line with spaces");
|
|
216
|
+
});
|
|
217
|
+
test("patch note is case sensitive", async () => {
|
|
218
|
+
const testPath = "test-note.md";
|
|
219
|
+
const content = "Hello world, hello again";
|
|
220
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
221
|
+
const result = await fileSystem.patchNote({
|
|
222
|
+
path: testPath,
|
|
223
|
+
oldString: "hello",
|
|
224
|
+
newString: "hi",
|
|
225
|
+
replaceAll: false
|
|
226
|
+
});
|
|
227
|
+
expect(result.success).toBe(true);
|
|
228
|
+
const updatedNote = await fileSystem.readNote(testPath);
|
|
229
|
+
expect(updatedNote.content).toContain("Hello world");
|
|
230
|
+
expect(updatedNote.content).toContain("hi again");
|
|
231
|
+
});
|
|
232
|
+
test("patch note handles many replacements efficiently", async () => {
|
|
233
|
+
const testPath = "test-note.md";
|
|
234
|
+
const lines = Array.from({ length: 100 }, (_, i) => `Line ${i}: replace_me`);
|
|
235
|
+
const content = lines.join("\n");
|
|
236
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
237
|
+
const startTime = Date.now();
|
|
238
|
+
const result = await fileSystem.patchNote({
|
|
239
|
+
path: testPath,
|
|
240
|
+
oldString: "replace_me",
|
|
241
|
+
newString: "replaced",
|
|
242
|
+
replaceAll: true
|
|
243
|
+
});
|
|
244
|
+
const duration = Date.now() - startTime;
|
|
245
|
+
expect(result.success).toBe(true);
|
|
246
|
+
expect(result.matchCount).toBe(100);
|
|
247
|
+
expect(duration).toBeLessThan(1000);
|
|
248
|
+
const updatedNote = await fileSystem.readNote(testPath);
|
|
249
|
+
expect(updatedNote.content).not.toContain("replace_me");
|
|
250
|
+
expect(updatedNote.content.match(/replaced/g)?.length).toBe(100);
|
|
251
|
+
});
|
|
252
|
+
test("patch note works with path containing spaces", async () => {
|
|
253
|
+
const testPath = "folder name/note with spaces.md";
|
|
254
|
+
const content = "# Test Note\n\nOld content here.";
|
|
255
|
+
await mkdir(join(testVaultPath, "folder name"), { recursive: true });
|
|
256
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
257
|
+
const result = await fileSystem.patchNote({
|
|
258
|
+
path: testPath,
|
|
259
|
+
oldString: "Old content",
|
|
260
|
+
newString: "New content",
|
|
261
|
+
replaceAll: false
|
|
262
|
+
});
|
|
263
|
+
expect(result.success).toBe(true);
|
|
264
|
+
const updatedNote = await fileSystem.readNote(testPath);
|
|
265
|
+
expect(updatedNote.content).toContain("New content");
|
|
266
|
+
});
|
|
267
|
+
// ============================================================================
|
|
268
|
+
// DELETE TESTS
|
|
269
|
+
// ============================================================================
|
|
270
|
+
test("delete note with correct confirmation", async () => {
|
|
271
|
+
const testPath = "test-note.md";
|
|
272
|
+
const content = "# Test Note\n\nThis is a test note to be deleted.";
|
|
273
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
274
|
+
const result = await fileSystem.deleteNote({
|
|
275
|
+
path: testPath,
|
|
276
|
+
confirmPath: testPath
|
|
277
|
+
});
|
|
278
|
+
expect(result.success).toBe(true);
|
|
279
|
+
expect(result.path).toBe(testPath);
|
|
280
|
+
expect(result.message).toContain("Successfully deleted");
|
|
281
|
+
expect(result.message).toContain("cannot be undone");
|
|
282
|
+
});
|
|
283
|
+
test("reject deletion with incorrect confirmation", async () => {
|
|
284
|
+
const testPath = "test-note.md";
|
|
285
|
+
const content = "# Test Note\n\nThis note should not be deleted.";
|
|
286
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
287
|
+
const result = await fileSystem.deleteNote({
|
|
288
|
+
path: testPath,
|
|
289
|
+
confirmPath: "wrong-path.md"
|
|
290
|
+
});
|
|
291
|
+
expect(result.success).toBe(false);
|
|
292
|
+
expect(result.path).toBe(testPath);
|
|
293
|
+
expect(result.message).toContain("confirmation path does not match");
|
|
294
|
+
const fileStillExists = await fileSystem.exists(testPath);
|
|
295
|
+
expect(fileStillExists).toBe(true);
|
|
296
|
+
});
|
|
297
|
+
test("handle deletion of non-existent file", async () => {
|
|
298
|
+
const testPath = "non-existent.md";
|
|
299
|
+
const result = await fileSystem.deleteNote({
|
|
300
|
+
path: testPath,
|
|
301
|
+
confirmPath: testPath
|
|
302
|
+
});
|
|
303
|
+
expect(result.success).toBe(false);
|
|
304
|
+
expect(result.path).toBe(testPath);
|
|
305
|
+
expect(result.message).toContain("File not found");
|
|
306
|
+
});
|
|
307
|
+
test("reject deletion of filtered paths", async () => {
|
|
308
|
+
const testPath = ".obsidian/app.json";
|
|
309
|
+
const result = await fileSystem.deleteNote({
|
|
310
|
+
path: testPath,
|
|
311
|
+
confirmPath: testPath
|
|
312
|
+
});
|
|
313
|
+
expect(result.success).toBe(false);
|
|
314
|
+
expect(result.path).toBe(testPath);
|
|
315
|
+
expect(result.message).toContain("Access denied");
|
|
316
|
+
});
|
|
317
|
+
test("handle directory deletion attempt", async () => {
|
|
318
|
+
const testPath = "test-directory";
|
|
319
|
+
await mkdir(join(testVaultPath, testPath));
|
|
320
|
+
const result = await fileSystem.deleteNote({
|
|
321
|
+
path: testPath,
|
|
322
|
+
confirmPath: testPath
|
|
323
|
+
});
|
|
324
|
+
expect(result.success).toBe(false);
|
|
325
|
+
expect(result.path).toBe(testPath);
|
|
326
|
+
expect(result.message).toContain("is not a file");
|
|
327
|
+
});
|
|
328
|
+
test("delete note with frontmatter", async () => {
|
|
329
|
+
const testPath = "note-with-frontmatter.md";
|
|
330
|
+
const content = `---
|
|
331
|
+
title: Test Note
|
|
332
|
+
tags: [test, delete]
|
|
333
|
+
---
|
|
334
|
+
|
|
335
|
+
# Test Note
|
|
336
|
+
|
|
337
|
+
This note has frontmatter and should be deleted successfully.`;
|
|
338
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
339
|
+
const result = await fileSystem.deleteNote({
|
|
340
|
+
path: testPath,
|
|
341
|
+
confirmPath: testPath
|
|
342
|
+
});
|
|
343
|
+
expect(result.success).toBe(true);
|
|
344
|
+
expect(result.path).toBe(testPath);
|
|
345
|
+
expect(result.message).toContain("Successfully deleted");
|
|
346
|
+
});
|
|
347
|
+
// ============================================================================
|
|
348
|
+
// FRONTMATTER INTEGRATION TESTS
|
|
349
|
+
// ============================================================================
|
|
350
|
+
test("write_note with frontmatter", async () => {
|
|
351
|
+
await fileSystem.writeNote({
|
|
352
|
+
path: "test.md",
|
|
353
|
+
content: "This is test content.",
|
|
354
|
+
frontmatter: {
|
|
355
|
+
title: "Test Note",
|
|
356
|
+
tags: ["test", "example"],
|
|
357
|
+
created: "2023-01-01"
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
const note = await fileSystem.readNote("test.md");
|
|
361
|
+
expect(note.frontmatter.title).toBe("Test Note");
|
|
362
|
+
expect(note.frontmatter.tags).toEqual(["test", "example"]);
|
|
363
|
+
expect(note.frontmatter.created).toBe("2023-01-01");
|
|
364
|
+
expect(note.content.trim()).toBe("This is test content.");
|
|
365
|
+
});
|
|
366
|
+
test("write_note with append mode preserves frontmatter", async () => {
|
|
367
|
+
await fileSystem.writeNote({
|
|
368
|
+
path: "append-test.md",
|
|
369
|
+
content: "Original content.",
|
|
370
|
+
frontmatter: { title: "Original", status: "draft" }
|
|
371
|
+
});
|
|
372
|
+
await fileSystem.writeNote({
|
|
373
|
+
path: "append-test.md",
|
|
374
|
+
content: "\nAppended content.",
|
|
375
|
+
frontmatter: { updated: "2023-12-01" },
|
|
376
|
+
mode: "append"
|
|
377
|
+
});
|
|
378
|
+
const note = await fileSystem.readNote("append-test.md");
|
|
379
|
+
expect(note.frontmatter.title).toBe("Original");
|
|
380
|
+
expect(note.frontmatter.status).toBe("draft");
|
|
381
|
+
expect(note.frontmatter.updated).toBe("2023-12-01");
|
|
382
|
+
expect(note.content.trim()).toBe("Original content.\n\nAppended content.");
|
|
383
|
+
});
|
|
384
|
+
test("update_frontmatter merges with existing", async () => {
|
|
385
|
+
await fileSystem.writeNote({
|
|
386
|
+
path: "update-test.md",
|
|
387
|
+
content: "Test content.",
|
|
388
|
+
frontmatter: {
|
|
389
|
+
title: "Original Title",
|
|
390
|
+
tags: ["original"],
|
|
391
|
+
status: "draft"
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
await fileSystem.updateFrontmatter({
|
|
395
|
+
path: "update-test.md",
|
|
396
|
+
frontmatter: {
|
|
397
|
+
title: "Updated Title",
|
|
398
|
+
priority: "high"
|
|
399
|
+
},
|
|
400
|
+
merge: true
|
|
401
|
+
});
|
|
402
|
+
const note = await fileSystem.readNote("update-test.md");
|
|
403
|
+
expect(note.frontmatter.title).toBe("Updated Title");
|
|
404
|
+
expect(note.frontmatter.tags).toEqual(["original"]);
|
|
405
|
+
expect(note.frontmatter.status).toBe("draft");
|
|
406
|
+
expect(note.frontmatter.priority).toBe("high");
|
|
407
|
+
expect(note.content.trim()).toBe("Test content.");
|
|
408
|
+
});
|
|
409
|
+
test("update_frontmatter replaces when merge is false", async () => {
|
|
410
|
+
await fileSystem.writeNote({
|
|
411
|
+
path: "replace-test.md",
|
|
412
|
+
content: "Test content.",
|
|
413
|
+
frontmatter: {
|
|
414
|
+
title: "Original Title",
|
|
415
|
+
tags: ["original"],
|
|
416
|
+
status: "draft"
|
|
417
|
+
}
|
|
418
|
+
});
|
|
419
|
+
await fileSystem.updateFrontmatter({
|
|
420
|
+
path: "replace-test.md",
|
|
421
|
+
frontmatter: {
|
|
422
|
+
title: "New Title",
|
|
423
|
+
priority: "high"
|
|
424
|
+
},
|
|
425
|
+
merge: false
|
|
426
|
+
});
|
|
427
|
+
const note = await fileSystem.readNote("replace-test.md");
|
|
428
|
+
expect(note.frontmatter.title).toBe("New Title");
|
|
429
|
+
expect(note.frontmatter.priority).toBe("high");
|
|
430
|
+
expect(note.frontmatter.tags).toBeUndefined();
|
|
431
|
+
expect(note.frontmatter.status).toBeUndefined();
|
|
432
|
+
});
|
|
433
|
+
test("manage_tags add operation", async () => {
|
|
434
|
+
await fileSystem.writeNote({
|
|
435
|
+
path: "tags-add-test.md",
|
|
436
|
+
content: "Test content.",
|
|
437
|
+
frontmatter: {
|
|
438
|
+
title: "Test",
|
|
439
|
+
tags: ["existing"]
|
|
440
|
+
}
|
|
441
|
+
});
|
|
442
|
+
const result = await fileSystem.manageTags({
|
|
443
|
+
path: "tags-add-test.md",
|
|
444
|
+
operation: "add",
|
|
445
|
+
tags: ["new", "important"]
|
|
446
|
+
});
|
|
447
|
+
expect(result.success).toBe(true);
|
|
448
|
+
expect(result.tags).toEqual(["existing", "new", "important"]);
|
|
449
|
+
const note = await fileSystem.readNote("tags-add-test.md");
|
|
450
|
+
expect(note.frontmatter.tags).toEqual(["existing", "new", "important"]);
|
|
451
|
+
});
|
|
452
|
+
test("manage_tags remove operation", async () => {
|
|
453
|
+
await fileSystem.writeNote({
|
|
454
|
+
path: "tags-remove-test.md",
|
|
455
|
+
content: "Test content.",
|
|
456
|
+
frontmatter: {
|
|
457
|
+
title: "Test",
|
|
458
|
+
tags: ["keep", "remove1", "remove2"]
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
const result = await fileSystem.manageTags({
|
|
462
|
+
path: "tags-remove-test.md",
|
|
463
|
+
operation: "remove",
|
|
464
|
+
tags: ["remove1", "remove2"]
|
|
465
|
+
});
|
|
466
|
+
expect(result.success).toBe(true);
|
|
467
|
+
expect(result.tags).toEqual(["keep"]);
|
|
468
|
+
const note = await fileSystem.readNote("tags-remove-test.md");
|
|
469
|
+
expect(note.frontmatter.tags).toEqual(["keep"]);
|
|
470
|
+
});
|
|
471
|
+
test("manage_tags list operation", async () => {
|
|
472
|
+
await fileSystem.writeNote({
|
|
473
|
+
path: "tags-list-test.md",
|
|
474
|
+
content: "Test content with #inline-tag.",
|
|
475
|
+
frontmatter: {
|
|
476
|
+
title: "Test",
|
|
477
|
+
tags: ["frontmatter-tag"]
|
|
478
|
+
}
|
|
479
|
+
});
|
|
480
|
+
const result = await fileSystem.manageTags({
|
|
481
|
+
path: "tags-list-test.md",
|
|
482
|
+
operation: "list"
|
|
483
|
+
});
|
|
484
|
+
expect(result.success).toBe(true);
|
|
485
|
+
expect(result.tags).toContain("frontmatter-tag");
|
|
486
|
+
expect(result.tags).toContain("inline-tag");
|
|
487
|
+
});
|
|
488
|
+
test("manage_tags removes tags array when empty", async () => {
|
|
489
|
+
await fileSystem.writeNote({
|
|
490
|
+
path: "tags-empty-test.md",
|
|
491
|
+
content: "Test content.",
|
|
492
|
+
frontmatter: {
|
|
493
|
+
title: "Test",
|
|
494
|
+
tags: ["remove-me"]
|
|
495
|
+
}
|
|
496
|
+
});
|
|
497
|
+
await fileSystem.manageTags({
|
|
498
|
+
path: "tags-empty-test.md",
|
|
499
|
+
operation: "remove",
|
|
500
|
+
tags: ["remove-me"]
|
|
501
|
+
});
|
|
502
|
+
const note = await fileSystem.readNote("tags-empty-test.md");
|
|
503
|
+
expect(note.frontmatter.tags).toBeUndefined();
|
|
504
|
+
expect(note.frontmatter.title).toBe("Test");
|
|
505
|
+
});
|
|
506
|
+
test("frontmatter validation with invalid data", async () => {
|
|
507
|
+
await expect(fileSystem.writeNote({
|
|
508
|
+
path: "invalid-test.md",
|
|
509
|
+
content: "Test content.",
|
|
510
|
+
frontmatter: {
|
|
511
|
+
title: "Test",
|
|
512
|
+
invalidFunction: () => "not allowed"
|
|
513
|
+
}
|
|
514
|
+
})).rejects.toThrow(/Invalid frontmatter/);
|
|
515
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { test, expect } from "vitest";
|
|
2
|
+
import { FrontmatterHandler } from "./frontmatter.js";
|
|
3
|
+
const handler = new FrontmatterHandler();
|
|
4
|
+
test("parse note with frontmatter", () => {
|
|
5
|
+
const content = `---
|
|
6
|
+
title: Test Note
|
|
7
|
+
tags: [test, example]
|
|
8
|
+
created: 2023-01-01
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Test Note
|
|
12
|
+
|
|
13
|
+
This is a test note with frontmatter.`;
|
|
14
|
+
const result = handler.parse(content);
|
|
15
|
+
expect(result.frontmatter.title).toBe("Test Note");
|
|
16
|
+
expect(result.frontmatter.tags).toEqual(["test", "example"]);
|
|
17
|
+
expect(result.frontmatter.created).toEqual(new Date("2023-01-01"));
|
|
18
|
+
expect(result.content.trim()).toBe("# Test Note\n\nThis is a test note with frontmatter.");
|
|
19
|
+
});
|
|
20
|
+
test("parse note without frontmatter", () => {
|
|
21
|
+
const content = `# Test Note
|
|
22
|
+
|
|
23
|
+
This is a test note without frontmatter.`;
|
|
24
|
+
const result = handler.parse(content);
|
|
25
|
+
expect(result.frontmatter).toEqual({});
|
|
26
|
+
expect(result.content).toBe(content);
|
|
27
|
+
});
|
|
28
|
+
test("stringify with frontmatter", () => {
|
|
29
|
+
const frontmatter = {
|
|
30
|
+
title: "Test Note",
|
|
31
|
+
tags: ["test", "example"]
|
|
32
|
+
};
|
|
33
|
+
const content = "# Test Note\n\nContent here.";
|
|
34
|
+
const result = handler.stringify(frontmatter, content);
|
|
35
|
+
expect(result).toContain("---");
|
|
36
|
+
expect(result).toContain("title: Test Note");
|
|
37
|
+
expect(result).toContain("tags:");
|
|
38
|
+
expect(result).toContain("# Test Note");
|
|
39
|
+
});
|
|
40
|
+
test("stringify without frontmatter", () => {
|
|
41
|
+
const content = "# Test Note\n\nContent here.";
|
|
42
|
+
const result = handler.stringify({}, content);
|
|
43
|
+
expect(result).toBe(content);
|
|
44
|
+
});
|
|
45
|
+
test("validate valid frontmatter", () => {
|
|
46
|
+
const frontmatter = {
|
|
47
|
+
title: "Valid Title",
|
|
48
|
+
tags: ["tag1", "tag2"],
|
|
49
|
+
date: new Date("2023-01-01"),
|
|
50
|
+
count: 42,
|
|
51
|
+
enabled: true
|
|
52
|
+
};
|
|
53
|
+
const result = handler.validate(frontmatter);
|
|
54
|
+
expect(result.isValid).toBe(true);
|
|
55
|
+
expect(result.errors).toHaveLength(0);
|
|
56
|
+
});
|
|
57
|
+
test("validate invalid frontmatter with function", () => {
|
|
58
|
+
const frontmatter = {
|
|
59
|
+
title: "Invalid",
|
|
60
|
+
badFunction: () => "not allowed"
|
|
61
|
+
};
|
|
62
|
+
const result = handler.validate(frontmatter);
|
|
63
|
+
expect(result.isValid).toBe(false);
|
|
64
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
65
|
+
// The specific error message may vary between YAML libraries
|
|
66
|
+
expect(result.errors[0]).toMatch(/Functions are not allowed|Invalid YAML structure/);
|
|
67
|
+
});
|
|
68
|
+
test("update frontmatter in existing content", () => {
|
|
69
|
+
const content = `---
|
|
70
|
+
title: Old Title
|
|
71
|
+
tags: [old]
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
# Content
|
|
75
|
+
|
|
76
|
+
Some content here.`;
|
|
77
|
+
const updates = {
|
|
78
|
+
title: "New Title",
|
|
79
|
+
modified: "2023-12-01"
|
|
80
|
+
};
|
|
81
|
+
const result = handler.updateFrontmatter(content, updates);
|
|
82
|
+
expect(result).toContain("title: New Title");
|
|
83
|
+
expect(result).toContain("modified: '2023-12-01'");
|
|
84
|
+
expect(result).toContain("tags:");
|
|
85
|
+
expect(result).toContain("# Content");
|
|
86
|
+
});
|