@mauricio.wolff/mcp-obsidian 0.5.5 → 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/README.md +27 -2
- package/dist/server.js +45 -1
- package/dist/src/filesystem.js +78 -0
- package/dist/src/filesystem.test.js +515 -0
- package/dist/src/frontmatter.test.js +86 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,9 +6,26 @@
|
|
|
6
6
|
|
|
7
7
|
A universal AI bridge for Obsidian vaults using the Model Context Protocol (MCP) standard. Connect any MCP-compatible AI assistant to your knowledge base - works with Claude, ChatGPT, and future AI tools. This server provides safe read/write access to your notes while preventing YAML frontmatter corruption.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
<div align="center">
|
|
10
|
+
|
|
11
|
+
[https://mcp-obsidian.org](https://mcp-obsidian.org)
|
|
12
|
+
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<div align="center">
|
|
16
|
+
|
|
17
|
+
[](https://github.com/bitbonsai/mcp-obsidian)
|
|
18
|
+
[](https://github.com/sponsors/bitbonsai)
|
|
19
|
+
[](https://github.com/sponsors/bitbonsai)
|
|
20
|
+
[](https://ko-fi.com/bitbonsai)
|
|
21
|
+
[](https://liberapay.com/bitbonsai/)
|
|
22
|
+
|
|
23
|
+
</div>
|
|
10
24
|
|
|
11
|
-
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
## Universal Compatibility
|
|
28
|
+
Works with any MCP-compatible AI assistant including Claude Desktop, Claude Code, ChatGPT Desktop (Enterprise+), IntelliJ IDEA 2025.1+, Cursor IDE, Windsurf IDE, and future AI platforms that adopt the MCP standard.
|
|
12
29
|
|
|
13
30
|
https://github.com/user-attachments/assets/657ac4c6-1cd2-4cc3-829f-fd095a32f71c
|
|
14
31
|
|
|
@@ -242,6 +259,14 @@ Edit `.claude.json` in your project or add to the projects section:
|
|
|
242
259
|
claude mcp add obsidian --scope user npx @mauricio.wolff/mcp-obsidian /path/to/your/vault
|
|
243
260
|
```
|
|
244
261
|
|
|
262
|
+
#### Goose Desktop
|
|
263
|
+
|
|
264
|
+
On Goose Desktop settings, click **Add custom extension**, and on the command field add:
|
|
265
|
+
|
|
266
|
+
```bash
|
|
267
|
+
npx @mauricio.wolff/mcp-obsidian@latest /path/to/your/vault
|
|
268
|
+
```
|
|
269
|
+
|
|
245
270
|
#### Other MCP-Compatible Clients (2025)
|
|
246
271
|
|
|
247
272
|
**Confirmed MCP Support:**
|
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.
|
|
21
|
+
version: "0.6.1"
|
|
22
22
|
}, {
|
|
23
23
|
capabilities: {
|
|
24
24
|
tools: {},
|
|
@@ -69,6 +69,33 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
69
69
|
required: ["path", "content"]
|
|
70
70
|
}
|
|
71
71
|
},
|
|
72
|
+
{
|
|
73
|
+
name: "patch_note",
|
|
74
|
+
description: "Efficiently update part of a note by replacing a specific string. This is more efficient than rewriting the entire note for small changes.",
|
|
75
|
+
inputSchema: {
|
|
76
|
+
type: "object",
|
|
77
|
+
properties: {
|
|
78
|
+
path: {
|
|
79
|
+
type: "string",
|
|
80
|
+
description: "Path to the note relative to vault root"
|
|
81
|
+
},
|
|
82
|
+
oldString: {
|
|
83
|
+
type: "string",
|
|
84
|
+
description: "The exact string to replace. Must match exactly including whitespace and line breaks."
|
|
85
|
+
},
|
|
86
|
+
newString: {
|
|
87
|
+
type: "string",
|
|
88
|
+
description: "The new string to insert in place of oldString"
|
|
89
|
+
},
|
|
90
|
+
replaceAll: {
|
|
91
|
+
type: "boolean",
|
|
92
|
+
description: "If true, replace all occurrences. If false (default), the operation will fail if multiple matches are found to prevent unintended replacements.",
|
|
93
|
+
default: false
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
required: ["path", "oldString", "newString"]
|
|
97
|
+
}
|
|
98
|
+
},
|
|
72
99
|
{
|
|
73
100
|
name: "list_directory",
|
|
74
101
|
description: "List files and directories in the vault",
|
|
@@ -321,6 +348,23 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
321
348
|
]
|
|
322
349
|
};
|
|
323
350
|
}
|
|
351
|
+
case "patch_note": {
|
|
352
|
+
const result = await fileSystem.patchNote({
|
|
353
|
+
path: trimmedArgs.path,
|
|
354
|
+
oldString: trimmedArgs.oldString,
|
|
355
|
+
newString: trimmedArgs.newString,
|
|
356
|
+
replaceAll: trimmedArgs.replaceAll
|
|
357
|
+
});
|
|
358
|
+
return {
|
|
359
|
+
content: [
|
|
360
|
+
{
|
|
361
|
+
type: "text",
|
|
362
|
+
text: JSON.stringify(result, null, 2)
|
|
363
|
+
}
|
|
364
|
+
],
|
|
365
|
+
isError: !result.success
|
|
366
|
+
};
|
|
367
|
+
}
|
|
324
368
|
case "list_directory": {
|
|
325
369
|
const listing = await fileSystem.listDirectory(trimmedArgs.path || '');
|
|
326
370
|
return {
|
package/dist/src/filesystem.js
CHANGED
|
@@ -129,6 +129,84 @@ export class FileSystemService {
|
|
|
129
129
|
throw new Error(`Failed to write file: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
130
130
|
}
|
|
131
131
|
}
|
|
132
|
+
async patchNote(params) {
|
|
133
|
+
const { path, oldString, newString, replaceAll = false } = params;
|
|
134
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
135
|
+
return {
|
|
136
|
+
success: false,
|
|
137
|
+
path,
|
|
138
|
+
message: `Access denied: ${path}`
|
|
139
|
+
};
|
|
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
|
+
}
|
|
156
|
+
// Validate that oldString and newString are different
|
|
157
|
+
if (oldString === newString) {
|
|
158
|
+
return {
|
|
159
|
+
success: false,
|
|
160
|
+
path,
|
|
161
|
+
message: 'oldString and newString must be different'
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
// Read the existing note
|
|
166
|
+
const note = await this.readNote(path);
|
|
167
|
+
// Get the full content with frontmatter
|
|
168
|
+
const fullContent = note.originalContent;
|
|
169
|
+
// Count occurrences of oldString
|
|
170
|
+
const occurrences = fullContent.split(oldString).length - 1;
|
|
171
|
+
if (occurrences === 0) {
|
|
172
|
+
return {
|
|
173
|
+
success: false,
|
|
174
|
+
path,
|
|
175
|
+
message: `String not found in note: "${oldString.substring(0, 50)}${oldString.length > 50 ? '...' : ''}"`,
|
|
176
|
+
matchCount: 0
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
// If not replaceAll and multiple occurrences exist, fail
|
|
180
|
+
if (!replaceAll && occurrences > 1) {
|
|
181
|
+
return {
|
|
182
|
+
success: false,
|
|
183
|
+
path,
|
|
184
|
+
message: `Found ${occurrences} occurrences of the string. Use replaceAll=true to replace all occurrences, or provide a more specific string to match exactly one occurrence.`,
|
|
185
|
+
matchCount: occurrences
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
// Perform the replacement
|
|
189
|
+
const updatedContent = replaceAll
|
|
190
|
+
? fullContent.split(oldString).join(newString)
|
|
191
|
+
: fullContent.replace(oldString, newString);
|
|
192
|
+
// Write the updated content
|
|
193
|
+
const fullPath = this.resolvePath(path);
|
|
194
|
+
await writeFile(fullPath, updatedContent, 'utf-8');
|
|
195
|
+
return {
|
|
196
|
+
success: true,
|
|
197
|
+
path,
|
|
198
|
+
message: `Successfully replaced ${replaceAll ? occurrences : 1} occurrence${occurrences > 1 ? 's' : ''}`,
|
|
199
|
+
matchCount: occurrences
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
catch (error) {
|
|
203
|
+
return {
|
|
204
|
+
success: false,
|
|
205
|
+
path,
|
|
206
|
+
message: `Failed to patch note: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
132
210
|
async listDirectory(path = '') {
|
|
133
211
|
// Normalize path: treat '.' as root directory
|
|
134
212
|
const normalizedPath = path === '.' ? '' : path;
|
|
@@ -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
|
+
});
|