@bitbonsai/mcpvault 0.8.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 +994 -0
- package/dist/server.js +690 -0
- package/dist/src/filesystem.js +763 -0
- package/dist/src/filesystem.test.js +946 -0
- package/dist/src/frontmatter.js +129 -0
- package/dist/src/frontmatter.test.js +118 -0
- package/dist/src/integration.test.js +168 -0
- package/dist/src/pathfilter.js +94 -0
- package/dist/src/pathfilter.test.js +264 -0
- package/dist/src/search.js +182 -0
- package/dist/src/search.test.js +212 -0
- package/dist/src/types.js +1 -0
- package/dist/src/uri.js +22 -0
- package/dist/src/uri.test.js +46 -0
- package/package.json +69 -0
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
import { join, resolve, relative, dirname } from 'path';
|
|
2
|
+
import { readdir, stat, readFile, writeFile, unlink, mkdir, access, rename, copyFile } from 'node:fs/promises';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { FrontmatterHandler } from './frontmatter.js';
|
|
5
|
+
import { PathFilter } from './pathfilter.js';
|
|
6
|
+
import { generateObsidianUri } from './uri.js';
|
|
7
|
+
export class FileSystemService {
|
|
8
|
+
vaultPath;
|
|
9
|
+
frontmatterHandler;
|
|
10
|
+
pathFilter;
|
|
11
|
+
constructor(vaultPath, pathFilter, frontmatterHandler) {
|
|
12
|
+
this.vaultPath = vaultPath;
|
|
13
|
+
this.vaultPath = resolve(vaultPath);
|
|
14
|
+
this.pathFilter = pathFilter || new PathFilter();
|
|
15
|
+
this.frontmatterHandler = frontmatterHandler || new FrontmatterHandler();
|
|
16
|
+
}
|
|
17
|
+
resolvePath(relativePath) {
|
|
18
|
+
// Handle undefined or null path
|
|
19
|
+
if (!relativePath) {
|
|
20
|
+
relativePath = '';
|
|
21
|
+
}
|
|
22
|
+
// Trim whitespace from path
|
|
23
|
+
relativePath = relativePath.trim();
|
|
24
|
+
// Normalize and resolve the path within the vault
|
|
25
|
+
const normalizedPath = relativePath.startsWith('/')
|
|
26
|
+
? relativePath.slice(1)
|
|
27
|
+
: relativePath;
|
|
28
|
+
const fullPath = resolve(join(this.vaultPath, normalizedPath));
|
|
29
|
+
// Security check: ensure path is within vault
|
|
30
|
+
const relativeToVault = relative(this.vaultPath, fullPath);
|
|
31
|
+
if (relativeToVault.startsWith('..')) {
|
|
32
|
+
throw new Error(`Path traversal not allowed: ${relativePath}. Paths must be within the vault directory.`);
|
|
33
|
+
}
|
|
34
|
+
return fullPath;
|
|
35
|
+
}
|
|
36
|
+
async readNote(path) {
|
|
37
|
+
const fullPath = this.resolvePath(path);
|
|
38
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
39
|
+
throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
|
|
40
|
+
}
|
|
41
|
+
// Check if the path is a directory first
|
|
42
|
+
const isDir = await this.isDirectory(path);
|
|
43
|
+
if (isDir) {
|
|
44
|
+
throw new Error(`Cannot read directory as file: ${path}. Use list_directory tool instead.`);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const content = await readFile(fullPath, 'utf-8');
|
|
48
|
+
return this.frontmatterHandler.parse(content);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (error instanceof Error && 'code' in error) {
|
|
52
|
+
if (error.code === 'ENOENT') {
|
|
53
|
+
throw new Error(`File not found: ${path}. Use list_directory to see available files, or check the path spelling.`);
|
|
54
|
+
}
|
|
55
|
+
if (error.code === 'EACCES') {
|
|
56
|
+
throw new Error(`Permission denied: ${path}. The file exists but cannot be read due to filesystem permissions.`);
|
|
57
|
+
}
|
|
58
|
+
if (error.code === 'EISDIR') {
|
|
59
|
+
throw new Error(`Cannot read directory as file: ${path}. Use list_directory tool instead.`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
throw new Error(`Failed to read file: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async writeNote(params) {
|
|
66
|
+
const { path, content, frontmatter, mode = 'overwrite' } = params;
|
|
67
|
+
const fullPath = this.resolvePath(path);
|
|
68
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
69
|
+
throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
|
|
70
|
+
}
|
|
71
|
+
// Validate content is a defined string to prevent writing literal "undefined"
|
|
72
|
+
if (content === undefined || content === null) {
|
|
73
|
+
throw new Error(`Content is required for writing a note: ${path}. The content parameter must be a string.`);
|
|
74
|
+
}
|
|
75
|
+
// Validate frontmatter if provided
|
|
76
|
+
if (frontmatter) {
|
|
77
|
+
const validation = this.frontmatterHandler.validate(frontmatter);
|
|
78
|
+
if (!validation.isValid) {
|
|
79
|
+
throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
let finalContent;
|
|
84
|
+
if (mode === 'overwrite') {
|
|
85
|
+
// Original behavior - replace entire content
|
|
86
|
+
finalContent = frontmatter
|
|
87
|
+
? this.frontmatterHandler.stringify(frontmatter, content)
|
|
88
|
+
: content;
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
// For append/prepend, we need to read existing content
|
|
92
|
+
let existingNote;
|
|
93
|
+
try {
|
|
94
|
+
existingNote = await this.readNote(path);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
// File doesn't exist, treat as overwrite
|
|
98
|
+
finalContent = frontmatter
|
|
99
|
+
? this.frontmatterHandler.stringify(frontmatter, content)
|
|
100
|
+
: content;
|
|
101
|
+
}
|
|
102
|
+
if (existingNote) {
|
|
103
|
+
// Merge frontmatter if provided
|
|
104
|
+
const mergedFrontmatter = frontmatter
|
|
105
|
+
? { ...existingNote.frontmatter, ...frontmatter }
|
|
106
|
+
: existingNote.frontmatter;
|
|
107
|
+
if (mode === 'append') {
|
|
108
|
+
finalContent = this.frontmatterHandler.stringify(mergedFrontmatter, existingNote.content + content);
|
|
109
|
+
}
|
|
110
|
+
else if (mode === 'prepend') {
|
|
111
|
+
finalContent = this.frontmatterHandler.stringify(mergedFrontmatter, content + existingNote.content);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
// Create directories if they don't exist
|
|
116
|
+
await mkdir(dirname(fullPath), { recursive: true });
|
|
117
|
+
await writeFile(fullPath, finalContent, 'utf-8');
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (error instanceof Error) {
|
|
121
|
+
if (error.message.includes('permission') || error.message.includes('access')) {
|
|
122
|
+
throw new Error(`Permission denied: ${path}`);
|
|
123
|
+
}
|
|
124
|
+
if (error.message.includes('space') || error.message.includes('ENOSPC')) {
|
|
125
|
+
throw new Error(`No space left on device: ${path}`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`Failed to write file: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async patchNote(params) {
|
|
132
|
+
const { path, oldString, newString, replaceAll = false } = params;
|
|
133
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
134
|
+
return {
|
|
135
|
+
success: false,
|
|
136
|
+
path,
|
|
137
|
+
message: `Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
// Validate that strings are not empty
|
|
141
|
+
if (!oldString || oldString.trim() === '') {
|
|
142
|
+
return {
|
|
143
|
+
success: false,
|
|
144
|
+
path,
|
|
145
|
+
message: 'oldString cannot be empty'
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
if (!newString) {
|
|
149
|
+
return {
|
|
150
|
+
success: false,
|
|
151
|
+
path,
|
|
152
|
+
message: 'newString cannot be empty'
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
// Validate that oldString and newString are different
|
|
156
|
+
if (oldString === newString) {
|
|
157
|
+
return {
|
|
158
|
+
success: false,
|
|
159
|
+
path,
|
|
160
|
+
message: 'oldString and newString must be different'
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
// Read the existing note
|
|
165
|
+
const note = await this.readNote(path);
|
|
166
|
+
// Get the full content with frontmatter
|
|
167
|
+
const fullContent = note.originalContent;
|
|
168
|
+
// Count occurrences of oldString
|
|
169
|
+
const occurrences = fullContent.split(oldString).length - 1;
|
|
170
|
+
if (occurrences === 0) {
|
|
171
|
+
return {
|
|
172
|
+
success: false,
|
|
173
|
+
path,
|
|
174
|
+
message: `String not found in note: "${oldString.substring(0, 50)}${oldString.length > 50 ? '...' : ''}"`,
|
|
175
|
+
matchCount: 0
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
// If not replaceAll and multiple occurrences exist, fail
|
|
179
|
+
if (!replaceAll && occurrences > 1) {
|
|
180
|
+
return {
|
|
181
|
+
success: false,
|
|
182
|
+
path,
|
|
183
|
+
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.`,
|
|
184
|
+
matchCount: occurrences
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
// Perform the replacement
|
|
188
|
+
const updatedContent = replaceAll
|
|
189
|
+
? fullContent.split(oldString).join(newString)
|
|
190
|
+
: fullContent.replace(oldString, newString);
|
|
191
|
+
// Write the updated content
|
|
192
|
+
const fullPath = this.resolvePath(path);
|
|
193
|
+
await writeFile(fullPath, updatedContent, 'utf-8');
|
|
194
|
+
return {
|
|
195
|
+
success: true,
|
|
196
|
+
path,
|
|
197
|
+
message: `Successfully replaced ${replaceAll ? occurrences : 1} occurrence${occurrences > 1 ? 's' : ''}`,
|
|
198
|
+
matchCount: occurrences
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
return {
|
|
203
|
+
success: false,
|
|
204
|
+
path,
|
|
205
|
+
message: `Failed to patch note: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
async listDirectory(path = '') {
|
|
210
|
+
// Normalize path: treat '.' as root directory
|
|
211
|
+
const normalizedPath = path === '.' ? '' : path;
|
|
212
|
+
const fullPath = this.resolvePath(normalizedPath);
|
|
213
|
+
try {
|
|
214
|
+
const entries = await readdir(fullPath, { withFileTypes: true });
|
|
215
|
+
const files = [];
|
|
216
|
+
const directories = [];
|
|
217
|
+
for (const entry of entries) {
|
|
218
|
+
const entryPath = normalizedPath ? `${normalizedPath}/${entry.name}` : entry.name;
|
|
219
|
+
if (!this.pathFilter.isAllowedForListing(entryPath)) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
if (entry.isDirectory()) {
|
|
223
|
+
directories.push(entry.name);
|
|
224
|
+
}
|
|
225
|
+
else if (entry.isFile()) {
|
|
226
|
+
files.push(entry.name);
|
|
227
|
+
}
|
|
228
|
+
// Skip other types (symlinks, etc.)
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
files: files.sort(),
|
|
232
|
+
directories: directories.sort()
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
if (error instanceof Error) {
|
|
237
|
+
if (error.message.includes('not found') || error.message.includes('ENOENT')) {
|
|
238
|
+
throw new Error(`Directory not found: ${path}. Use list_directory with no path or '/' to see root folders.`);
|
|
239
|
+
}
|
|
240
|
+
if (error.message.includes('permission') || error.message.includes('access')) {
|
|
241
|
+
throw new Error(`Permission denied: ${path}. The directory exists but cannot be read due to filesystem permissions.`);
|
|
242
|
+
}
|
|
243
|
+
if (error.message.includes('not a directory') || error.message.includes('ENOTDIR')) {
|
|
244
|
+
throw new Error(`Not a directory: ${path}. This path points to a file, not a folder. Use read_note to read files.`);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
throw new Error(`Failed to list directory: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
async exists(path) {
|
|
251
|
+
const fullPath = this.resolvePath(path);
|
|
252
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
try {
|
|
256
|
+
await access(fullPath, constants.F_OK);
|
|
257
|
+
return true;
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return false;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async isDirectory(path) {
|
|
264
|
+
const fullPath = this.resolvePath(path);
|
|
265
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
try {
|
|
269
|
+
const stats = await stat(fullPath);
|
|
270
|
+
return stats.isDirectory();
|
|
271
|
+
}
|
|
272
|
+
catch {
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async deleteNote(params) {
|
|
277
|
+
const { path, confirmPath } = params;
|
|
278
|
+
// Confirmation check - paths must match exactly
|
|
279
|
+
if (path !== confirmPath) {
|
|
280
|
+
return {
|
|
281
|
+
success: false,
|
|
282
|
+
path: path,
|
|
283
|
+
message: "Deletion cancelled: confirmation path does not match. For safety, both 'path' and 'confirmPath' must be identical."
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const fullPath = this.resolvePath(path);
|
|
287
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
288
|
+
return {
|
|
289
|
+
success: false,
|
|
290
|
+
path: path,
|
|
291
|
+
message: `Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
try {
|
|
295
|
+
// Check if it's a directory first (can't delete directories with this method)
|
|
296
|
+
const isDir = await this.isDirectory(path);
|
|
297
|
+
if (isDir) {
|
|
298
|
+
return {
|
|
299
|
+
success: false,
|
|
300
|
+
path: path,
|
|
301
|
+
message: `Cannot delete: ${path} is not a file`
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
// Perform the deletion using Node.js native API
|
|
305
|
+
await unlink(fullPath);
|
|
306
|
+
return {
|
|
307
|
+
success: true,
|
|
308
|
+
path: path,
|
|
309
|
+
message: `Successfully deleted note: ${path}. This action cannot be undone.`
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
if (error instanceof Error && 'code' in error) {
|
|
314
|
+
if (error.code === 'ENOENT') {
|
|
315
|
+
return {
|
|
316
|
+
success: false,
|
|
317
|
+
path: path,
|
|
318
|
+
message: `File not found: ${path}. Use list_directory to see available files.`
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
if (error.code === 'EACCES') {
|
|
322
|
+
return {
|
|
323
|
+
success: false,
|
|
324
|
+
path: path,
|
|
325
|
+
message: `Permission denied: ${path}. The file exists but cannot be deleted due to filesystem permissions.`
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return {
|
|
330
|
+
success: false,
|
|
331
|
+
path: path,
|
|
332
|
+
message: `Failed to delete file: ${path} - ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async moveNote(params) {
|
|
337
|
+
const { oldPath, newPath, overwrite = false } = params;
|
|
338
|
+
if (!this.pathFilter.isAllowed(oldPath)) {
|
|
339
|
+
return {
|
|
340
|
+
success: false,
|
|
341
|
+
oldPath,
|
|
342
|
+
newPath,
|
|
343
|
+
message: `Access denied: ${oldPath}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
if (!this.pathFilter.isAllowed(newPath)) {
|
|
347
|
+
return {
|
|
348
|
+
success: false,
|
|
349
|
+
oldPath,
|
|
350
|
+
newPath,
|
|
351
|
+
message: `Access denied: ${newPath}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
const oldFullPath = this.resolvePath(oldPath);
|
|
355
|
+
const newFullPath = this.resolvePath(newPath);
|
|
356
|
+
try {
|
|
357
|
+
// Read source content (will throw ENOENT if not found)
|
|
358
|
+
let content;
|
|
359
|
+
try {
|
|
360
|
+
content = await readFile(oldFullPath, 'utf-8');
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
364
|
+
return {
|
|
365
|
+
success: false,
|
|
366
|
+
oldPath,
|
|
367
|
+
newPath,
|
|
368
|
+
message: `Source file not found: ${oldPath}. Use list_directory to see available files.`
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
// Create directories if needed
|
|
374
|
+
await mkdir(dirname(newFullPath), { recursive: true });
|
|
375
|
+
// Write to new location, checking for existing file atomically if !overwrite
|
|
376
|
+
try {
|
|
377
|
+
if (overwrite) {
|
|
378
|
+
await writeFile(newFullPath, content, 'utf-8');
|
|
379
|
+
}
|
|
380
|
+
else {
|
|
381
|
+
// wx flag: write exclusive - fails if file exists
|
|
382
|
+
await writeFile(newFullPath, content, { encoding: 'utf-8', flag: 'wx' });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
if (error instanceof Error && 'code' in error && error.code === 'EEXIST') {
|
|
387
|
+
return {
|
|
388
|
+
success: false,
|
|
389
|
+
oldPath,
|
|
390
|
+
newPath,
|
|
391
|
+
message: `Target file already exists: ${newPath}. Use overwrite=true to replace it.`
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
throw error;
|
|
395
|
+
}
|
|
396
|
+
// Delete the source file
|
|
397
|
+
await unlink(oldFullPath);
|
|
398
|
+
return {
|
|
399
|
+
success: true,
|
|
400
|
+
oldPath,
|
|
401
|
+
newPath,
|
|
402
|
+
message: `Successfully moved note from ${oldPath} to ${newPath}`
|
|
403
|
+
};
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
return {
|
|
407
|
+
success: false,
|
|
408
|
+
oldPath,
|
|
409
|
+
newPath,
|
|
410
|
+
message: `Failed to move note: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
async moveFile(params) {
|
|
415
|
+
const { oldPath, newPath, confirmOldPath, confirmNewPath, overwrite = false } = params;
|
|
416
|
+
if (oldPath !== confirmOldPath || newPath !== confirmNewPath) {
|
|
417
|
+
return {
|
|
418
|
+
success: false,
|
|
419
|
+
oldPath,
|
|
420
|
+
newPath,
|
|
421
|
+
message: "Move cancelled: confirmation paths do not match. For safety, oldPath must equal confirmOldPath and newPath must equal confirmNewPath."
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
if (!this.pathFilter.isAllowedForListing(oldPath)) {
|
|
425
|
+
return {
|
|
426
|
+
success: false,
|
|
427
|
+
oldPath,
|
|
428
|
+
newPath,
|
|
429
|
+
message: `Access denied: ${oldPath}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
if (!this.pathFilter.isAllowedForListing(newPath)) {
|
|
433
|
+
return {
|
|
434
|
+
success: false,
|
|
435
|
+
oldPath,
|
|
436
|
+
newPath,
|
|
437
|
+
message: `Access denied: ${newPath}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
const oldFullPath = this.resolvePath(oldPath);
|
|
441
|
+
const newFullPath = this.resolvePath(newPath);
|
|
442
|
+
try {
|
|
443
|
+
const sourceStat = await stat(oldFullPath);
|
|
444
|
+
if (sourceStat.isDirectory()) {
|
|
445
|
+
return {
|
|
446
|
+
success: false,
|
|
447
|
+
oldPath,
|
|
448
|
+
newPath,
|
|
449
|
+
message: `Source path is a directory: ${oldPath}. move_file currently supports files only.`
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
455
|
+
return {
|
|
456
|
+
success: false,
|
|
457
|
+
oldPath,
|
|
458
|
+
newPath,
|
|
459
|
+
message: `Source file not found: ${oldPath}. Use list_directory to see available files.`
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
return {
|
|
463
|
+
success: false,
|
|
464
|
+
oldPath,
|
|
465
|
+
newPath,
|
|
466
|
+
message: `Failed to inspect source file: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
try {
|
|
470
|
+
if (!overwrite) {
|
|
471
|
+
try {
|
|
472
|
+
await access(newFullPath, constants.F_OK);
|
|
473
|
+
return {
|
|
474
|
+
success: false,
|
|
475
|
+
oldPath,
|
|
476
|
+
newPath,
|
|
477
|
+
message: `Target file already exists: ${newPath}. Use overwrite=true to replace it.`
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
|
|
482
|
+
throw error;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
await mkdir(dirname(newFullPath), { recursive: true });
|
|
487
|
+
if (overwrite) {
|
|
488
|
+
try {
|
|
489
|
+
const targetStat = await stat(newFullPath);
|
|
490
|
+
if (targetStat.isDirectory()) {
|
|
491
|
+
return {
|
|
492
|
+
success: false,
|
|
493
|
+
oldPath,
|
|
494
|
+
newPath,
|
|
495
|
+
message: `Target path is a directory: ${newPath}. Please provide a file path.`
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
await unlink(newFullPath);
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
if (!(error instanceof Error) || !('code' in error) || error.code !== 'ENOENT') {
|
|
502
|
+
throw error;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
await rename(oldFullPath, newFullPath);
|
|
508
|
+
}
|
|
509
|
+
catch (error) {
|
|
510
|
+
if (error instanceof Error && 'code' in error && error.code === 'EXDEV') {
|
|
511
|
+
await copyFile(oldFullPath, newFullPath);
|
|
512
|
+
await unlink(oldFullPath);
|
|
513
|
+
}
|
|
514
|
+
else {
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
return {
|
|
519
|
+
success: true,
|
|
520
|
+
oldPath,
|
|
521
|
+
newPath,
|
|
522
|
+
message: `Successfully moved file from ${oldPath} to ${newPath}`
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
catch (error) {
|
|
526
|
+
return {
|
|
527
|
+
success: false,
|
|
528
|
+
oldPath,
|
|
529
|
+
newPath,
|
|
530
|
+
message: `Failed to move file: ${error instanceof Error ? error.message : 'Unknown error'}`
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
async readMultipleNotes(params) {
|
|
535
|
+
const { paths, includeContent = true, includeFrontmatter = true } = params;
|
|
536
|
+
if (paths.length > 10) {
|
|
537
|
+
throw new Error('Maximum 10 files per batch read request');
|
|
538
|
+
}
|
|
539
|
+
const results = await Promise.allSettled(paths.map(async (path) => {
|
|
540
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
541
|
+
throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
|
|
542
|
+
}
|
|
543
|
+
const note = await this.readNote(path);
|
|
544
|
+
const result = {
|
|
545
|
+
path,
|
|
546
|
+
obsidianUri: generateObsidianUri(this.vaultPath, path)
|
|
547
|
+
};
|
|
548
|
+
if (includeFrontmatter) {
|
|
549
|
+
result.frontmatter = note.frontmatter;
|
|
550
|
+
}
|
|
551
|
+
if (includeContent) {
|
|
552
|
+
result.content = note.content;
|
|
553
|
+
}
|
|
554
|
+
return result;
|
|
555
|
+
}));
|
|
556
|
+
const successful = [];
|
|
557
|
+
const failed = [];
|
|
558
|
+
results.forEach((result, index) => {
|
|
559
|
+
if (result.status === 'fulfilled') {
|
|
560
|
+
successful.push(result.value);
|
|
561
|
+
}
|
|
562
|
+
else {
|
|
563
|
+
failed.push({
|
|
564
|
+
path: paths[index] || '',
|
|
565
|
+
error: result.reason instanceof Error ? result.reason.message : 'Unknown error'
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
return { successful, failed };
|
|
570
|
+
}
|
|
571
|
+
async updateFrontmatter(params) {
|
|
572
|
+
const { path, frontmatter, merge = true } = params;
|
|
573
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
574
|
+
throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
|
|
575
|
+
}
|
|
576
|
+
// Read the existing note
|
|
577
|
+
const note = await this.readNote(path);
|
|
578
|
+
// Prepare new frontmatter
|
|
579
|
+
const newFrontmatter = merge
|
|
580
|
+
? { ...note.frontmatter, ...frontmatter }
|
|
581
|
+
: frontmatter;
|
|
582
|
+
// Validate the new frontmatter
|
|
583
|
+
const validation = this.frontmatterHandler.validate(newFrontmatter);
|
|
584
|
+
if (!validation.isValid) {
|
|
585
|
+
throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
|
|
586
|
+
}
|
|
587
|
+
// Update the note with new frontmatter, preserving content
|
|
588
|
+
await this.writeNote({
|
|
589
|
+
path,
|
|
590
|
+
content: note.content,
|
|
591
|
+
frontmatter: newFrontmatter
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
async getNotesInfo(paths) {
|
|
595
|
+
const results = await Promise.allSettled(paths.map(async (path) => {
|
|
596
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
597
|
+
throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
|
|
598
|
+
}
|
|
599
|
+
const fullPath = this.resolvePath(path);
|
|
600
|
+
let stats;
|
|
601
|
+
try {
|
|
602
|
+
stats = await stat(fullPath);
|
|
603
|
+
}
|
|
604
|
+
catch (error) {
|
|
605
|
+
if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
|
|
606
|
+
throw new Error(`File not found: ${path}`);
|
|
607
|
+
}
|
|
608
|
+
throw error;
|
|
609
|
+
}
|
|
610
|
+
const size = stats.size;
|
|
611
|
+
const lastModified = stats.mtime.getTime();
|
|
612
|
+
// Quick check for frontmatter without reading full content
|
|
613
|
+
const file = await readFile(fullPath, 'utf-8');
|
|
614
|
+
const firstChunk = file.slice(0, 100);
|
|
615
|
+
const hasFrontmatter = firstChunk.startsWith('---\n');
|
|
616
|
+
return {
|
|
617
|
+
path,
|
|
618
|
+
size,
|
|
619
|
+
modified: lastModified,
|
|
620
|
+
hasFrontmatter,
|
|
621
|
+
obsidianUri: generateObsidianUri(this.vaultPath, path)
|
|
622
|
+
};
|
|
623
|
+
}));
|
|
624
|
+
// Return only successful results, filter out failed ones
|
|
625
|
+
return results
|
|
626
|
+
.filter((result) => result.status === 'fulfilled')
|
|
627
|
+
.map(result => result.value);
|
|
628
|
+
}
|
|
629
|
+
async manageTags(params) {
|
|
630
|
+
const { path, operation, tags = [] } = params;
|
|
631
|
+
if (!this.pathFilter.isAllowed(path)) {
|
|
632
|
+
return {
|
|
633
|
+
path,
|
|
634
|
+
operation,
|
|
635
|
+
tags: [],
|
|
636
|
+
success: false,
|
|
637
|
+
message: `Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
try {
|
|
641
|
+
const note = await this.readNote(path);
|
|
642
|
+
let currentTags = [];
|
|
643
|
+
// Extract tags from frontmatter
|
|
644
|
+
if (note.frontmatter.tags) {
|
|
645
|
+
if (Array.isArray(note.frontmatter.tags)) {
|
|
646
|
+
currentTags = note.frontmatter.tags;
|
|
647
|
+
}
|
|
648
|
+
else if (typeof note.frontmatter.tags === 'string') {
|
|
649
|
+
currentTags = [note.frontmatter.tags];
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// Also extract inline tags from content
|
|
653
|
+
const inlineTagMatches = note.content.match(/#[a-zA-Z0-9_-]+/g) || [];
|
|
654
|
+
const inlineTags = inlineTagMatches.map(tag => tag.slice(1)); // Remove #
|
|
655
|
+
currentTags = [...new Set([...currentTags, ...inlineTags])]; // Deduplicate
|
|
656
|
+
if (operation === 'list') {
|
|
657
|
+
return {
|
|
658
|
+
path,
|
|
659
|
+
operation,
|
|
660
|
+
tags: currentTags,
|
|
661
|
+
success: true
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
let newTags = [...currentTags];
|
|
665
|
+
if (operation === 'add') {
|
|
666
|
+
for (const tag of tags) {
|
|
667
|
+
if (!newTags.includes(tag)) {
|
|
668
|
+
newTags.push(tag);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
else if (operation === 'remove') {
|
|
673
|
+
newTags = newTags.filter(tag => !tags.includes(tag));
|
|
674
|
+
}
|
|
675
|
+
// Update frontmatter with new tags
|
|
676
|
+
const updatedFrontmatter = {
|
|
677
|
+
...note.frontmatter
|
|
678
|
+
};
|
|
679
|
+
if (newTags.length > 0) {
|
|
680
|
+
updatedFrontmatter.tags = newTags;
|
|
681
|
+
}
|
|
682
|
+
else if ('tags' in updatedFrontmatter) {
|
|
683
|
+
delete updatedFrontmatter.tags;
|
|
684
|
+
}
|
|
685
|
+
// Write back the note with updated frontmatter
|
|
686
|
+
await this.writeNote({
|
|
687
|
+
path,
|
|
688
|
+
content: note.content,
|
|
689
|
+
frontmatter: updatedFrontmatter,
|
|
690
|
+
mode: 'overwrite'
|
|
691
|
+
});
|
|
692
|
+
return {
|
|
693
|
+
path,
|
|
694
|
+
operation,
|
|
695
|
+
tags: newTags,
|
|
696
|
+
success: true,
|
|
697
|
+
message: `Successfully ${operation === 'add' ? 'added' : 'removed'} tags`
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
return {
|
|
702
|
+
path,
|
|
703
|
+
operation,
|
|
704
|
+
tags: [],
|
|
705
|
+
success: false,
|
|
706
|
+
message: error instanceof Error ? error.message : 'Unknown error'
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
getVaultPath() {
|
|
711
|
+
return this.vaultPath;
|
|
712
|
+
}
|
|
713
|
+
async getVaultStats(recentCount = 5) {
|
|
714
|
+
let totalNotes = 0;
|
|
715
|
+
let totalFolders = 0;
|
|
716
|
+
let totalSize = 0;
|
|
717
|
+
const recentFiles = [];
|
|
718
|
+
const scanDirectory = async (dirPath, relativePath = '') => {
|
|
719
|
+
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
720
|
+
for (const entry of entries) {
|
|
721
|
+
const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
722
|
+
const fullEntryPath = join(dirPath, entry.name);
|
|
723
|
+
if (entry.isDirectory()) {
|
|
724
|
+
if (!this.pathFilter.isAllowedForListing(entryRelativePath)) {
|
|
725
|
+
continue;
|
|
726
|
+
}
|
|
727
|
+
totalFolders++;
|
|
728
|
+
await scanDirectory(fullEntryPath, entryRelativePath);
|
|
729
|
+
}
|
|
730
|
+
else if (entry.isFile()) {
|
|
731
|
+
if (!this.pathFilter.isAllowed(entryRelativePath)) {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
totalNotes++;
|
|
735
|
+
const stats = await stat(fullEntryPath);
|
|
736
|
+
totalSize += stats.size;
|
|
737
|
+
// Track recent files
|
|
738
|
+
const fileInfo = { path: entryRelativePath, modified: stats.mtime.getTime() };
|
|
739
|
+
// Insert in sorted order (most recent first)
|
|
740
|
+
const insertIndex = recentFiles.findIndex(f => f.modified < fileInfo.modified);
|
|
741
|
+
if (insertIndex === -1) {
|
|
742
|
+
if (recentFiles.length < recentCount) {
|
|
743
|
+
recentFiles.push(fileInfo);
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
else {
|
|
747
|
+
recentFiles.splice(insertIndex, 0, fileInfo);
|
|
748
|
+
if (recentFiles.length > recentCount) {
|
|
749
|
+
recentFiles.pop();
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
};
|
|
755
|
+
await scanDirectory(this.vaultPath);
|
|
756
|
+
return {
|
|
757
|
+
totalNotes,
|
|
758
|
+
totalFolders,
|
|
759
|
+
totalSize,
|
|
760
|
+
recentlyModified: recentFiles
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
}
|