@bitbonsai/mcpvault 0.18.0 → 0.18.2
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 +1 -1
- 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/dist/src/frontmatter.d.ts.map +1 -1
- package/dist/src/frontmatter.js +33 -7
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -998,7 +998,7 @@ Only grant write access to clients and conversations you trust. Use `--read-only
|
|
|
998
998
|
- `server.ts` - MCP server entry point
|
|
999
999
|
- `src/cli.ts` - CLI option parsing for vault path, read-only mode, and default exclusions
|
|
1000
1000
|
- `src/createServer.ts` - MCP tool registration, request routing, and discovery defaults
|
|
1001
|
-
- `src/frontmatter.ts` - YAML frontmatter handling with gray-matter
|
|
1001
|
+
- `src/frontmatter.ts` - YAML frontmatter handling with gray-matter, preserving formatting and CRLF line endings; executable JavaScript frontmatter stays inert as note content
|
|
1002
1002
|
- `src/filesystem.ts` - File operations with path validation
|
|
1003
1003
|
- `src/pathfilter.ts` - Directory and file filtering
|
|
1004
1004
|
- `src/search.ts` - Note search functionality with content and frontmatter support
|
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 {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"frontmatter.d.ts","sourceRoot":"","sources":["../../src/frontmatter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,2BAA2B,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"frontmatter.d.ts","sourceRoot":"","sources":["../../src/frontmatter.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,2BAA2B,EAAE,MAAM,YAAY,CAAC;AA0C1E;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,SAAS,CAmB5E;AAED,qBAAa,kBAAkB;IAC7B,KAAK,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,CAkBjC;IAED,SAAS,CAAC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAcvE;IAED,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,2BAA2B,CAmB1E;IAED,OAAO,CAAC,yBAAyB;IAmDjC,iBAAiB,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAyB1F;IAED,kBAAkB,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAGvD;IAED,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,CAUvE;CACF"}
|
package/dist/src/frontmatter.js
CHANGED
|
@@ -8,11 +8,32 @@ const yamlEngine = {
|
|
|
8
8
|
// YAML 1.2 schema (default). Dates stay as strings, but this avoids the
|
|
9
9
|
// yaml-1.1 bug where single-letter keys like 'y'/'n' become booleans.
|
|
10
10
|
// Merge keys (<<) are not resolved, which is fine for Obsidian frontmatter.
|
|
11
|
-
parse: (str) => parse(str),
|
|
11
|
+
parse: (str) => parse(toLf(str)),
|
|
12
12
|
stringify: (data) => stringify(data),
|
|
13
13
|
};
|
|
14
14
|
function withYamlEngine(options = {}) {
|
|
15
|
-
return {
|
|
15
|
+
return {
|
|
16
|
+
...options,
|
|
17
|
+
engines: {
|
|
18
|
+
yaml: yamlEngine,
|
|
19
|
+
// gray-matter merges default engines; override JavaScript to prevent eval, including its js alias.
|
|
20
|
+
javascript: {
|
|
21
|
+
parse: () => { throw new Error('JavaScript frontmatter is not supported'); },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
/** Use CRLF when present, otherwise LF. */
|
|
27
|
+
function detectEol(text) {
|
|
28
|
+
return text.includes('\r\n') ? '\r\n' : '\n';
|
|
29
|
+
}
|
|
30
|
+
// gray-matter leaves a trailing CR before the closing delimiter; normalize before YAML parsing.
|
|
31
|
+
function toLf(text) {
|
|
32
|
+
return text.replace(/\r\n?/g, '\n');
|
|
33
|
+
}
|
|
34
|
+
/** Re-apply CRLF to LF-only text. */
|
|
35
|
+
function applyEol(text, eol) {
|
|
36
|
+
return eol === '\r\n' ? text.replace(/\n/g, '\r\n') : text;
|
|
16
37
|
}
|
|
17
38
|
/**
|
|
18
39
|
* Parse a frontmatter value that may be a JSON string (LLM clients sometimes
|
|
@@ -67,7 +88,10 @@ export class FrontmatterHandler {
|
|
|
67
88
|
if (!frontmatterData || Object.keys(frontmatterData).length === 0) {
|
|
68
89
|
return content;
|
|
69
90
|
}
|
|
70
|
-
|
|
91
|
+
const eol = detectEol(content);
|
|
92
|
+
// Serialize only the header so body bytes stay untouched; drop gray-matter's empty-body newline.
|
|
93
|
+
const header = matter.stringify('', frontmatterData, withYamlEngine()).slice(0, -1);
|
|
94
|
+
return applyEol(header, eol) + content + (content.endsWith('\n') ? '' : eol);
|
|
71
95
|
}
|
|
72
96
|
catch (error) {
|
|
73
97
|
throw new Error(`Failed to stringify frontmatter: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
@@ -137,9 +161,11 @@ export class FrontmatterHandler {
|
|
|
137
161
|
if (!updates || Object.keys(updates).length === 0) {
|
|
138
162
|
return content;
|
|
139
163
|
}
|
|
140
|
-
return
|
|
164
|
+
return this.stringify(updates, content);
|
|
141
165
|
}
|
|
142
|
-
|
|
166
|
+
// Normalize only YAML; preserve the body's original bytes.
|
|
167
|
+
const eol = rawMatter.includes('\r\n') ? '\r\n' : detectEol(content);
|
|
168
|
+
const doc = parseDocument(toLf(rawMatter).trimStart());
|
|
143
169
|
for (const [key, value] of Object.entries(updates)) {
|
|
144
170
|
if (value === undefined) {
|
|
145
171
|
doc.delete(key);
|
|
@@ -148,8 +174,8 @@ export class FrontmatterHandler {
|
|
|
148
174
|
doc.set(key, value);
|
|
149
175
|
}
|
|
150
176
|
}
|
|
151
|
-
const yamlContent = doc.toString();
|
|
152
|
-
return
|
|
177
|
+
const yamlContent = applyEol(doc.toString(), eol);
|
|
178
|
+
return `---${eol}${yamlContent}---${eol}${content}`;
|
|
153
179
|
}
|
|
154
180
|
catch (error) {
|
|
155
181
|
throw new Error(`Failed to stringify frontmatter: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
package/package.json
CHANGED