@bitbonsai/mcpvault 0.17.3 → 0.18.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 +2 -2
- package/dist/src/filesystem.d.ts +3 -0
- package/dist/src/filesystem.d.ts.map +1 -1
- package/dist/src/filesystem.js +214 -156
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ https://github.com/user-attachments/assets/657ac4c6-1cd2-4cc3-829f-fd095a32f71c
|
|
|
31
31
|
1. **Install Node.js runtime:**
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
# Download from https://nodejs.org (
|
|
34
|
+
# Download from https://nodejs.org (v22.12.0 or later)
|
|
35
35
|
# or use a package manager like nvm, brew, apt, etc.
|
|
36
36
|
```
|
|
37
37
|
|
|
@@ -125,7 +125,7 @@ An MCP client starts MCPVault as a local stdio process and passes the vault path
|
|
|
125
125
|
|
|
126
126
|
## Prerequisites
|
|
127
127
|
|
|
128
|
-
- [Node.js](https://nodejs.org) runtime (
|
|
128
|
+
- [Node.js](https://nodejs.org) runtime (v22.12.0 or later)
|
|
129
129
|
- An Obsidian vault (local directory with `.md`, `.markdown`, `.txt`, `.base`, or `.canvas` files)
|
|
130
130
|
- MCP-compatible AI client (Claude Desktop, ChatGPT Desktop, Claude Code, etc.)
|
|
131
131
|
|
package/dist/src/filesystem.d.ts
CHANGED
|
@@ -15,6 +15,7 @@ export declare class FileSystemService {
|
|
|
15
15
|
private vaultPath;
|
|
16
16
|
private frontmatterHandler;
|
|
17
17
|
private pathFilter;
|
|
18
|
+
private writeChains;
|
|
18
19
|
constructor(vaultPath: string, pathFilter?: PathFilter, frontmatterHandler?: FrontmatterHandler);
|
|
19
20
|
/**
|
|
20
21
|
* Normalize an incoming path to be vault-relative. Strips leading slashes
|
|
@@ -23,6 +24,8 @@ export declare class FileSystemService {
|
|
|
23
24
|
*/
|
|
24
25
|
private normalizePath;
|
|
25
26
|
private resolvePath;
|
|
27
|
+
private withPathLock;
|
|
28
|
+
private atomicWrite;
|
|
26
29
|
readNote(path: string): Promise<ParsedNote>;
|
|
27
30
|
writeNote(params: NoteWriteParams): Promise<void>;
|
|
28
31
|
patchNote(params: PatchNoteParams): Promise<PatchNoteResult>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"filesystem.d.ts","sourceRoot":"","sources":["../../src/filesystem.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"filesystem.d.ts","sourceRoot":"","sources":["../../src/filesystem.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AACtD,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,eAAe,EAAE,gBAAgB,EAAE,YAAY,EAAE,cAAc,EAAE,cAAc,EAAE,UAAU,EAAE,eAAe,EAAE,eAAe,EAAE,uBAAuB,EAAE,QAAQ,EAAE,mBAAmB,EAAE,mBAAmB,EAAE,eAAe,EAAE,eAAe,EAAE,UAAU,EAAE,WAAW,EAAE,mBAAmB,EAAE,MAAM,YAAY,CAAC;AAE3V;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,KAAK,CAiBtE;AAoBD,qBAAa,iBAAiB;IAO1B,OAAO,CAAC,SAAS;IANnB,OAAO,CAAC,kBAAkB,CAAqB;IAC/C,OAAO,CAAC,UAAU,CAAa;IAE/B,OAAO,CAAC,WAAW,CAAoC;IAEvD,YACU,SAAS,EAAE,MAAM,EACzB,UAAU,CAAC,EAAE,UAAU,EACvB,kBAAkB,CAAC,EAAE,kBAAkB,EAWxC;IAED;;;;OAIG;IACH,OAAO,CAAC,aAAa;IAsBrB,OAAO,CAAC,WAAW;YAuDL,YAAY;YAiBZ,WAAW;IAgCnB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CA+BhD;IAEK,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CA4EtD;IAEK,SAAS,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CA2FjE;IAEK,aAAa,CAAC,IAAI,GAAE,MAAW,GAAG,OAAO,CAAC,gBAAgB,CAAC,CA4DhE;IAEK,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAc3C;IAEK,WAAW,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAchD;IAEK,UAAU,CAAC,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,YAAY,CAAC,CAyGhE;IAEK,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,CA4H1D;IAEK,QAAQ,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,UAAU,CAAC,CA+H1D;IAEK,iBAAiB,CAAC,MAAM,EAAE,eAAe,GAAG,OAAO,CAAC,eAAe,CAAC,CA+CzE;IAEK,iBAAiB,CAAC,MAAM,EAAE,uBAAuB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuBtE;IAEK,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC,CA0CvD;IAEK,UAAU,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAqG1E;IAED,YAAY,IAAI,MAAM,CAErB;IAED;;;;;;;;;;;;;;OAcG;IACG,mBAAmB,CAAC,YAAY,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC,CA+CjE;IAEK,cAAc,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,EAAE,CAAC,CA+EzD;IAEK,aAAa,CAAC,MAAM,EAAE,mBAAmB,GAAG,OAAO,CAAC,MAAM,CAAC,CAehE;IAEK,aAAa,CAAC,WAAW,GAAE,MAAU,GAAG,OAAO,CAAC,UAAU,CAAC,CAuDhE;IAEK,WAAW,IAAI,OAAO,CAAC,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC,CAiDlE;CACF"}
|
package/dist/src/filesystem.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { join, resolve, relative, dirname } from 'path';
|
|
2
2
|
import { homedir } from 'os';
|
|
3
|
-
import { readdir, stat, readFile, writeFile, unlink, mkdir, access, rename, copyFile } from 'node:fs/promises';
|
|
3
|
+
import { readdir, stat, readFile, writeFile, open, unlink, mkdir, access, rename, copyFile } from 'node:fs/promises';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
4
5
|
import { constants, lstatSync, realpathSync } from 'node:fs';
|
|
5
6
|
import trash from 'trash';
|
|
6
7
|
import { FrontmatterHandler } from './frontmatter.js';
|
|
@@ -54,6 +55,8 @@ export class FileSystemService {
|
|
|
54
55
|
vaultPath;
|
|
55
56
|
frontmatterHandler;
|
|
56
57
|
pathFilter;
|
|
58
|
+
// shortcut: instance-local locks only, add cross-process coordination if shared-vault writers need it.
|
|
59
|
+
writeChains = new Map();
|
|
57
60
|
constructor(vaultPath, pathFilter, frontmatterHandler) {
|
|
58
61
|
this.vaultPath = vaultPath;
|
|
59
62
|
const resolved = resolve(vaultPath);
|
|
@@ -96,7 +99,7 @@ export class FileSystemService {
|
|
|
96
99
|
}
|
|
97
100
|
return p;
|
|
98
101
|
}
|
|
99
|
-
resolvePath(relativePath) {
|
|
102
|
+
resolvePath(relativePath, followSymlinks = false) {
|
|
100
103
|
const normalizedPath = this.normalizePath(relativePath);
|
|
101
104
|
const fullPath = resolve(join(this.vaultPath, normalizedPath));
|
|
102
105
|
// Security check: ensure path is within vault (lexical)
|
|
@@ -112,6 +115,13 @@ export class FileSystemService {
|
|
|
112
115
|
if (relative(this.vaultPath, realPath).startsWith('..')) {
|
|
113
116
|
throw new Error(`Symlink target is outside vault: ${relativePath}. Symbolic links must resolve to a path within the vault directory.`);
|
|
114
117
|
}
|
|
118
|
+
if (followSymlinks) {
|
|
119
|
+
const target = resolve(realPath, relative(candidate, fullPath));
|
|
120
|
+
if (!this.pathFilter.isAllowed(relative(this.vaultPath, target))) {
|
|
121
|
+
throw new Error(`Access denied: ${relativePath}. The resolved target is restricted.`);
|
|
122
|
+
}
|
|
123
|
+
return target;
|
|
124
|
+
}
|
|
115
125
|
break;
|
|
116
126
|
}
|
|
117
127
|
catch (err) {
|
|
@@ -146,6 +156,61 @@ export class FileSystemService {
|
|
|
146
156
|
}
|
|
147
157
|
return fullPath;
|
|
148
158
|
}
|
|
159
|
+
async withPathLock(path, fn) {
|
|
160
|
+
const fullPath = this.resolvePath(path, true);
|
|
161
|
+
const previous = this.writeChains.get(fullPath) ?? Promise.resolve();
|
|
162
|
+
let release;
|
|
163
|
+
const current = new Promise((resolve) => { release = resolve; });
|
|
164
|
+
this.writeChains.set(fullPath, current);
|
|
165
|
+
await previous;
|
|
166
|
+
try {
|
|
167
|
+
return await fn(fullPath);
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
release();
|
|
171
|
+
if (this.writeChains.get(fullPath) === current) {
|
|
172
|
+
this.writeChains.delete(fullPath);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
async atomicWrite(fullPath, content) {
|
|
177
|
+
let mode;
|
|
178
|
+
try {
|
|
179
|
+
const target = await stat(fullPath);
|
|
180
|
+
if (!target.isFile()) {
|
|
181
|
+
throw new Error(`Cannot write non-regular file: ${relative(this.vaultPath, fullPath) || '/'}`);
|
|
182
|
+
}
|
|
183
|
+
mode = target.mode & 0o777;
|
|
184
|
+
await access(fullPath, constants.W_OK);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT')
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
191
|
+
const tmpPath = join(dirname(fullPath), `.mcpvault-${randomUUID()}.tmp`);
|
|
192
|
+
const file = await open(tmpPath, 'wx', mode ?? 0o666);
|
|
193
|
+
try {
|
|
194
|
+
try {
|
|
195
|
+
await file.writeFile(content, 'utf-8');
|
|
196
|
+
// Creation applies umask; existing notes must retain their exact permissions.
|
|
197
|
+
if (mode !== undefined)
|
|
198
|
+
await file.chmod(mode);
|
|
199
|
+
await file.sync();
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
await file.close();
|
|
203
|
+
}
|
|
204
|
+
await rename(tmpPath, fullPath);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
try {
|
|
208
|
+
await unlink(tmpPath);
|
|
209
|
+
}
|
|
210
|
+
catch { /* best effort */ }
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
149
214
|
async readNote(path) {
|
|
150
215
|
path = this.normalizePath(path);
|
|
151
216
|
const fullPath = this.resolvePath(path);
|
|
@@ -179,7 +244,6 @@ export class FileSystemService {
|
|
|
179
244
|
async writeNote(params) {
|
|
180
245
|
const { content, frontmatter, mode = 'overwrite' } = params;
|
|
181
246
|
const path = this.normalizePath(params.path);
|
|
182
|
-
const fullPath = this.resolvePath(path);
|
|
183
247
|
if (!this.pathFilter.isAllowed(path)) {
|
|
184
248
|
throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
|
|
185
249
|
}
|
|
@@ -194,50 +258,54 @@ export class FileSystemService {
|
|
|
194
258
|
throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
|
|
195
259
|
}
|
|
196
260
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
finalContent
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
}
|
|
205
|
-
else {
|
|
206
|
-
// For append/prepend, we need to read existing content
|
|
207
|
-
let existingNote;
|
|
208
|
-
try {
|
|
209
|
-
existingNote = await this.readNote(path);
|
|
210
|
-
}
|
|
211
|
-
catch (error) {
|
|
212
|
-
// File doesn't exist, treat as overwrite
|
|
261
|
+
// Serialize the read-modify-write against concurrent writers of the same file,
|
|
262
|
+
// and replace the file atomically so a torn/partial file is never observed.
|
|
263
|
+
return this.withPathLock(path, async (fullPath) => {
|
|
264
|
+
try {
|
|
265
|
+
let finalContent;
|
|
266
|
+
if (mode === 'overwrite') {
|
|
267
|
+
// Original behavior - replace entire content
|
|
213
268
|
finalContent = frontmatter
|
|
214
269
|
? this.frontmatterHandler.stringify(frontmatter, content)
|
|
215
270
|
: content;
|
|
216
271
|
}
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
const mergedContent = mode === 'append'
|
|
223
|
-
? existingNote.content + content
|
|
224
|
-
: content + existingNote.content;
|
|
225
|
-
if (existingNote.matter && existingNote.matter.trim() !== '') {
|
|
226
|
-
// Preserve raw formatting for unmodified fields by only applying explicit updates
|
|
227
|
-
finalContent = this.frontmatterHandler.preserveStringify(existingNote.matter, frontmatter || {}, mergedContent);
|
|
272
|
+
else {
|
|
273
|
+
// For append/prepend, we need to read existing content
|
|
274
|
+
let existingNote;
|
|
275
|
+
try {
|
|
276
|
+
existingNote = this.frontmatterHandler.parse(await readFile(fullPath, 'utf-8'));
|
|
228
277
|
}
|
|
229
|
-
|
|
230
|
-
|
|
278
|
+
catch (error) {
|
|
279
|
+
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT')
|
|
280
|
+
throw error;
|
|
281
|
+
// File doesn't exist, treat as overwrite
|
|
282
|
+
finalContent = frontmatter
|
|
283
|
+
? this.frontmatterHandler.stringify(frontmatter, content)
|
|
284
|
+
: content;
|
|
285
|
+
}
|
|
286
|
+
if (existingNote) {
|
|
287
|
+
// Merge frontmatter if provided
|
|
288
|
+
const mergedFrontmatter = frontmatter
|
|
289
|
+
? { ...existingNote.frontmatter, ...frontmatter }
|
|
290
|
+
: existingNote.frontmatter;
|
|
291
|
+
const mergedContent = mode === 'append'
|
|
292
|
+
? existingNote.content + content
|
|
293
|
+
: content + existingNote.content;
|
|
294
|
+
if (existingNote.matter && existingNote.matter.trim() !== '') {
|
|
295
|
+
// Preserve raw formatting for unmodified fields by only applying explicit updates
|
|
296
|
+
finalContent = this.frontmatterHandler.preserveStringify(existingNote.matter, frontmatter || {}, mergedContent);
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
finalContent = this.frontmatterHandler.stringify(mergedFrontmatter, mergedContent);
|
|
300
|
+
}
|
|
231
301
|
}
|
|
232
302
|
}
|
|
303
|
+
await this.atomicWrite(fullPath, finalContent);
|
|
233
304
|
}
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
}
|
|
238
|
-
catch (error) {
|
|
239
|
-
throw classifyWriteError(error, path);
|
|
240
|
-
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
throw classifyWriteError(error, path);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
241
309
|
}
|
|
242
310
|
async patchNote(params) {
|
|
243
311
|
const { oldString, newString, replaceAll = false } = params;
|
|
@@ -273,44 +341,44 @@ export class FileSystemService {
|
|
|
273
341
|
};
|
|
274
342
|
}
|
|
275
343
|
try {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
344
|
+
return await this.withPathLock(path, async (fullPath) => {
|
|
345
|
+
const note = await this.readNote(fullPath);
|
|
346
|
+
// Get the full content with frontmatter
|
|
347
|
+
const fullContent = note.originalContent;
|
|
348
|
+
// Count occurrences of oldString
|
|
349
|
+
const occurrences = fullContent.split(oldString).length - 1;
|
|
350
|
+
if (occurrences === 0) {
|
|
351
|
+
return {
|
|
352
|
+
success: false,
|
|
353
|
+
path,
|
|
354
|
+
message: `String not found in note: "${oldString.substring(0, 50)}${oldString.length > 50 ? '...' : ''}"`,
|
|
355
|
+
matchCount: 0
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
// If not replaceAll and multiple occurrences exist, fail
|
|
359
|
+
if (!replaceAll && occurrences > 1) {
|
|
360
|
+
return {
|
|
361
|
+
success: false,
|
|
362
|
+
path,
|
|
363
|
+
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.`,
|
|
364
|
+
matchCount: occurrences
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
// Perform the replacement
|
|
368
|
+
// Use a replacer function so newString is inserted literally,
|
|
369
|
+
// without $ replacement pattern expansion ($$, $&, $`, $')
|
|
370
|
+
const updatedContent = replaceAll
|
|
371
|
+
? fullContent.split(oldString).join(newString)
|
|
372
|
+
: fullContent.replace(oldString, () => newString);
|
|
373
|
+
// Write the updated content atomically
|
|
374
|
+
await this.atomicWrite(fullPath, updatedContent);
|
|
292
375
|
return {
|
|
293
|
-
success:
|
|
376
|
+
success: true,
|
|
294
377
|
path,
|
|
295
|
-
message: `
|
|
378
|
+
message: `Successfully replaced ${replaceAll ? occurrences : 1} occurrence${occurrences > 1 ? 's' : ''}`,
|
|
296
379
|
matchCount: occurrences
|
|
297
380
|
};
|
|
298
|
-
}
|
|
299
|
-
// Perform the replacement
|
|
300
|
-
// Use a replacer function so newString is inserted literally,
|
|
301
|
-
// without $ replacement pattern expansion ($$, $&, $`, $')
|
|
302
|
-
const updatedContent = replaceAll
|
|
303
|
-
? fullContent.split(oldString).join(newString)
|
|
304
|
-
: fullContent.replace(oldString, () => newString);
|
|
305
|
-
// Write the updated content
|
|
306
|
-
const fullPath = this.resolvePath(path);
|
|
307
|
-
await writeFile(fullPath, updatedContent, 'utf-8');
|
|
308
|
-
return {
|
|
309
|
-
success: true,
|
|
310
|
-
path,
|
|
311
|
-
message: `Successfully replaced ${replaceAll ? occurrences : 1} occurrence${occurrences > 1 ? 's' : ''}`,
|
|
312
|
-
matchCount: occurrences
|
|
313
|
-
};
|
|
381
|
+
});
|
|
314
382
|
}
|
|
315
383
|
catch (error) {
|
|
316
384
|
return {
|
|
@@ -791,31 +859,20 @@ export class FileSystemService {
|
|
|
791
859
|
if (!this.pathFilter.isAllowed(path)) {
|
|
792
860
|
throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
|
|
793
861
|
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
const updatedContent = this.frontmatterHandler.preserveStringify(note.matter, frontmatter, note.content);
|
|
809
|
-
await writeFile(fullPath, updatedContent, 'utf-8');
|
|
810
|
-
}
|
|
811
|
-
else {
|
|
812
|
-
// Replace frontmatter entirely (or no existing matter to preserve)
|
|
813
|
-
await this.writeNote({
|
|
814
|
-
path,
|
|
815
|
-
content: note.content,
|
|
816
|
-
frontmatter: newFrontmatter
|
|
817
|
-
});
|
|
818
|
-
}
|
|
862
|
+
return this.withPathLock(path, async (fullPath) => {
|
|
863
|
+
const note = await this.readNote(fullPath);
|
|
864
|
+
const newFrontmatter = merge
|
|
865
|
+
? { ...note.frontmatter, ...frontmatter }
|
|
866
|
+
: frontmatter;
|
|
867
|
+
const validation = this.frontmatterHandler.validate(newFrontmatter);
|
|
868
|
+
if (!validation.isValid) {
|
|
869
|
+
throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
|
|
870
|
+
}
|
|
871
|
+
const updatedContent = merge && note.matter && note.matter.trim() !== ''
|
|
872
|
+
? this.frontmatterHandler.preserveStringify(note.matter, frontmatter, note.content)
|
|
873
|
+
: this.frontmatterHandler.stringify(newFrontmatter, note.content);
|
|
874
|
+
await this.atomicWrite(fullPath, updatedContent);
|
|
875
|
+
});
|
|
819
876
|
}
|
|
820
877
|
async getNotesInfo(paths) {
|
|
821
878
|
const results = await Promise.allSettled(paths.map(async (rawPath) => {
|
|
@@ -866,72 +923,73 @@ export class FileSystemService {
|
|
|
866
923
|
};
|
|
867
924
|
}
|
|
868
925
|
try {
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
if (
|
|
874
|
-
|
|
926
|
+
return await this.withPathLock(path, async (fullPath) => {
|
|
927
|
+
const note = await this.readNote(fullPath);
|
|
928
|
+
let currentTags = [];
|
|
929
|
+
// Extract tags from frontmatter
|
|
930
|
+
if (note.frontmatter.tags) {
|
|
931
|
+
if (Array.isArray(note.frontmatter.tags)) {
|
|
932
|
+
currentTags = note.frontmatter.tags;
|
|
933
|
+
}
|
|
934
|
+
else if (typeof note.frontmatter.tags === 'string') {
|
|
935
|
+
currentTags = [note.frontmatter.tags];
|
|
936
|
+
}
|
|
875
937
|
}
|
|
876
|
-
|
|
877
|
-
|
|
938
|
+
// Also extract inline tags from content
|
|
939
|
+
const inlineTagMatches = note.content.match(/#[a-zA-Z0-9_-]+/g) || [];
|
|
940
|
+
const inlineTags = inlineTagMatches.map(tag => tag.slice(1)); // Remove #
|
|
941
|
+
currentTags = [...new Set([...currentTags, ...inlineTags])]; // Deduplicate
|
|
942
|
+
if (operation === 'list') {
|
|
943
|
+
return {
|
|
944
|
+
path,
|
|
945
|
+
operation,
|
|
946
|
+
tags: currentTags,
|
|
947
|
+
success: true
|
|
948
|
+
};
|
|
878
949
|
}
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
return {
|
|
886
|
-
path,
|
|
887
|
-
operation,
|
|
888
|
-
tags: currentTags,
|
|
889
|
-
success: true
|
|
890
|
-
};
|
|
891
|
-
}
|
|
892
|
-
let newTags = [...currentTags];
|
|
893
|
-
if (operation === 'add') {
|
|
894
|
-
for (const tag of tags) {
|
|
895
|
-
if (!newTags.includes(tag)) {
|
|
896
|
-
newTags.push(tag);
|
|
950
|
+
let newTags = [...currentTags];
|
|
951
|
+
if (operation === 'add') {
|
|
952
|
+
for (const tag of tags) {
|
|
953
|
+
if (!newTags.includes(tag)) {
|
|
954
|
+
newTags.push(tag);
|
|
955
|
+
}
|
|
897
956
|
}
|
|
898
957
|
}
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
const tagUpdates = {};
|
|
905
|
-
if (newTags.length > 0) {
|
|
906
|
-
tagUpdates.tags = newTags;
|
|
907
|
-
}
|
|
908
|
-
else {
|
|
909
|
-
tagUpdates.tags = undefined;
|
|
910
|
-
}
|
|
911
|
-
// Write back the note with updated frontmatter, preserving raw formatting for unmodified fields
|
|
912
|
-
let updatedContent;
|
|
913
|
-
if (note.matter && note.matter.trim() !== '') {
|
|
914
|
-
updatedContent = this.frontmatterHandler.preserveStringify(note.matter, tagUpdates, note.content);
|
|
915
|
-
}
|
|
916
|
-
else {
|
|
917
|
-
const updatedFrontmatter = { ...note.frontmatter };
|
|
958
|
+
else if (operation === 'remove') {
|
|
959
|
+
newTags = newTags.filter(tag => !tags.includes(tag));
|
|
960
|
+
}
|
|
961
|
+
// Build tag updates for preserveStringify
|
|
962
|
+
const tagUpdates = {};
|
|
918
963
|
if (newTags.length > 0) {
|
|
919
|
-
|
|
964
|
+
tagUpdates.tags = newTags;
|
|
920
965
|
}
|
|
921
966
|
else {
|
|
922
|
-
|
|
967
|
+
tagUpdates.tags = undefined;
|
|
923
968
|
}
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
969
|
+
// Write back the note with updated frontmatter, preserving raw formatting for unmodified fields
|
|
970
|
+
let updatedContent;
|
|
971
|
+
if (note.matter && note.matter.trim() !== '') {
|
|
972
|
+
updatedContent = this.frontmatterHandler.preserveStringify(note.matter, tagUpdates, note.content);
|
|
973
|
+
}
|
|
974
|
+
else {
|
|
975
|
+
const updatedFrontmatter = { ...note.frontmatter };
|
|
976
|
+
if (newTags.length > 0) {
|
|
977
|
+
updatedFrontmatter.tags = newTags;
|
|
978
|
+
}
|
|
979
|
+
else {
|
|
980
|
+
delete updatedFrontmatter.tags;
|
|
981
|
+
}
|
|
982
|
+
updatedContent = this.frontmatterHandler.stringify(updatedFrontmatter, note.content);
|
|
983
|
+
}
|
|
984
|
+
await this.atomicWrite(fullPath, updatedContent);
|
|
985
|
+
return {
|
|
986
|
+
path,
|
|
987
|
+
operation,
|
|
988
|
+
tags: newTags,
|
|
989
|
+
success: true,
|
|
990
|
+
message: `Successfully ${operation === 'add' ? 'added' : 'removed'} tags`
|
|
991
|
+
};
|
|
992
|
+
});
|
|
935
993
|
}
|
|
936
994
|
catch (error) {
|
|
937
995
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bitbonsai/mcpvault",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.1",
|
|
4
4
|
"description": "Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant",
|
|
5
5
|
"homepage": "https://mcpvault.org",
|
|
6
6
|
"author": "bitbonsai",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"vitest": "^4.1.12"
|
|
54
54
|
},
|
|
55
55
|
"engines": {
|
|
56
|
-
"node": ">=
|
|
56
|
+
"node": ">=22.12.0"
|
|
57
57
|
},
|
|
58
58
|
"repository": {
|
|
59
59
|
"type": "git",
|