@mauricio.wolff/mcp-obsidian 0.7.5 → 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 +283 -25
- package/dist/server.js +79 -16
- package/dist/src/filesystem.js +131 -8
- package/dist/src/filesystem.test.js +218 -1
- package/dist/src/frontmatter.js +26 -0
- package/dist/src/frontmatter.test.js +34 -2
- package/dist/src/integration.test.js +0 -93
- package/dist/src/pathfilter.js +19 -5
- package/dist/src/pathfilter.test.js +18 -0
- package/dist/src/search.js +94 -38
- package/dist/src/search.test.js +212 -0
- package/package.json +4 -5
package/dist/server.js
CHANGED
|
@@ -3,51 +3,54 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
5
5
|
import { FileSystemService } from "./src/filesystem.js";
|
|
6
|
-
import { FrontmatterHandler } from "./src/frontmatter.js";
|
|
6
|
+
import { FrontmatterHandler, parseFrontmatter } from "./src/frontmatter.js";
|
|
7
7
|
import { PathFilter } from "./src/pathfilter.js";
|
|
8
8
|
import { SearchService } from "./src/search.js";
|
|
9
9
|
import { readFileSync } from "fs";
|
|
10
10
|
import { fileURLToPath } from "url";
|
|
11
|
-
import { dirname, join } from "path";
|
|
11
|
+
import { dirname, join, resolve } from "path";
|
|
12
12
|
// Get package.json version
|
|
13
13
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
14
|
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
|
|
|
27
28
|
Universal AI bridge for Obsidian vaults - connect any MCP-compatible assistant
|
|
28
29
|
|
|
29
30
|
Usage:
|
|
30
|
-
npx @mauricio.wolff/mcp-obsidian
|
|
31
|
+
npx @mauricio.wolff/mcp-obsidian [vault-path]
|
|
31
32
|
|
|
32
33
|
Arguments:
|
|
33
|
-
|
|
34
|
+
[vault-path] Optional path to your Obsidian vault directory
|
|
35
|
+
Defaults to current working directory when omitted
|
|
34
36
|
|
|
35
37
|
Options:
|
|
36
38
|
--version, -v Show version number
|
|
37
39
|
--help, -h Show this help message
|
|
38
40
|
|
|
39
41
|
Examples:
|
|
42
|
+
npx @mauricio.wolff/mcp-obsidian
|
|
40
43
|
npx @mauricio.wolff/mcp-obsidian ~/Documents/MyVault
|
|
44
|
+
npx @mauricio.wolff/mcp-obsidian ./Vault
|
|
41
45
|
npx @mauricio.wolff/mcp-obsidian /path/to/obsidian/vault
|
|
46
|
+
npx @mauricio.wolff/mcp-obsidian "/path/with spaces/Obsidian Vault"
|
|
42
47
|
`);
|
|
43
48
|
process.exit(0);
|
|
44
49
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
process.exit(1);
|
|
50
|
-
}
|
|
50
|
+
// Join trailing args to support vault paths with spaces.
|
|
51
|
+
// When omitted, default to current working directory.
|
|
52
|
+
const vaultPathArg = cliArgs.join(' ').trim();
|
|
53
|
+
const vaultPath = resolve(vaultPathArg || process.cwd());
|
|
51
54
|
// Initialize services
|
|
52
55
|
const pathFilter = new PathFilter();
|
|
53
56
|
const frontmatterHandler = new FrontmatterHandler();
|
|
@@ -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);
|
|
@@ -419,10 +459,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
419
459
|
};
|
|
420
460
|
}
|
|
421
461
|
case "write_note": {
|
|
462
|
+
const fm = parseFrontmatter(trimmedArgs.frontmatter);
|
|
422
463
|
await fileSystem.writeNote({
|
|
423
464
|
path: trimmedArgs.path,
|
|
424
465
|
content: trimmedArgs.content,
|
|
425
|
-
frontmatter:
|
|
466
|
+
...(fm !== undefined && { frontmatter: fm }),
|
|
426
467
|
mode: trimmedArgs.mode || 'overwrite'
|
|
427
468
|
});
|
|
428
469
|
return {
|
|
@@ -515,6 +556,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
515
556
|
isError: !result.success
|
|
516
557
|
};
|
|
517
558
|
}
|
|
559
|
+
case "move_file": {
|
|
560
|
+
const result = await fileSystem.moveFile({
|
|
561
|
+
oldPath: trimmedArgs.oldPath,
|
|
562
|
+
newPath: trimmedArgs.newPath,
|
|
563
|
+
confirmOldPath: trimmedArgs.confirmOldPath,
|
|
564
|
+
confirmNewPath: trimmedArgs.confirmNewPath,
|
|
565
|
+
overwrite: trimmedArgs.overwrite
|
|
566
|
+
});
|
|
567
|
+
return {
|
|
568
|
+
content: [
|
|
569
|
+
{
|
|
570
|
+
type: "text",
|
|
571
|
+
text: JSON.stringify(result, null, 2)
|
|
572
|
+
}
|
|
573
|
+
],
|
|
574
|
+
isError: !result.success
|
|
575
|
+
};
|
|
576
|
+
}
|
|
518
577
|
case "read_multiple_notes": {
|
|
519
578
|
const result = await fileSystem.readMultipleNotes({
|
|
520
579
|
paths: trimmedArgs.paths,
|
|
@@ -535,9 +594,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
535
594
|
};
|
|
536
595
|
}
|
|
537
596
|
case "update_frontmatter": {
|
|
597
|
+
const fm = parseFrontmatter(trimmedArgs.frontmatter);
|
|
598
|
+
if (!fm) {
|
|
599
|
+
throw new Error('frontmatter is required');
|
|
600
|
+
}
|
|
538
601
|
await fileSystem.updateFrontmatter({
|
|
539
602
|
path: trimmedArgs.path,
|
|
540
|
-
frontmatter:
|
|
603
|
+
frontmatter: fm,
|
|
541
604
|
merge: trimmedArgs.merge
|
|
542
605
|
});
|
|
543
606
|
return {
|
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) {
|
|
@@ -595,19 +719,18 @@ export class FileSystemService {
|
|
|
595
719
|
const entries = await readdir(dirPath, { withFileTypes: true });
|
|
596
720
|
for (const entry of entries) {
|
|
597
721
|
const entryRelativePath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
598
|
-
if (!this.pathFilter.isAllowed(entryRelativePath)) {
|
|
599
|
-
continue;
|
|
600
|
-
}
|
|
601
722
|
const fullEntryPath = join(dirPath, entry.name);
|
|
602
723
|
if (entry.isDirectory()) {
|
|
603
|
-
|
|
604
|
-
if (!this.pathFilter.isAllowed(`${entryRelativePath}/test.md`)) {
|
|
724
|
+
if (!this.pathFilter.isAllowedForListing(entryRelativePath)) {
|
|
605
725
|
continue;
|
|
606
726
|
}
|
|
607
727
|
totalFolders++;
|
|
608
728
|
await scanDirectory(fullEntryPath, entryRelativePath);
|
|
609
729
|
}
|
|
610
730
|
else if (entry.isFile()) {
|
|
731
|
+
if (!this.pathFilter.isAllowed(entryRelativePath)) {
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
611
734
|
totalNotes++;
|
|
612
735
|
const stats = await stat(fullEntryPath);
|
|
613
736
|
totalSize += stats.size;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { test, expect, beforeEach, afterEach } from "vitest";
|
|
2
2
|
import { FileSystemService } from "./filesystem.js";
|
|
3
|
-
import {
|
|
3
|
+
import { PathFilter } from "./pathfilter.js";
|
|
4
|
+
import { writeFile, readFile, mkdir, mkdtemp, rm } from "fs/promises";
|
|
4
5
|
import { join } from "path";
|
|
5
6
|
import { tmpdir } from "os";
|
|
6
7
|
let testVaultPath;
|
|
@@ -184,6 +185,68 @@ test("patch note fails with empty newString", async () => {
|
|
|
184
185
|
expect(result.success).toBe(false);
|
|
185
186
|
expect(result.message).toMatch(/empty|filled|required/i);
|
|
186
187
|
});
|
|
188
|
+
test("patch note fails with undefined newString", async () => {
|
|
189
|
+
const testPath = "test-note.md";
|
|
190
|
+
const content = "# Test Note\n\nSome content.";
|
|
191
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
192
|
+
const result = await fileSystem.patchNote({
|
|
193
|
+
path: testPath,
|
|
194
|
+
oldString: "content",
|
|
195
|
+
newString: undefined,
|
|
196
|
+
replaceAll: false
|
|
197
|
+
});
|
|
198
|
+
expect(result.success).toBe(false);
|
|
199
|
+
expect(result.message).toMatch(/empty|filled|required/i);
|
|
200
|
+
// Verify the note was NOT corrupted
|
|
201
|
+
const note = await fileSystem.readNote(testPath);
|
|
202
|
+
expect(note.content).not.toContain("undefined");
|
|
203
|
+
expect(note.content).toContain("Some content.");
|
|
204
|
+
});
|
|
205
|
+
test("patch note fails with null newString", async () => {
|
|
206
|
+
const testPath = "test-note.md";
|
|
207
|
+
const content = "# Test Note\n\nSome content.";
|
|
208
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
209
|
+
const result = await fileSystem.patchNote({
|
|
210
|
+
path: testPath,
|
|
211
|
+
oldString: "content",
|
|
212
|
+
newString: null,
|
|
213
|
+
replaceAll: false
|
|
214
|
+
});
|
|
215
|
+
expect(result.success).toBe(false);
|
|
216
|
+
expect(result.message).toMatch(/empty|filled|required/i);
|
|
217
|
+
// Verify the note was NOT corrupted
|
|
218
|
+
const note = await fileSystem.readNote(testPath);
|
|
219
|
+
expect(note.content).not.toContain("null");
|
|
220
|
+
expect(note.content).toContain("Some content.");
|
|
221
|
+
});
|
|
222
|
+
test("writeNote rejects undefined content", async () => {
|
|
223
|
+
const testPath = "test-note.md";
|
|
224
|
+
await expect(fileSystem.writeNote({
|
|
225
|
+
path: testPath,
|
|
226
|
+
content: undefined
|
|
227
|
+
})).rejects.toThrow(/Content is required/);
|
|
228
|
+
});
|
|
229
|
+
test("writeNote rejects null content", async () => {
|
|
230
|
+
const testPath = "test-note.md";
|
|
231
|
+
await expect(fileSystem.writeNote({
|
|
232
|
+
path: testPath,
|
|
233
|
+
content: null
|
|
234
|
+
})).rejects.toThrow(/Content is required/);
|
|
235
|
+
});
|
|
236
|
+
test("writeNote append with undefined content does not corrupt note", async () => {
|
|
237
|
+
const testPath = "test-note.md";
|
|
238
|
+
const content = "# Test Note\n\nOriginal content.";
|
|
239
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
240
|
+
await expect(fileSystem.writeNote({
|
|
241
|
+
path: testPath,
|
|
242
|
+
content: undefined,
|
|
243
|
+
mode: 'append'
|
|
244
|
+
})).rejects.toThrow(/Content is required/);
|
|
245
|
+
// Verify the note was NOT corrupted
|
|
246
|
+
const note = await fileSystem.readNote(testPath);
|
|
247
|
+
expect(note.content).not.toContain("undefined");
|
|
248
|
+
expect(note.content).toContain("Original content.");
|
|
249
|
+
});
|
|
187
250
|
test("patch note handles regex special characters literally", async () => {
|
|
188
251
|
const testPath = "test-note.md";
|
|
189
252
|
const content = "Price: $10.50 (special)";
|
|
@@ -199,6 +262,34 @@ test("patch note handles regex special characters literally", async () => {
|
|
|
199
262
|
expect(updatedNote.content).toContain("$15.75");
|
|
200
263
|
expect(updatedNote.content).not.toContain("$10.50");
|
|
201
264
|
});
|
|
265
|
+
test("patch note works with fenced code blocks", async () => {
|
|
266
|
+
const testPath = "code-fence-test.md";
|
|
267
|
+
const content = "# Example\n\n```rust\nfn main() {\n println!(\"hello\");\n}\n```\n";
|
|
268
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
269
|
+
const result = await fileSystem.patchNote({
|
|
270
|
+
path: testPath,
|
|
271
|
+
oldString: "println!(\"hello\");",
|
|
272
|
+
newString: "println!(\"hello world\");",
|
|
273
|
+
replaceAll: false
|
|
274
|
+
});
|
|
275
|
+
expect(result.success).toBe(true);
|
|
276
|
+
const updatedNote = await fileSystem.readNote(testPath);
|
|
277
|
+
expect(updatedNote.originalContent).toContain("println!(\"hello world\");");
|
|
278
|
+
});
|
|
279
|
+
test("patch note works with markdown tables", async () => {
|
|
280
|
+
const testPath = "table-test.md";
|
|
281
|
+
const content = "| Tool | Status |\n|---|---|\n| patch_note | flaky |\n";
|
|
282
|
+
await writeFile(join(testVaultPath, testPath), content);
|
|
283
|
+
const result = await fileSystem.patchNote({
|
|
284
|
+
path: testPath,
|
|
285
|
+
oldString: "| patch_note | flaky |",
|
|
286
|
+
newString: "| patch_note | stable |",
|
|
287
|
+
replaceAll: false
|
|
288
|
+
});
|
|
289
|
+
expect(result.success).toBe(true);
|
|
290
|
+
const updatedNote = await fileSystem.readNote(testPath);
|
|
291
|
+
expect(updatedNote.originalContent).toContain("| patch_note | stable |");
|
|
292
|
+
});
|
|
202
293
|
test("patch note preserves tabs and spaces", async () => {
|
|
203
294
|
const testPath = "test-note.md";
|
|
204
295
|
const content = "Line with\ttabs\n Line with spaces\n\tTabbed line";
|
|
@@ -514,6 +605,14 @@ test("frontmatter validation with invalid data", async () => {
|
|
|
514
605
|
}
|
|
515
606
|
})).rejects.toThrow(/Invalid frontmatter/);
|
|
516
607
|
});
|
|
608
|
+
test("listDirectory includes non-note files but readNote still blocks them", async () => {
|
|
609
|
+
const imagePath = "assets/diagram.png";
|
|
610
|
+
await mkdir(join(testVaultPath, "assets"), { recursive: true });
|
|
611
|
+
await writeFile(join(testVaultPath, imagePath), "fake-png-content");
|
|
612
|
+
const listing = await fileSystem.listDirectory("assets");
|
|
613
|
+
expect(listing.files).toContain("diagram.png");
|
|
614
|
+
await expect(fileSystem.readNote(imagePath)).rejects.toThrow(/Access denied/);
|
|
615
|
+
});
|
|
517
616
|
// ============================================================================
|
|
518
617
|
// NON-EXISTENT VAULT TESTS
|
|
519
618
|
// ============================================================================
|
|
@@ -615,6 +714,96 @@ test("move note with special chars in both paths", async () => {
|
|
|
615
714
|
const note = await fileSystem.readNote(newPath);
|
|
616
715
|
expect(note.content).toContain("Moving note");
|
|
617
716
|
});
|
|
717
|
+
test("move_file moves binary files without corruption", async () => {
|
|
718
|
+
const oldPath = "attachments/original image.png";
|
|
719
|
+
const newPath = "assets/original image.png";
|
|
720
|
+
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00, 0xff, 0x10, 0x42]);
|
|
721
|
+
await mkdir(join(testVaultPath, "attachments"), { recursive: true });
|
|
722
|
+
await writeFile(join(testVaultPath, oldPath), binaryContent);
|
|
723
|
+
const result = await fileSystem.moveFile({
|
|
724
|
+
oldPath,
|
|
725
|
+
newPath,
|
|
726
|
+
confirmOldPath: oldPath,
|
|
727
|
+
confirmNewPath: newPath
|
|
728
|
+
});
|
|
729
|
+
expect(result.success).toBe(true);
|
|
730
|
+
const moved = await readFile(join(testVaultPath, newPath));
|
|
731
|
+
expect(Buffer.compare(moved, binaryContent)).toBe(0);
|
|
732
|
+
await expect(readFile(join(testVaultPath, oldPath))).rejects.toMatchObject({ code: "ENOENT" });
|
|
733
|
+
});
|
|
734
|
+
test("move_file respects overwrite=false", async () => {
|
|
735
|
+
const oldPath = "attachments/image.png";
|
|
736
|
+
const newPath = "assets/image.png";
|
|
737
|
+
await mkdir(join(testVaultPath, "attachments"), { recursive: true });
|
|
738
|
+
await mkdir(join(testVaultPath, "assets"), { recursive: true });
|
|
739
|
+
await writeFile(join(testVaultPath, oldPath), Buffer.from([0x01, 0x02, 0x03]));
|
|
740
|
+
await writeFile(join(testVaultPath, newPath), Buffer.from([0xaa, 0xbb]));
|
|
741
|
+
const result = await fileSystem.moveFile({
|
|
742
|
+
oldPath,
|
|
743
|
+
newPath,
|
|
744
|
+
confirmOldPath: oldPath,
|
|
745
|
+
confirmNewPath: newPath,
|
|
746
|
+
overwrite: false
|
|
747
|
+
});
|
|
748
|
+
expect(result.success).toBe(false);
|
|
749
|
+
expect(result.message).toContain("Target file already exists");
|
|
750
|
+
});
|
|
751
|
+
test("move_file overwrites existing file when overwrite=true", async () => {
|
|
752
|
+
const oldPath = "attachments/image.png";
|
|
753
|
+
const newPath = "assets/image.png";
|
|
754
|
+
const replacement = Buffer.from([0xde, 0xad, 0xbe, 0xef]);
|
|
755
|
+
await mkdir(join(testVaultPath, "attachments"), { recursive: true });
|
|
756
|
+
await mkdir(join(testVaultPath, "assets"), { recursive: true });
|
|
757
|
+
await writeFile(join(testVaultPath, oldPath), replacement);
|
|
758
|
+
await writeFile(join(testVaultPath, newPath), Buffer.from([0x00]));
|
|
759
|
+
const result = await fileSystem.moveFile({
|
|
760
|
+
oldPath,
|
|
761
|
+
newPath,
|
|
762
|
+
confirmOldPath: oldPath,
|
|
763
|
+
confirmNewPath: newPath,
|
|
764
|
+
overwrite: true
|
|
765
|
+
});
|
|
766
|
+
expect(result.success).toBe(true);
|
|
767
|
+
const moved = await readFile(join(testVaultPath, newPath));
|
|
768
|
+
expect(Buffer.compare(moved, replacement)).toBe(0);
|
|
769
|
+
});
|
|
770
|
+
test("move_file rejects directory sources", async () => {
|
|
771
|
+
await mkdir(join(testVaultPath, "attachments/folder"), { recursive: true });
|
|
772
|
+
const result = await fileSystem.moveFile({
|
|
773
|
+
oldPath: "attachments/folder",
|
|
774
|
+
newPath: "assets/folder",
|
|
775
|
+
confirmOldPath: "attachments/folder",
|
|
776
|
+
confirmNewPath: "assets/folder"
|
|
777
|
+
});
|
|
778
|
+
expect(result.success).toBe(false);
|
|
779
|
+
expect(result.message).toContain("supports files only");
|
|
780
|
+
});
|
|
781
|
+
test("move_file blocks restricted system paths", async () => {
|
|
782
|
+
const result = await fileSystem.moveFile({
|
|
783
|
+
oldPath: ".obsidian/plugins/data.json",
|
|
784
|
+
newPath: "assets/data.json",
|
|
785
|
+
confirmOldPath: ".obsidian/plugins/data.json",
|
|
786
|
+
confirmNewPath: "assets/data.json"
|
|
787
|
+
});
|
|
788
|
+
expect(result.success).toBe(false);
|
|
789
|
+
expect(result.message).toContain("Access denied");
|
|
790
|
+
});
|
|
791
|
+
test("move_file requires matching confirmation paths", async () => {
|
|
792
|
+
const oldPath = "attachments/check.png";
|
|
793
|
+
const newPath = "assets/check.png";
|
|
794
|
+
await mkdir(join(testVaultPath, "attachments"), { recursive: true });
|
|
795
|
+
await writeFile(join(testVaultPath, oldPath), Buffer.from([0x11, 0x22]));
|
|
796
|
+
const result = await fileSystem.moveFile({
|
|
797
|
+
oldPath,
|
|
798
|
+
newPath,
|
|
799
|
+
confirmOldPath: "attachments/other.png",
|
|
800
|
+
confirmNewPath: newPath
|
|
801
|
+
});
|
|
802
|
+
expect(result.success).toBe(false);
|
|
803
|
+
expect(result.message).toContain("confirmation paths do not match");
|
|
804
|
+
const stillExists = await readFile(join(testVaultPath, oldPath));
|
|
805
|
+
expect(Buffer.compare(stillExists, Buffer.from([0x11, 0x22]))).toBe(0);
|
|
806
|
+
});
|
|
618
807
|
test("patch note with regex special chars in oldString", async () => {
|
|
619
808
|
const testPath = "regex-test.md";
|
|
620
809
|
const content = "Price: $10.50 (discount)";
|
|
@@ -703,6 +892,34 @@ test("get vault stats excludes filtered paths", async () => {
|
|
|
703
892
|
expect(stats.recentlyModified.map(f => f.path)).toContain("visible.md");
|
|
704
893
|
expect(stats.recentlyModified.map(f => f.path)).not.toContain(".obsidian/config.json");
|
|
705
894
|
});
|
|
895
|
+
test("get vault stats excludes files matched by custom ** ignored patterns", async () => {
|
|
896
|
+
const customFilter = new PathFilter({
|
|
897
|
+
ignoredPatterns: ["ignored/**"]
|
|
898
|
+
});
|
|
899
|
+
const customFileSystem = new FileSystemService(testVaultPath, customFilter);
|
|
900
|
+
await mkdir(join(testVaultPath, "ignored"), { recursive: true });
|
|
901
|
+
await mkdir(join(testVaultPath, "ignored/nested"), { recursive: true });
|
|
902
|
+
await writeFile(join(testVaultPath, "ignored/something.md"), "# Disallowed 1");
|
|
903
|
+
await writeFile(join(testVaultPath, "ignored/nested/something.md"), "# Disallowed 2");
|
|
904
|
+
await writeFile(join(testVaultPath, "visible.md"), "# Visible");
|
|
905
|
+
const stats = await customFileSystem.getVaultStats(10);
|
|
906
|
+
const recentPaths = stats.recentlyModified.map(file => file.path);
|
|
907
|
+
expect(stats.totalNotes).toBe(1);
|
|
908
|
+
expect(recentPaths).toContain("visible.md");
|
|
909
|
+
expect(recentPaths).not.toContain("ignored/something.md");
|
|
910
|
+
expect(recentPaths).not.toContain("ignored/nested/something.md");
|
|
911
|
+
});
|
|
912
|
+
test("get vault stats includes notes inside directories that contain dots", async () => {
|
|
913
|
+
await mkdir(join(testVaultPath, "2026.03"), { recursive: true });
|
|
914
|
+
await writeFile(join(testVaultPath, "2026.03/nested.md"), "# Nested");
|
|
915
|
+
await writeFile(join(testVaultPath, "root.md"), "# Root");
|
|
916
|
+
const stats = await fileSystem.getVaultStats(10);
|
|
917
|
+
const recentPaths = stats.recentlyModified.map(file => file.path);
|
|
918
|
+
expect(stats.totalNotes).toBe(2);
|
|
919
|
+
expect(stats.totalFolders).toBe(1);
|
|
920
|
+
expect(recentPaths).toContain("2026.03/nested.md");
|
|
921
|
+
expect(recentPaths).toContain("root.md");
|
|
922
|
+
});
|
|
706
923
|
test("get vault stats calculates total size correctly", async () => {
|
|
707
924
|
const content1 = "# Note 1 with some content";
|
|
708
925
|
const content2 = "# Note 2 with more content here";
|
package/dist/src/frontmatter.js
CHANGED
|
@@ -1,4 +1,30 @@
|
|
|
1
1
|
import matter from 'gray-matter';
|
|
2
|
+
/**
|
|
3
|
+
* Parse a frontmatter value that may be a JSON string (LLM clients sometimes
|
|
4
|
+
* pass frontmatter as a serialized JSON string instead of an object).
|
|
5
|
+
* Returns undefined if the value is null/undefined, or throws if invalid.
|
|
6
|
+
*/
|
|
7
|
+
export function parseFrontmatter(value) {
|
|
8
|
+
if (value === undefined || value === null) {
|
|
9
|
+
return undefined;
|
|
10
|
+
}
|
|
11
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
12
|
+
return value;
|
|
13
|
+
}
|
|
14
|
+
if (typeof value === 'string') {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = JSON.parse(value);
|
|
17
|
+
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
|
18
|
+
return parsed;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// not valid JSON
|
|
23
|
+
}
|
|
24
|
+
throw new Error('frontmatter must be a JSON object, got a string that is not valid JSON');
|
|
25
|
+
}
|
|
26
|
+
throw new Error(`frontmatter must be a JSON object, got ${typeof value}`);
|
|
27
|
+
}
|
|
2
28
|
export class FrontmatterHandler {
|
|
3
29
|
parse(content) {
|
|
4
30
|
try {
|