@mauricio.wolff/mcp-obsidian 0.4.1 → 0.5.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 +29 -28
- package/dist/server.js +489 -0
- package/dist/src/filesystem.js +525 -0
- package/dist/src/frontmatter.js +104 -0
- package/dist/src/pathfilter.js +57 -0
- package/dist/src/search.js +105 -0
- package/dist/src/types.js +1 -0
- package/package.json +18 -13
- package/server.ts +3 -3
- package/src/filesystem.ts +0 -602
- package/src/frontmatter.ts +0 -124
- package/src/pathfilter.ts +0 -72
- package/src/search.ts +0 -97
- package/src/types.ts +0 -119
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import { join, resolve, relative, dirname } from 'path';
|
|
2
|
+
import { readdir, stat, readFile, writeFile, unlink, mkdir, access } from 'node:fs/promises';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { FrontmatterHandler } from './frontmatter.js';
|
|
5
|
+
import { PathFilter } from './pathfilter.js';
|
|
6
|
+
export class FileSystemService {
|
|
7
|
+
vaultPath;
|
|
8
|
+
frontmatterHandler;
|
|
9
|
+
pathFilter;
|
|
10
|
+
constructor(vaultPath, pathFilter, frontmatterHandler) {
|
|
11
|
+
this.vaultPath = vaultPath;
|
|
12
|
+
this.vaultPath = resolve(vaultPath);
|
|
13
|
+
this.pathFilter = pathFilter || new PathFilter();
|
|
14
|
+
this.frontmatterHandler = frontmatterHandler || new FrontmatterHandler();
|
|
15
|
+
}
|
|
16
|
+
resolvePath(relativePath) {
|
|
17
|
+
// Handle undefined or null path
|
|
18
|
+
if (!relativePath) {
|
|
19
|
+
relativePath = '';
|
|
20
|
+
}
|
|
21
|
+
// Trim whitespace from path
|
|
22
|
+
relativePath = relativePath.trim();
|
|
23
|
+
// Normalize and resolve the path within the vault
|
|
24
|
+
const normalizedPath = relativePath.startsWith('/')
|
|
25
|
+
? relativePath.slice(1)
|
|
26
|
+
: relativePath;
|
|
27
|
+
const fullPath = resolve(join(this.vaultPath, normalizedPath));
|
|
28
|
+
// Security check: ensure path is within vault
|
|
29
|
+
const relativeToVault = relative(this.vaultPath, fullPath);
|
|
30
|
+
if (relativeToVault.startsWith('..')) {
|
|
31
|
+
throw new Error(`Path traversal not allowed: ${relativePath}`);
|
|
32
|
+
}
|
|
33
|
+
return fullPath;
|
|
34
|
+
}
|
|
35
|
+
async readNote(path) {
|
|
36
|
+
const fullPath = this.resolvePath(path);
|
|
37
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
38
|
+
throw new Error(`Access denied: ${path}`);
|
|
39
|
+
}
|
|
40
|
+
// Check if the path is a directory first
|
|
41
|
+
const isDir = await this.isDirectory(path);
|
|
42
|
+
if (isDir) {
|
|
43
|
+
throw new Error(`Cannot read directory as file: ${path}. Use list_directory tool instead.`);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
try {
|
|
47
|
+
await access(fullPath, constants.F_OK);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
throw new Error(`File not found: ${path}`);
|
|
51
|
+
}
|
|
52
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
53
|
+
return this.frontmatterHandler.parse(content);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error instanceof Error) {
|
|
57
|
+
if (error.message.includes('File not found')) {
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
if (error.message.includes('permission') || error.message.includes('access')) {
|
|
61
|
+
throw new Error(`Permission denied: ${path}`);
|
|
62
|
+
}
|
|
63
|
+
if (error.message.includes('Cannot read directory')) {
|
|
64
|
+
throw error;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
throw new Error(`Failed to read file: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
async writeNote(params) {
|
|
71
|
+
const { path, content, frontmatter, mode = 'overwrite' } = params;
|
|
72
|
+
const fullPath = this.resolvePath(path);
|
|
73
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
74
|
+
throw new Error(`Access denied: ${path}`);
|
|
75
|
+
}
|
|
76
|
+
// Validate frontmatter if provided
|
|
77
|
+
if (frontmatter) {
|
|
78
|
+
const validation = this.frontmatterHandler.validate(frontmatter);
|
|
79
|
+
if (!validation.isValid) {
|
|
80
|
+
throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
try {
|
|
84
|
+
let finalContent;
|
|
85
|
+
if (mode === 'overwrite') {
|
|
86
|
+
// Original behavior - replace entire content
|
|
87
|
+
finalContent = frontmatter
|
|
88
|
+
? this.frontmatterHandler.stringify(frontmatter, content)
|
|
89
|
+
: content;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
// For append/prepend, we need to read existing content
|
|
93
|
+
let existingNote;
|
|
94
|
+
try {
|
|
95
|
+
existingNote = await this.readNote(path);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
// File doesn't exist, treat as overwrite
|
|
99
|
+
finalContent = frontmatter
|
|
100
|
+
? this.frontmatterHandler.stringify(frontmatter, content)
|
|
101
|
+
: content;
|
|
102
|
+
}
|
|
103
|
+
if (existingNote) {
|
|
104
|
+
// Merge frontmatter if provided
|
|
105
|
+
const mergedFrontmatter = frontmatter
|
|
106
|
+
? { ...existingNote.frontmatter, ...frontmatter }
|
|
107
|
+
: existingNote.frontmatter;
|
|
108
|
+
if (mode === 'append') {
|
|
109
|
+
finalContent = this.frontmatterHandler.stringify(mergedFrontmatter, existingNote.content + content);
|
|
110
|
+
}
|
|
111
|
+
else if (mode === 'prepend') {
|
|
112
|
+
finalContent = this.frontmatterHandler.stringify(mergedFrontmatter, content + existingNote.content);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
// Create directories if they don't exist
|
|
117
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
118
|
+
await writeFile(fullPath, finalContent, 'utf-8');
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (error instanceof Error) {
|
|
122
|
+
if (error.message.includes('permission') || error.message.includes('access')) {
|
|
123
|
+
throw new Error(`Permission denied: ${path}`);
|
|
124
|
+
}
|
|
125
|
+
if (error.message.includes('space') || error.message.includes('ENOSPC')) {
|
|
126
|
+
throw new Error(`No space left on device: ${path}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
throw new Error(`Failed to write file: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async listDirectory(path = '') {
|
|
133
|
+
const fullPath = this.resolvePath(path);
|
|
134
|
+
try {
|
|
135
|
+
const entries = await readdir(fullPath, { withFileTypes: true });
|
|
136
|
+
const files = [];
|
|
137
|
+
const directories = [];
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
const entryPath = path ? `${path}/${entry.name}` : entry.name;
|
|
140
|
+
if (!this.pathFilter.isAllowed(entryPath)) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (entry.isDirectory()) {
|
|
144
|
+
directories.push(entry.name);
|
|
145
|
+
}
|
|
146
|
+
else if (entry.isFile()) {
|
|
147
|
+
files.push(entry.name);
|
|
148
|
+
}
|
|
149
|
+
// Skip other types (symlinks, etc.)
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
files: files.sort(),
|
|
153
|
+
directories: directories.sort()
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
if (error instanceof Error) {
|
|
158
|
+
if (error.message.includes('not found') || error.message.includes('ENOENT')) {
|
|
159
|
+
throw new Error(`Directory not found: ${path}`);
|
|
160
|
+
}
|
|
161
|
+
if (error.message.includes('permission') || error.message.includes('access')) {
|
|
162
|
+
throw new Error(`Permission denied: ${path}`);
|
|
163
|
+
}
|
|
164
|
+
if (error.message.includes('not a directory') || error.message.includes('ENOTDIR')) {
|
|
165
|
+
throw new Error(`Not a directory: ${path}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
throw new Error(`Failed to list directory: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async exists(path) {
|
|
172
|
+
const fullPath = this.resolvePath(path);
|
|
173
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
await access(fullPath, constants.F_OK);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async isDirectory(path) {
|
|
185
|
+
const fullPath = this.resolvePath(path);
|
|
186
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const stats = await stat(fullPath);
|
|
191
|
+
return stats.isDirectory();
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async deleteNote(params) {
|
|
198
|
+
const { path, confirmPath } = params;
|
|
199
|
+
// Confirmation check - paths must match exactly
|
|
200
|
+
if (path !== confirmPath) {
|
|
201
|
+
return {
|
|
202
|
+
success: false,
|
|
203
|
+
path: path,
|
|
204
|
+
message: "Deletion cancelled: confirmation path does not match. For safety, both 'path' and 'confirmPath' must be identical."
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const fullPath = this.resolvePath(path);
|
|
208
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
209
|
+
return {
|
|
210
|
+
success: false,
|
|
211
|
+
path: path,
|
|
212
|
+
message: `Access denied: ${path}`
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
// Check if it's a directory first (can't delete directories with this method)
|
|
217
|
+
const isDir = await this.isDirectory(path);
|
|
218
|
+
if (isDir) {
|
|
219
|
+
return {
|
|
220
|
+
success: false,
|
|
221
|
+
path: path,
|
|
222
|
+
message: `Cannot delete: ${path} is not a file`
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
// Check if file exists
|
|
226
|
+
try {
|
|
227
|
+
await access(fullPath, constants.F_OK);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
return {
|
|
231
|
+
success: false,
|
|
232
|
+
path: path,
|
|
233
|
+
message: `File not found: ${path}`
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
// Perform the deletion using Node.js native API
|
|
237
|
+
await unlink(fullPath);
|
|
238
|
+
return {
|
|
239
|
+
success: true,
|
|
240
|
+
path: path,
|
|
241
|
+
message: `Successfully deleted note: ${path}. This action cannot be undone.`
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
catch (error) {
|
|
245
|
+
if (error instanceof Error && 'code' in error) {
|
|
246
|
+
if (error.code === 'ENOENT') {
|
|
247
|
+
return {
|
|
248
|
+
success: false,
|
|
249
|
+
path: path,
|
|
250
|
+
message: `File not found: ${path}`
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (error.code === 'EACCES') {
|
|
254
|
+
return {
|
|
255
|
+
success: false,
|
|
256
|
+
path: path,
|
|
257
|
+
message: `Permission denied: ${path}`
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
success: false,
|
|
263
|
+
path: path,
|
|
264
|
+
message: `Failed to delete file: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
async moveNote(params) {
|
|
269
|
+
const { oldPath, newPath, overwrite = false } = params;
|
|
270
|
+
if (!this.pathFilter.isAllowed(oldPath)) {
|
|
271
|
+
return {
|
|
272
|
+
success: false,
|
|
273
|
+
oldPath,
|
|
274
|
+
newPath,
|
|
275
|
+
message: `Access denied: ${oldPath}`
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
if (!this.pathFilter.isAllowed(newPath)) {
|
|
279
|
+
return {
|
|
280
|
+
success: false,
|
|
281
|
+
oldPath,
|
|
282
|
+
newPath,
|
|
283
|
+
message: `Access denied: ${newPath}`
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const oldFullPath = this.resolvePath(oldPath);
|
|
287
|
+
const newFullPath = this.resolvePath(newPath);
|
|
288
|
+
try {
|
|
289
|
+
// Check if source file exists
|
|
290
|
+
try {
|
|
291
|
+
await access(oldFullPath, constants.F_OK);
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
return {
|
|
295
|
+
success: false,
|
|
296
|
+
oldPath,
|
|
297
|
+
newPath,
|
|
298
|
+
message: `Source file not found: ${oldPath}`
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
// Check if target already exists
|
|
302
|
+
let targetExists = false;
|
|
303
|
+
try {
|
|
304
|
+
await access(newFullPath, constants.F_OK);
|
|
305
|
+
targetExists = true;
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
// Target doesn't exist, which is fine
|
|
309
|
+
}
|
|
310
|
+
if (targetExists && !overwrite) {
|
|
311
|
+
return {
|
|
312
|
+
success: false,
|
|
313
|
+
oldPath,
|
|
314
|
+
newPath,
|
|
315
|
+
message: `Target file already exists: ${newPath}. Use overwrite=true to replace it.`
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
// Read source content
|
|
319
|
+
const content = await readFile(oldFullPath, 'utf-8');
|
|
320
|
+
// Write to new location (create directories if they don't exist)
|
|
321
|
+
await mkdir(dirname(newFullPath), { recursive: true });
|
|
322
|
+
await writeFile(newFullPath, content, 'utf-8');
|
|
323
|
+
// Verify the write was successful
|
|
324
|
+
try {
|
|
325
|
+
await access(newFullPath, constants.F_OK);
|
|
326
|
+
}
|
|
327
|
+
catch {
|
|
328
|
+
return {
|
|
329
|
+
success: false,
|
|
330
|
+
oldPath,
|
|
331
|
+
newPath,
|
|
332
|
+
message: `Failed to create target file: ${newPath}`
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
// Delete the source file
|
|
336
|
+
await unlink(oldFullPath);
|
|
337
|
+
return {
|
|
338
|
+
success: true,
|
|
339
|
+
oldPath,
|
|
340
|
+
newPath,
|
|
341
|
+
message: `Successfully moved note from ${oldPath} to ${newPath}`
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
return {
|
|
346
|
+
success: false,
|
|
347
|
+
oldPath,
|
|
348
|
+
newPath,
|
|
349
|
+
message: `Failed to move note: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
async readMultipleNotes(params) {
|
|
354
|
+
const { paths, includeContent = true, includeFrontmatter = true } = params;
|
|
355
|
+
if (paths.length > 10) {
|
|
356
|
+
throw new Error('Maximum 10 files per batch read request');
|
|
357
|
+
}
|
|
358
|
+
const results = await Promise.allSettled(paths.map(async (path) => {
|
|
359
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
360
|
+
throw new Error(`Access denied: ${path}`);
|
|
361
|
+
}
|
|
362
|
+
const note = await this.readNote(path);
|
|
363
|
+
const result = { path };
|
|
364
|
+
if (includeFrontmatter) {
|
|
365
|
+
result.frontmatter = note.frontmatter;
|
|
366
|
+
}
|
|
367
|
+
if (includeContent) {
|
|
368
|
+
result.content = note.content;
|
|
369
|
+
}
|
|
370
|
+
return result;
|
|
371
|
+
}));
|
|
372
|
+
const successful = [];
|
|
373
|
+
const failed = [];
|
|
374
|
+
results.forEach((result, index) => {
|
|
375
|
+
if (result.status === 'fulfilled') {
|
|
376
|
+
successful.push(result.value);
|
|
377
|
+
}
|
|
378
|
+
else {
|
|
379
|
+
failed.push({
|
|
380
|
+
path: paths[index] || '',
|
|
381
|
+
error: result.reason instanceof Error ? result.reason.message : 'Unknown error'
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
return { successful, failed };
|
|
386
|
+
}
|
|
387
|
+
async updateFrontmatter(params) {
|
|
388
|
+
const { path, frontmatter, merge = true } = params;
|
|
389
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
390
|
+
throw new Error(`Access denied: ${path}`);
|
|
391
|
+
}
|
|
392
|
+
// Read the existing note
|
|
393
|
+
const note = await this.readNote(path);
|
|
394
|
+
// Prepare new frontmatter
|
|
395
|
+
const newFrontmatter = merge
|
|
396
|
+
? { ...note.frontmatter, ...frontmatter }
|
|
397
|
+
: frontmatter;
|
|
398
|
+
// Validate the new frontmatter
|
|
399
|
+
const validation = this.frontmatterHandler.validate(newFrontmatter);
|
|
400
|
+
if (!validation.isValid) {
|
|
401
|
+
throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
|
|
402
|
+
}
|
|
403
|
+
// Update the note with new frontmatter, preserving content
|
|
404
|
+
await this.writeNote({
|
|
405
|
+
path,
|
|
406
|
+
content: note.content,
|
|
407
|
+
frontmatter: newFrontmatter
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
async getNotesInfo(paths) {
|
|
411
|
+
const results = await Promise.allSettled(paths.map(async (path) => {
|
|
412
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
413
|
+
throw new Error(`Access denied: ${path}`);
|
|
414
|
+
}
|
|
415
|
+
const fullPath = this.resolvePath(path);
|
|
416
|
+
try {
|
|
417
|
+
await access(fullPath, constants.F_OK);
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
throw new Error(`File not found: ${path}`);
|
|
421
|
+
}
|
|
422
|
+
const stats = await stat(fullPath);
|
|
423
|
+
const size = stats.size;
|
|
424
|
+
const lastModified = stats.mtime.getTime();
|
|
425
|
+
// Quick check for frontmatter without reading full content
|
|
426
|
+
const file = await readFile(fullPath, 'utf-8');
|
|
427
|
+
const firstChunk = file.slice(0, 100);
|
|
428
|
+
const hasFrontmatter = firstChunk.startsWith('---\n');
|
|
429
|
+
return {
|
|
430
|
+
path,
|
|
431
|
+
size,
|
|
432
|
+
modified: lastModified,
|
|
433
|
+
hasFrontmatter
|
|
434
|
+
};
|
|
435
|
+
}));
|
|
436
|
+
// Return only successful results, filter out failed ones
|
|
437
|
+
return results
|
|
438
|
+
.filter((result) => result.status === 'fulfilled')
|
|
439
|
+
.map(result => result.value);
|
|
440
|
+
}
|
|
441
|
+
async manageTags(params) {
|
|
442
|
+
const { path, operation, tags = [] } = params;
|
|
443
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
444
|
+
return {
|
|
445
|
+
path,
|
|
446
|
+
operation,
|
|
447
|
+
tags: [],
|
|
448
|
+
success: false,
|
|
449
|
+
message: `Access denied: ${path}`
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
try {
|
|
453
|
+
const note = await this.readNote(path);
|
|
454
|
+
let currentTags = [];
|
|
455
|
+
// Extract tags from frontmatter
|
|
456
|
+
if (note.frontmatter.tags) {
|
|
457
|
+
if (Array.isArray(note.frontmatter.tags)) {
|
|
458
|
+
currentTags = note.frontmatter.tags;
|
|
459
|
+
}
|
|
460
|
+
else if (typeof note.frontmatter.tags === 'string') {
|
|
461
|
+
currentTags = [note.frontmatter.tags];
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
// Also extract inline tags from content
|
|
465
|
+
const inlineTagMatches = note.content.match(/#[a-zA-Z0-9_-]+/g) || [];
|
|
466
|
+
const inlineTags = inlineTagMatches.map(tag => tag.slice(1)); // Remove #
|
|
467
|
+
currentTags = [...new Set([...currentTags, ...inlineTags])]; // Deduplicate
|
|
468
|
+
if (operation === 'list') {
|
|
469
|
+
return {
|
|
470
|
+
path,
|
|
471
|
+
operation,
|
|
472
|
+
tags: currentTags,
|
|
473
|
+
success: true
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
let newTags = [...currentTags];
|
|
477
|
+
if (operation === 'add') {
|
|
478
|
+
for (const tag of tags) {
|
|
479
|
+
if (!newTags.includes(tag)) {
|
|
480
|
+
newTags.push(tag);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
else if (operation === 'remove') {
|
|
485
|
+
newTags = newTags.filter(tag => !tags.includes(tag));
|
|
486
|
+
}
|
|
487
|
+
// Update frontmatter with new tags
|
|
488
|
+
const updatedFrontmatter = {
|
|
489
|
+
...note.frontmatter
|
|
490
|
+
};
|
|
491
|
+
if (newTags.length > 0) {
|
|
492
|
+
updatedFrontmatter.tags = newTags;
|
|
493
|
+
}
|
|
494
|
+
else if ('tags' in updatedFrontmatter) {
|
|
495
|
+
delete updatedFrontmatter.tags;
|
|
496
|
+
}
|
|
497
|
+
// Write back the note with updated frontmatter
|
|
498
|
+
await this.writeNote({
|
|
499
|
+
path,
|
|
500
|
+
content: note.content,
|
|
501
|
+
frontmatter: updatedFrontmatter,
|
|
502
|
+
mode: 'overwrite'
|
|
503
|
+
});
|
|
504
|
+
return {
|
|
505
|
+
path,
|
|
506
|
+
operation,
|
|
507
|
+
tags: newTags,
|
|
508
|
+
success: true,
|
|
509
|
+
message: `Successfully ${operation === 'add' ? 'added' : 'removed'} tags`
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
return {
|
|
514
|
+
path,
|
|
515
|
+
operation,
|
|
516
|
+
tags: [],
|
|
517
|
+
success: false,
|
|
518
|
+
message: error instanceof Error ? error.message : 'Unknown error'
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
getVaultPath() {
|
|
523
|
+
return this.vaultPath;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import matter from 'gray-matter';
|
|
2
|
+
import * as yaml from 'js-yaml';
|
|
3
|
+
export class FrontmatterHandler {
|
|
4
|
+
parse(content) {
|
|
5
|
+
try {
|
|
6
|
+
const parsed = matter(content);
|
|
7
|
+
return {
|
|
8
|
+
frontmatter: parsed.data,
|
|
9
|
+
content: parsed.content,
|
|
10
|
+
originalContent: content
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
// If parsing fails, treat as content without frontmatter
|
|
15
|
+
return {
|
|
16
|
+
frontmatter: {},
|
|
17
|
+
content: content,
|
|
18
|
+
originalContent: content
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
stringify(frontmatterData, content) {
|
|
23
|
+
try {
|
|
24
|
+
// If no frontmatter, return content as-is
|
|
25
|
+
if (!frontmatterData || Object.keys(frontmatterData).length === 0) {
|
|
26
|
+
return content;
|
|
27
|
+
}
|
|
28
|
+
return matter.stringify(content, frontmatterData);
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
throw new Error(`Failed to stringify frontmatter: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
validate(frontmatterData) {
|
|
35
|
+
const result = {
|
|
36
|
+
isValid: true,
|
|
37
|
+
errors: [],
|
|
38
|
+
warnings: []
|
|
39
|
+
};
|
|
40
|
+
try {
|
|
41
|
+
// Test if the frontmatter can be serialized to valid YAML using js-yaml
|
|
42
|
+
yaml.dump(frontmatterData);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
result.isValid = false;
|
|
46
|
+
result.errors.push(`Invalid YAML structure: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
47
|
+
}
|
|
48
|
+
// Check for problematic values
|
|
49
|
+
this.checkForProblematicValues(frontmatterData, result, '');
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
checkForProblematicValues(obj, result, path) {
|
|
53
|
+
if (obj === null || obj === undefined) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (typeof obj === 'function') {
|
|
57
|
+
result.errors.push(`Functions are not allowed in frontmatter at path: ${path}`);
|
|
58
|
+
result.isValid = false;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (typeof obj === 'symbol') {
|
|
62
|
+
result.errors.push(`Symbols are not allowed in frontmatter at path: ${path}`);
|
|
63
|
+
result.isValid = false;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (obj instanceof Date) {
|
|
67
|
+
// Dates are fine, but warn if they're invalid
|
|
68
|
+
if (isNaN(obj.getTime())) {
|
|
69
|
+
result.warnings.push(`Invalid date at path: ${path}`);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (Array.isArray(obj)) {
|
|
74
|
+
obj.forEach((item, index) => {
|
|
75
|
+
this.checkForProblematicValues(item, result, `${path}[${index}]`);
|
|
76
|
+
});
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (typeof obj === 'object' && obj !== null) {
|
|
80
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
81
|
+
const currentPath = path ? `${path}.${key}` : key;
|
|
82
|
+
// Check for problematic keys
|
|
83
|
+
if (typeof key !== 'string') {
|
|
84
|
+
result.errors.push(`Non-string keys are not allowed: ${key}`);
|
|
85
|
+
result.isValid = false;
|
|
86
|
+
}
|
|
87
|
+
this.checkForProblematicValues(value, result, currentPath);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
extractFrontmatter(content) {
|
|
92
|
+
const parsed = this.parse(content);
|
|
93
|
+
return parsed.frontmatter;
|
|
94
|
+
}
|
|
95
|
+
updateFrontmatter(content, updates) {
|
|
96
|
+
const parsed = this.parse(content);
|
|
97
|
+
const updatedFrontmatter = { ...parsed.frontmatter, ...updates };
|
|
98
|
+
const validation = this.validate(updatedFrontmatter);
|
|
99
|
+
if (!validation.isValid) {
|
|
100
|
+
throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
|
|
101
|
+
}
|
|
102
|
+
return this.stringify(updatedFrontmatter, parsed.content);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export class PathFilter {
|
|
2
|
+
ignoredPatterns;
|
|
3
|
+
allowedExtensions;
|
|
4
|
+
constructor(config) {
|
|
5
|
+
this.ignoredPatterns = [
|
|
6
|
+
'.obsidian/**',
|
|
7
|
+
'.git/**',
|
|
8
|
+
'node_modules/**',
|
|
9
|
+
'.DS_Store',
|
|
10
|
+
'Thumbs.db',
|
|
11
|
+
...config?.ignoredPatterns || []
|
|
12
|
+
];
|
|
13
|
+
this.allowedExtensions = [
|
|
14
|
+
'.md',
|
|
15
|
+
'.markdown',
|
|
16
|
+
'.txt',
|
|
17
|
+
...config?.allowedExtensions || []
|
|
18
|
+
];
|
|
19
|
+
}
|
|
20
|
+
simpleGlobMatch(pattern, path) {
|
|
21
|
+
// Convert glob pattern to regex
|
|
22
|
+
// Handle ** (any number of directories)
|
|
23
|
+
let regexPattern = pattern
|
|
24
|
+
.replace(/\*\*/g, '.*') // ** matches any number of directories
|
|
25
|
+
.replace(/\*/g, '[^/]*') // * matches anything except /
|
|
26
|
+
.replace(/\?/g, '[^/]') // ? matches single character except /
|
|
27
|
+
.replace(/\./g, '\\.'); // Escape dots
|
|
28
|
+
// Ensure we match the full path
|
|
29
|
+
regexPattern = '^' + regexPattern + '$';
|
|
30
|
+
const regex = new RegExp(regexPattern);
|
|
31
|
+
return regex.test(path);
|
|
32
|
+
}
|
|
33
|
+
isAllowed(path) {
|
|
34
|
+
// Normalize path separators
|
|
35
|
+
const normalizedPath = path.replace(/\\/g, '/');
|
|
36
|
+
// Check if path matches any ignored pattern
|
|
37
|
+
for (const pattern of this.ignoredPatterns) {
|
|
38
|
+
if (this.simpleGlobMatch(pattern, normalizedPath)) {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// For files, check extension if allowedExtensions is configured
|
|
43
|
+
if (this.allowedExtensions.length > 0 && this.isFile(normalizedPath)) {
|
|
44
|
+
const hasAllowedExtension = this.allowedExtensions.some(ext => normalizedPath.toLowerCase().endsWith(ext.toLowerCase()));
|
|
45
|
+
if (!hasAllowedExtension) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
isFile(path) {
|
|
52
|
+
return path.includes('.') && !path.endsWith('/');
|
|
53
|
+
}
|
|
54
|
+
filterPaths(paths) {
|
|
55
|
+
return paths.filter(path => this.isAllowed(path));
|
|
56
|
+
}
|
|
57
|
+
}
|