@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/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 arg = process.argv[2];
19
- if (arg === "--version" || arg === "-v") {
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 (arg === "--help" || arg === "-h") {
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
- const vaultPath = arg;
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,
@@ -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.isAllowed(entryPath)) {
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++) {
@@ -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
- // Check if path matches any ignored pattern
38
- for (const pattern of this.ignoredPatterns) {
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
  // ============================================================================