@mauricio.wolff/mcp-obsidian 0.7.4 → 0.8.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 +272 -20
- package/dist/server.js +63 -5
- package/dist/src/filesystem.js +127 -3
- package/dist/src/filesystem.test.js +189 -1
- package/dist/src/integration.test.js +31 -0
- package/dist/src/pathfilter.js +20 -5
- package/dist/src/pathfilter.test.js +16 -0
- package/dist/src/search.js +117 -42
- package/package.json +2 -1
package/dist/server.js
CHANGED
|
@@ -15,12 +15,13 @@ const __dirname = dirname(__filename);
|
|
|
15
15
|
const packageJson = JSON.parse(readFileSync(join(__dirname, "../package.json"), "utf-8"));
|
|
16
16
|
const VERSION = packageJson.version;
|
|
17
17
|
// Handle --version and --help flags
|
|
18
|
-
const
|
|
19
|
-
|
|
18
|
+
const cliArgs = process.argv.slice(2);
|
|
19
|
+
const firstArg = cliArgs[0];
|
|
20
|
+
if (firstArg === "--version" || firstArg === "-v") {
|
|
20
21
|
console.log(VERSION);
|
|
21
22
|
process.exit(0);
|
|
22
23
|
}
|
|
23
|
-
if (
|
|
24
|
+
if (firstArg === "--help" || firstArg === "-h") {
|
|
24
25
|
console.log(`
|
|
25
26
|
@mauricio.wolff/mcp-obsidian v${VERSION}
|
|
26
27
|
|
|
@@ -39,10 +40,12 @@ Options:
|
|
|
39
40
|
Examples:
|
|
40
41
|
npx @mauricio.wolff/mcp-obsidian ~/Documents/MyVault
|
|
41
42
|
npx @mauricio.wolff/mcp-obsidian /path/to/obsidian/vault
|
|
43
|
+
npx @mauricio.wolff/mcp-obsidian "/path/with spaces/Obsidian Vault"
|
|
42
44
|
`);
|
|
43
45
|
process.exit(0);
|
|
44
46
|
}
|
|
45
|
-
|
|
47
|
+
// Join all trailing args to support vault paths with spaces
|
|
48
|
+
const vaultPath = cliArgs.join(' ').trim();
|
|
46
49
|
if (!vaultPath) {
|
|
47
50
|
console.error("Usage: npx @mauricio.wolff/mcp-obsidian /path/to/vault");
|
|
48
51
|
console.error("Run 'npx @mauricio.wolff/mcp-obsidian --help' for more information");
|
|
@@ -140,7 +143,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
140
143
|
},
|
|
141
144
|
{
|
|
142
145
|
name: "list_directory",
|
|
143
|
-
description: "List files and directories in the vault",
|
|
146
|
+
description: "List files and directories in the vault (includes non-note filenames, while read/write tools remain note-only)",
|
|
144
147
|
inputSchema: {
|
|
145
148
|
type: "object",
|
|
146
149
|
properties: {
|
|
@@ -237,6 +240,37 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
237
240
|
required: ["oldPath", "newPath"]
|
|
238
241
|
}
|
|
239
242
|
},
|
|
243
|
+
{
|
|
244
|
+
name: "move_file",
|
|
245
|
+
description: "Move or rename any file in the vault (binary-safe, file-only, requires confirmation)",
|
|
246
|
+
inputSchema: {
|
|
247
|
+
type: "object",
|
|
248
|
+
properties: {
|
|
249
|
+
oldPath: {
|
|
250
|
+
type: "string",
|
|
251
|
+
description: "Current path of the file"
|
|
252
|
+
},
|
|
253
|
+
newPath: {
|
|
254
|
+
type: "string",
|
|
255
|
+
description: "New path for the file"
|
|
256
|
+
},
|
|
257
|
+
confirmOldPath: {
|
|
258
|
+
type: "string",
|
|
259
|
+
description: "Confirmation: must exactly match oldPath"
|
|
260
|
+
},
|
|
261
|
+
confirmNewPath: {
|
|
262
|
+
type: "string",
|
|
263
|
+
description: "Confirmation: must exactly match newPath"
|
|
264
|
+
},
|
|
265
|
+
overwrite: {
|
|
266
|
+
type: "boolean",
|
|
267
|
+
description: "Allow overwriting existing file (default: false)",
|
|
268
|
+
default: false
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
required: ["oldPath", "newPath", "confirmOldPath", "confirmNewPath"]
|
|
272
|
+
}
|
|
273
|
+
},
|
|
240
274
|
{
|
|
241
275
|
name: "read_multiple_notes",
|
|
242
276
|
description: "Read multiple notes in a batch (max 10 files)",
|
|
@@ -392,6 +426,12 @@ function trimPaths(args) {
|
|
|
392
426
|
if (trimmed.confirmPath && typeof trimmed.confirmPath === 'string') {
|
|
393
427
|
trimmed.confirmPath = trimmed.confirmPath.trim();
|
|
394
428
|
}
|
|
429
|
+
if (trimmed.confirmOldPath && typeof trimmed.confirmOldPath === 'string') {
|
|
430
|
+
trimmed.confirmOldPath = trimmed.confirmOldPath.trim();
|
|
431
|
+
}
|
|
432
|
+
if (trimmed.confirmNewPath && typeof trimmed.confirmNewPath === 'string') {
|
|
433
|
+
trimmed.confirmNewPath = trimmed.confirmNewPath.trim();
|
|
434
|
+
}
|
|
395
435
|
// Trim path arrays
|
|
396
436
|
if (trimmed.paths && Array.isArray(trimmed.paths)) {
|
|
397
437
|
trimmed.paths = trimmed.paths.map((p) => typeof p === 'string' ? p.trim() : p);
|
|
@@ -515,6 +555,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
515
555
|
isError: !result.success
|
|
516
556
|
};
|
|
517
557
|
}
|
|
558
|
+
case "move_file": {
|
|
559
|
+
const result = await fileSystem.moveFile({
|
|
560
|
+
oldPath: trimmedArgs.oldPath,
|
|
561
|
+
newPath: trimmedArgs.newPath,
|
|
562
|
+
confirmOldPath: trimmedArgs.confirmOldPath,
|
|
563
|
+
confirmNewPath: trimmedArgs.confirmNewPath,
|
|
564
|
+
overwrite: trimmedArgs.overwrite
|
|
565
|
+
});
|
|
566
|
+
return {
|
|
567
|
+
content: [
|
|
568
|
+
{
|
|
569
|
+
type: "text",
|
|
570
|
+
text: JSON.stringify(result, null, 2)
|
|
571
|
+
}
|
|
572
|
+
],
|
|
573
|
+
isError: !result.success
|
|
574
|
+
};
|
|
575
|
+
}
|
|
518
576
|
case "read_multiple_notes": {
|
|
519
577
|
const result = await fileSystem.readMultipleNotes({
|
|
520
578
|
paths: trimmedArgs.paths,
|
package/dist/src/filesystem.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { join, resolve, relative, dirname } from 'path';
|
|
2
|
-
import { readdir, stat, readFile, writeFile, unlink, mkdir, access } from 'node:fs/promises';
|
|
2
|
+
import { readdir, stat, readFile, writeFile, unlink, mkdir, access, rename, copyFile } from 'node:fs/promises';
|
|
3
3
|
import { constants } from 'node:fs';
|
|
4
4
|
import { FrontmatterHandler } from './frontmatter.js';
|
|
5
5
|
import { PathFilter } from './pathfilter.js';
|
|
@@ -68,6 +68,10 @@ export class FileSystemService {
|
|
|
68
68
|
if (!this.pathFilter.isAllowed(path)) {
|
|
69
69
|
throw new Error(`Access denied: ${path}. This path is restricted (system files like .obsidian, .git, and dotfiles are not accessible).`);
|
|
70
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
|
+
}
|
|
71
75
|
// Validate frontmatter if provided
|
|
72
76
|
if (frontmatter) {
|
|
73
77
|
const validation = this.frontmatterHandler.validate(frontmatter);
|
|
@@ -141,7 +145,7 @@ export class FileSystemService {
|
|
|
141
145
|
message: 'oldString cannot be empty'
|
|
142
146
|
};
|
|
143
147
|
}
|
|
144
|
-
if (newString
|
|
148
|
+
if (!newString) {
|
|
145
149
|
return {
|
|
146
150
|
success: false,
|
|
147
151
|
path,
|
|
@@ -212,7 +216,7 @@ export class FileSystemService {
|
|
|
212
216
|
const directories = [];
|
|
213
217
|
for (const entry of entries) {
|
|
214
218
|
const entryPath = normalizedPath ? `${normalizedPath}/${entry.name}` : entry.name;
|
|
215
|
-
if (!this.pathFilter.
|
|
219
|
+
if (!this.pathFilter.isAllowedForListing(entryPath)) {
|
|
216
220
|
continue;
|
|
217
221
|
}
|
|
218
222
|
if (entry.isDirectory()) {
|
|
@@ -407,6 +411,126 @@ export class FileSystemService {
|
|
|
407
411
|
};
|
|
408
412
|
}
|
|
409
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
|
+
}
|
|
410
534
|
async readMultipleNotes(params) {
|
|
411
535
|
const { paths, includeContent = true, includeFrontmatter = true } = params;
|
|
412
536
|
if (paths.length > 10) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { test, expect, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { FileSystemService } from "./filesystem.js";
|
|
3
|
-
import { writeFile, mkdir, mkdtemp, rm } from "fs/promises";
|
|
3
|
+
import { writeFile, readFile, mkdir, mkdtemp, rm } from "fs/promises";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { tmpdir } from "os";
|
|
6
6
|
let testVaultPath;
|
|
@@ -184,6 +184,68 @@ test("patch note fails with empty newString", async () => {
|
|
|
184
184
|
expect(result.success).toBe(false);
|
|
185
185
|
expect(result.message).toMatch(/empty|filled|required/i);
|
|
186
186
|
});
|
|
187
|
+
test("patch note fails with undefined newString", async () => {
|
|
188
|
+
const testPath = "test-note.md";
|
|
189
|
+
const content = "# Test Note\n\nSome content.";
|
|
190
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
191
|
+
const result = await fileSystem.patchNote({
|
|
192
|
+
path: testPath,
|
|
193
|
+
oldString: "content",
|
|
194
|
+
newString: undefined,
|
|
195
|
+
replaceAll: false
|
|
196
|
+
});
|
|
197
|
+
expect(result.success).toBe(false);
|
|
198
|
+
expect(result.message).toMatch(/empty|filled|required/i);
|
|
199
|
+
// Verify the note was NOT corrupted
|
|
200
|
+
const note = await fileSystem.readNote(testPath);
|
|
201
|
+
expect(note.content).not.toContain("undefined");
|
|
202
|
+
expect(note.content).toContain("Some content.");
|
|
203
|
+
});
|
|
204
|
+
test("patch note fails with null newString", async () => {
|
|
205
|
+
const testPath = "test-note.md";
|
|
206
|
+
const content = "# Test Note\n\nSome content.";
|
|
207
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
208
|
+
const result = await fileSystem.patchNote({
|
|
209
|
+
path: testPath,
|
|
210
|
+
oldString: "content",
|
|
211
|
+
newString: null,
|
|
212
|
+
replaceAll: false
|
|
213
|
+
});
|
|
214
|
+
expect(result.success).toBe(false);
|
|
215
|
+
expect(result.message).toMatch(/empty|filled|required/i);
|
|
216
|
+
// Verify the note was NOT corrupted
|
|
217
|
+
const note = await fileSystem.readNote(testPath);
|
|
218
|
+
expect(note.content).not.toContain("null");
|
|
219
|
+
expect(note.content).toContain("Some content.");
|
|
220
|
+
});
|
|
221
|
+
test("writeNote rejects undefined content", async () => {
|
|
222
|
+
const testPath = "test-note.md";
|
|
223
|
+
await expect(fileSystem.writeNote({
|
|
224
|
+
path: testPath,
|
|
225
|
+
content: undefined
|
|
226
|
+
})).rejects.toThrow(/Content is required/);
|
|
227
|
+
});
|
|
228
|
+
test("writeNote rejects null content", async () => {
|
|
229
|
+
const testPath = "test-note.md";
|
|
230
|
+
await expect(fileSystem.writeNote({
|
|
231
|
+
path: testPath,
|
|
232
|
+
content: null
|
|
233
|
+
})).rejects.toThrow(/Content is required/);
|
|
234
|
+
});
|
|
235
|
+
test("writeNote append with undefined content does not corrupt note", async () => {
|
|
236
|
+
const testPath = "test-note.md";
|
|
237
|
+
const content = "# Test Note\n\nOriginal content.";
|
|
238
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
239
|
+
await expect(fileSystem.writeNote({
|
|
240
|
+
path: testPath,
|
|
241
|
+
content: undefined,
|
|
242
|
+
mode: 'append'
|
|
243
|
+
})).rejects.toThrow(/Content is required/);
|
|
244
|
+
// Verify the note was NOT corrupted
|
|
245
|
+
const note = await fileSystem.readNote(testPath);
|
|
246
|
+
expect(note.content).not.toContain("undefined");
|
|
247
|
+
expect(note.content).toContain("Original content.");
|
|
248
|
+
});
|
|
187
249
|
test("patch note handles regex special characters literally", async () => {
|
|
188
250
|
const testPath = "test-note.md";
|
|
189
251
|
const content = "Price: $10.50 (special)";
|
|
@@ -199,6 +261,34 @@ test("patch note handles regex special characters literally", async () => {
|
|
|
199
261
|
expect(updatedNote.content).toContain("$15.75");
|
|
200
262
|
expect(updatedNote.content).not.toContain("$10.50");
|
|
201
263
|
});
|
|
264
|
+
test("patch note works with fenced code blocks", async () => {
|
|
265
|
+
const testPath = "code-fence-test.md";
|
|
266
|
+
const content = "# Example\n\n```rust\nfn main() {\n println!(\"hello\");\n}\n```\n";
|
|
267
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
268
|
+
const result = await fileSystem.patchNote({
|
|
269
|
+
path: testPath,
|
|
270
|
+
oldString: "println!(\"hello\");",
|
|
271
|
+
newString: "println!(\"hello world\");",
|
|
272
|
+
replaceAll: false
|
|
273
|
+
});
|
|
274
|
+
expect(result.success).toBe(true);
|
|
275
|
+
const updatedNote = await fileSystem.readNote(testPath);
|
|
276
|
+
expect(updatedNote.originalContent).toContain("println!(\"hello world\");");
|
|
277
|
+
});
|
|
278
|
+
test("patch note works with markdown tables", async () => {
|
|
279
|
+
const testPath = "table-test.md";
|
|
280
|
+
const content = "| Tool | Status |\n|---|---|\n| patch_note | flaky |\n";
|
|
281
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
282
|
+
const result = await fileSystem.patchNote({
|
|
283
|
+
path: testPath,
|
|
284
|
+
oldString: "| patch_note | flaky |",
|
|
285
|
+
newString: "| patch_note | stable |",
|
|
286
|
+
replaceAll: false
|
|
287
|
+
});
|
|
288
|
+
expect(result.success).toBe(true);
|
|
289
|
+
const updatedNote = await fileSystem.readNote(testPath);
|
|
290
|
+
expect(updatedNote.originalContent).toContain("| patch_note | stable |");
|
|
291
|
+
});
|
|
202
292
|
test("patch note preserves tabs and spaces", async () => {
|
|
203
293
|
const testPath = "test-note.md";
|
|
204
294
|
const content = "Line with\ttabs\n Line with spaces\n\tTabbed line";
|
|
@@ -514,6 +604,14 @@ test("frontmatter validation with invalid data", async () => {
|
|
|
514
604
|
}
|
|
515
605
|
})).rejects.toThrow(/Invalid frontmatter/);
|
|
516
606
|
});
|
|
607
|
+
test("listDirectory includes non-note files but readNote still blocks them", async () => {
|
|
608
|
+
const imagePath = "assets/diagram.png";
|
|
609
|
+
await mkdir(join(testVaultPath, "assets"), { recursive: true });
|
|
610
|
+
await writeFile(join(testVaultPath, imagePath), "fake-png-content");
|
|
611
|
+
const listing = await fileSystem.listDirectory("assets");
|
|
612
|
+
expect(listing.files).toContain("diagram.png");
|
|
613
|
+
await expect(fileSystem.readNote(imagePath)).rejects.toThrow(/Access denied/);
|
|
614
|
+
});
|
|
517
615
|
// ============================================================================
|
|
518
616
|
// NON-EXISTENT VAULT TESTS
|
|
519
617
|
// ============================================================================
|
|
@@ -615,6 +713,96 @@ test("move note with special chars in both paths", async () => {
|
|
|
615
713
|
const note = await fileSystem.readNote(newPath);
|
|
616
714
|
expect(note.content).toContain("Moving note");
|
|
617
715
|
});
|
|
716
|
+
test("move_file moves binary files without corruption", async () => {
|
|
717
|
+
const oldPath = "attachments/original image.png";
|
|
718
|
+
const newPath = "assets/original image.png";
|
|
719
|
+
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0x10, 0x42]);
|
|
720
|
+
await mkdir(join(testVaultPath, "attachments"), { recursive: true });
|
|
721
|
+
await writeFile(join(testVaultPath, oldPath), binaryContent);
|
|
722
|
+
const result = await fileSystem.moveFile({
|
|
723
|
+
oldPath,
|
|
724
|
+
newPath,
|
|
725
|
+
confirmOldPath: oldPath,
|
|
726
|
+
confirmNewPath: newPath
|
|
727
|
+
});
|
|
728
|
+
expect(result.success).toBe(true);
|
|
729
|
+
const moved = await readFile(join(testVaultPath, newPath));
|
|
730
|
+
expect(Buffer.compare(moved, binaryContent)).toBe(0);
|
|
731
|
+
await expect(readFile(join(testVaultPath, oldPath))).rejects.toMatchObject({ code: "ENOENT" });
|
|
732
|
+
});
|
|
733
|
+
test("move_file respects overwrite=false", async () => {
|
|
734
|
+
const oldPath = "attachments/image.png";
|
|
735
|
+
const newPath = "assets/image.png";
|
|
736
|
+
await mkdir(join(testVaultPath, "attachments"), { recursive: true });
|
|
737
|
+
await mkdir(join(testVaultPath, "assets"), { recursive: true });
|
|
738
|
+
await writeFile(join(testVaultPath, oldPath), Buffer.from([0x01, 0x02, 0x03]));
|
|
739
|
+
await writeFile(join(testVaultPath, newPath), Buffer.from([0xaa, 0xbb]));
|
|
740
|
+
const result = await fileSystem.moveFile({
|
|
741
|
+
oldPath,
|
|
742
|
+
newPath,
|
|
743
|
+
confirmOldPath: oldPath,
|
|
744
|
+
confirmNewPath: newPath,
|
|
745
|
+
overwrite: false
|
|
746
|
+
});
|
|
747
|
+
expect(result.success).toBe(false);
|
|
748
|
+
expect(result.message).toContain("Target file already exists");
|
|
749
|
+
});
|
|
750
|
+
test("move_file overwrites existing file when overwrite=true", async () => {
|
|
751
|
+
const oldPath = "attachments/image.png";
|
|
752
|
+
const newPath = "assets/image.png";
|
|
753
|
+
const replacement = Buffer.from([0xde, 0xad, 0xbe, 0xef]);
|
|
754
|
+
await mkdir(join(testVaultPath, "attachments"), { recursive: true });
|
|
755
|
+
await mkdir(join(testVaultPath, "assets"), { recursive: true });
|
|
756
|
+
await writeFile(join(testVaultPath, oldPath), replacement);
|
|
757
|
+
await writeFile(join(testVaultPath, newPath), Buffer.from([0x00]));
|
|
758
|
+
const result = await fileSystem.moveFile({
|
|
759
|
+
oldPath,
|
|
760
|
+
newPath,
|
|
761
|
+
confirmOldPath: oldPath,
|
|
762
|
+
confirmNewPath: newPath,
|
|
763
|
+
overwrite: true
|
|
764
|
+
});
|
|
765
|
+
expect(result.success).toBe(true);
|
|
766
|
+
const moved = await readFile(join(testVaultPath, newPath));
|
|
767
|
+
expect(Buffer.compare(moved, replacement)).toBe(0);
|
|
768
|
+
});
|
|
769
|
+
test("move_file rejects directory sources", async () => {
|
|
770
|
+
await mkdir(join(testVaultPath, "attachments/folder"), { recursive: true });
|
|
771
|
+
const result = await fileSystem.moveFile({
|
|
772
|
+
oldPath: "attachments/folder",
|
|
773
|
+
newPath: "assets/folder",
|
|
774
|
+
confirmOldPath: "attachments/folder",
|
|
775
|
+
confirmNewPath: "assets/folder"
|
|
776
|
+
});
|
|
777
|
+
expect(result.success).toBe(false);
|
|
778
|
+
expect(result.message).toContain("supports files only");
|
|
779
|
+
});
|
|
780
|
+
test("move_file blocks restricted system paths", async () => {
|
|
781
|
+
const result = await fileSystem.moveFile({
|
|
782
|
+
oldPath: ".obsidian/plugins/data.json",
|
|
783
|
+
newPath: "assets/data.json",
|
|
784
|
+
confirmOldPath: ".obsidian/plugins/data.json",
|
|
785
|
+
confirmNewPath: "assets/data.json"
|
|
786
|
+
});
|
|
787
|
+
expect(result.success).toBe(false);
|
|
788
|
+
expect(result.message).toContain("Access denied");
|
|
789
|
+
});
|
|
790
|
+
test("move_file requires matching confirmation paths", async () => {
|
|
791
|
+
const oldPath = "attachments/check.png";
|
|
792
|
+
const newPath = "assets/check.png";
|
|
793
|
+
await mkdir(join(testVaultPath, "attachments"), { recursive: true });
|
|
794
|
+
await writeFile(join(testVaultPath, oldPath), Buffer.from([0x11, 0x22]));
|
|
795
|
+
const result = await fileSystem.moveFile({
|
|
796
|
+
oldPath,
|
|
797
|
+
newPath,
|
|
798
|
+
confirmOldPath: "attachments/other.png",
|
|
799
|
+
confirmNewPath: newPath
|
|
800
|
+
});
|
|
801
|
+
expect(result.success).toBe(false);
|
|
802
|
+
expect(result.message).toContain("confirmation paths do not match");
|
|
803
|
+
const stillExists = await readFile(join(testVaultPath, oldPath));
|
|
804
|
+
expect(Buffer.compare(stillExists, Buffer.from([0x11, 0x22]))).toBe(0);
|
|
805
|
+
});
|
|
618
806
|
test("patch note with regex special chars in oldString", async () => {
|
|
619
807
|
const testPath = "regex-test.md";
|
|
620
808
|
const content = "Price: $10.50 (discount)";
|
|
@@ -134,6 +134,37 @@ Pattern: backup.2024/**/*.md`;
|
|
|
134
134
|
await expect(fileSystem.readNote(".git/config"))
|
|
135
135
|
.rejects.toThrow(/Access denied/);
|
|
136
136
|
});
|
|
137
|
+
test("search matches note filename even without content match", async () => {
|
|
138
|
+
// Issue #30: notes without a heading that rely on filename for discovery
|
|
139
|
+
await fileSystem.writeNote({
|
|
140
|
+
path: "Yard.md",
|
|
141
|
+
content: "Some info about lawn care and gardening tips."
|
|
142
|
+
});
|
|
143
|
+
await fileSystem.writeNote({
|
|
144
|
+
path: "Kitchen.md",
|
|
145
|
+
content: "Recipes and kitchen organization."
|
|
146
|
+
});
|
|
147
|
+
// Search for "yard" — should match Yard.md by filename
|
|
148
|
+
const results = await searchService.search({
|
|
149
|
+
query: "yard",
|
|
150
|
+
searchContent: true,
|
|
151
|
+
limit: 10
|
|
152
|
+
});
|
|
153
|
+
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
154
|
+
expect(results.some(r => r.p === "Yard.md")).toBe(true);
|
|
155
|
+
// Verify filename-only match has reasonable fields
|
|
156
|
+
const yardResult = results.find(r => r.p === "Yard.md");
|
|
157
|
+
expect(yardResult.t).toBe("Yard");
|
|
158
|
+
expect(yardResult.mc).toBeGreaterThanOrEqual(1);
|
|
159
|
+
// Search for "kitchen" — should match Kitchen.md by filename
|
|
160
|
+
const kitchenResults = await searchService.search({
|
|
161
|
+
query: "kitchen",
|
|
162
|
+
searchContent: true,
|
|
163
|
+
limit: 10
|
|
164
|
+
});
|
|
165
|
+
// Should match both filename AND content (content contains "kitchen")
|
|
166
|
+
expect(kitchenResults.some(r => r.p === "Kitchen.md")).toBe(true);
|
|
167
|
+
});
|
|
137
168
|
test("multi-step workflow: search, read multiple, update frontmatter", async () => {
|
|
138
169
|
// Create several notes
|
|
139
170
|
for (let i = 1; i <= 3; i++) {
|
package/dist/src/pathfilter.js
CHANGED
|
@@ -3,8 +3,11 @@ export class PathFilter {
|
|
|
3
3
|
allowedExtensions;
|
|
4
4
|
constructor(config) {
|
|
5
5
|
this.ignoredPatterns = [
|
|
6
|
+
'.obsidian',
|
|
6
7
|
'.obsidian/**',
|
|
8
|
+
'.git',
|
|
7
9
|
'.git/**',
|
|
10
|
+
'node_modules',
|
|
8
11
|
'node_modules/**',
|
|
9
12
|
'.DS_Store',
|
|
10
13
|
'Thumbs.db',
|
|
@@ -34,11 +37,8 @@ export class PathFilter {
|
|
|
34
37
|
isAllowed(path) {
|
|
35
38
|
// Normalize path separators
|
|
36
39
|
const normalizedPath = path.replace(/\\/g, '/');
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (this.simpleGlobMatch(pattern, normalizedPath)) {
|
|
40
|
-
return false;
|
|
41
|
-
}
|
|
40
|
+
if (this.isIgnoredPath(normalizedPath)) {
|
|
41
|
+
return false;
|
|
42
42
|
}
|
|
43
43
|
// For files, check extension if allowedExtensions is configured
|
|
44
44
|
if (this.allowedExtensions.length > 0 && this.isFile(normalizedPath)) {
|
|
@@ -49,6 +49,21 @@ export class PathFilter {
|
|
|
49
49
|
}
|
|
50
50
|
return true;
|
|
51
51
|
}
|
|
52
|
+
isAllowedForListing(path) {
|
|
53
|
+
// Normalize path separators
|
|
54
|
+
const normalizedPath = path.replace(/\\/g, '/');
|
|
55
|
+
// Listing includes non-note files, but still blocks restricted system paths
|
|
56
|
+
return !this.isIgnoredPath(normalizedPath);
|
|
57
|
+
}
|
|
58
|
+
isIgnoredPath(normalizedPath) {
|
|
59
|
+
// Check if path matches any ignored pattern
|
|
60
|
+
for (const pattern of this.ignoredPatterns) {
|
|
61
|
+
if (this.simpleGlobMatch(pattern, normalizedPath)) {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
52
67
|
isFile(path) {
|
|
53
68
|
// A path is a file if it has a file extension at the end
|
|
54
69
|
// Paths ending with '/' are always directories
|
|
@@ -12,16 +12,19 @@ describe("PathFilter", () => {
|
|
|
12
12
|
});
|
|
13
13
|
test("blocks .obsidian directory", () => {
|
|
14
14
|
const filter = new PathFilter();
|
|
15
|
+
expect(filter.isAllowed(".obsidian")).toBe(false);
|
|
15
16
|
expect(filter.isAllowed(".obsidian/app.json")).toBe(false);
|
|
16
17
|
expect(filter.isAllowed(".obsidian/plugins/plugin/main.js")).toBe(false);
|
|
17
18
|
});
|
|
18
19
|
test("blocks .git directory", () => {
|
|
19
20
|
const filter = new PathFilter();
|
|
21
|
+
expect(filter.isAllowed(".git")).toBe(false);
|
|
20
22
|
expect(filter.isAllowed(".git/config")).toBe(false);
|
|
21
23
|
expect(filter.isAllowed(".git/objects/abc123")).toBe(false);
|
|
22
24
|
});
|
|
23
25
|
test("blocks node_modules", () => {
|
|
24
26
|
const filter = new PathFilter();
|
|
27
|
+
expect(filter.isAllowed("node_modules")).toBe(false);
|
|
25
28
|
expect(filter.isAllowed("node_modules/package/index.js")).toBe(false);
|
|
26
29
|
});
|
|
27
30
|
test("blocks system files", () => {
|
|
@@ -35,6 +38,19 @@ describe("PathFilter", () => {
|
|
|
35
38
|
expect(filter.isAllowed("data.json")).toBe(false);
|
|
36
39
|
expect(filter.isAllowed("image.png")).toBe(false);
|
|
37
40
|
});
|
|
41
|
+
test("allows non-note files for directory listing", () => {
|
|
42
|
+
const filter = new PathFilter();
|
|
43
|
+
expect(filter.isAllowedForListing("image.png")).toBe(true);
|
|
44
|
+
expect(filter.isAllowedForListing("docs/report.pdf")).toBe(true);
|
|
45
|
+
expect(filter.isAllowedForListing("archive/data.json")).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
test("blocks restricted paths in directory listing", () => {
|
|
48
|
+
const filter = new PathFilter();
|
|
49
|
+
expect(filter.isAllowedForListing(".obsidian/app.json")).toBe(false);
|
|
50
|
+
expect(filter.isAllowedForListing(".git/config")).toBe(false);
|
|
51
|
+
expect(filter.isAllowedForListing("node_modules/pkg/index.js")).toBe(false);
|
|
52
|
+
expect(filter.isAllowedForListing(".DS_Store")).toBe(false);
|
|
53
|
+
});
|
|
38
54
|
// ============================================================================
|
|
39
55
|
// REGEX SPECIAL CHARACTERS - SECURITY TESTS
|
|
40
56
|
// ============================================================================
|