@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,129 @@
|
|
|
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
|
+
}
|
|
28
|
+
export class FrontmatterHandler {
|
|
29
|
+
parse(content) {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = matter(content);
|
|
32
|
+
return {
|
|
33
|
+
frontmatter: parsed.data,
|
|
34
|
+
content: parsed.content,
|
|
35
|
+
originalContent: content
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
// If parsing fails, treat as content without frontmatter
|
|
40
|
+
return {
|
|
41
|
+
frontmatter: {},
|
|
42
|
+
content: content,
|
|
43
|
+
originalContent: content
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
stringify(frontmatterData, content) {
|
|
48
|
+
try {
|
|
49
|
+
// If no frontmatter, return content as-is
|
|
50
|
+
if (!frontmatterData || Object.keys(frontmatterData).length === 0) {
|
|
51
|
+
return content;
|
|
52
|
+
}
|
|
53
|
+
return matter.stringify(content, frontmatterData);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
throw new Error(`Failed to stringify frontmatter: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
validate(frontmatterData) {
|
|
60
|
+
const result = {
|
|
61
|
+
isValid: true,
|
|
62
|
+
errors: [],
|
|
63
|
+
warnings: []
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
// Test if the frontmatter can be serialized to valid YAML using gray-matter
|
|
67
|
+
matter.stringify('', frontmatterData);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
result.isValid = false;
|
|
71
|
+
result.errors.push(`Invalid YAML structure: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
72
|
+
}
|
|
73
|
+
// Check for problematic values
|
|
74
|
+
this.checkForProblematicValues(frontmatterData, result, '');
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
checkForProblematicValues(obj, result, path) {
|
|
78
|
+
if (obj === null || obj === undefined) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (typeof obj === 'function') {
|
|
82
|
+
result.errors.push(`Functions are not allowed in frontmatter at path: ${path}`);
|
|
83
|
+
result.isValid = false;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (typeof obj === 'symbol') {
|
|
87
|
+
result.errors.push(`Symbols are not allowed in frontmatter at path: ${path}`);
|
|
88
|
+
result.isValid = false;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (obj instanceof Date) {
|
|
92
|
+
// Dates are fine, but warn if they're invalid
|
|
93
|
+
if (isNaN(obj.getTime())) {
|
|
94
|
+
result.warnings.push(`Invalid date at path: ${path}`);
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (Array.isArray(obj)) {
|
|
99
|
+
obj.forEach((item, index) => {
|
|
100
|
+
this.checkForProblematicValues(item, result, `${path}[${index}]`);
|
|
101
|
+
});
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (typeof obj === 'object' && obj !== null) {
|
|
105
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
106
|
+
const currentPath = path ? `${path}.${key}` : key;
|
|
107
|
+
// Check for problematic keys
|
|
108
|
+
if (typeof key !== 'string') {
|
|
109
|
+
result.errors.push(`Non-string keys are not allowed: ${key}`);
|
|
110
|
+
result.isValid = false;
|
|
111
|
+
}
|
|
112
|
+
this.checkForProblematicValues(value, result, currentPath);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
extractFrontmatter(content) {
|
|
117
|
+
const parsed = this.parse(content);
|
|
118
|
+
return parsed.frontmatter;
|
|
119
|
+
}
|
|
120
|
+
updateFrontmatter(content, updates) {
|
|
121
|
+
const parsed = this.parse(content);
|
|
122
|
+
const updatedFrontmatter = { ...parsed.frontmatter, ...updates };
|
|
123
|
+
const validation = this.validate(updatedFrontmatter);
|
|
124
|
+
if (!validation.isValid) {
|
|
125
|
+
throw new Error(`Invalid frontmatter: ${validation.errors.join(', ')}`);
|
|
126
|
+
}
|
|
127
|
+
return this.stringify(updatedFrontmatter, parsed.content);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { test, expect, describe } from "vitest";
|
|
2
|
+
import { FrontmatterHandler, parseFrontmatter } from "./frontmatter.js";
|
|
3
|
+
const handler = new FrontmatterHandler();
|
|
4
|
+
test("parse note with frontmatter", () => {
|
|
5
|
+
const content = `---
|
|
6
|
+
title: Test Note
|
|
7
|
+
tags: [test, example]
|
|
8
|
+
created: 2023-01-01
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Test Note
|
|
12
|
+
|
|
13
|
+
This is a test note with frontmatter.`;
|
|
14
|
+
const result = handler.parse(content);
|
|
15
|
+
expect(result.frontmatter.title).toBe("Test Note");
|
|
16
|
+
expect(result.frontmatter.tags).toEqual(["test", "example"]);
|
|
17
|
+
expect(result.frontmatter.created).toEqual(new Date("2023-01-01"));
|
|
18
|
+
expect(result.content.trim()).toBe("# Test Note\n\nThis is a test note with frontmatter.");
|
|
19
|
+
});
|
|
20
|
+
test("parse note without frontmatter", () => {
|
|
21
|
+
const content = `# Test Note
|
|
22
|
+
|
|
23
|
+
This is a test note without frontmatter.`;
|
|
24
|
+
const result = handler.parse(content);
|
|
25
|
+
expect(result.frontmatter).toEqual({});
|
|
26
|
+
expect(result.content).toBe(content);
|
|
27
|
+
});
|
|
28
|
+
test("stringify with frontmatter", () => {
|
|
29
|
+
const frontmatter = {
|
|
30
|
+
title: "Test Note",
|
|
31
|
+
tags: ["test", "example"]
|
|
32
|
+
};
|
|
33
|
+
const content = "# Test Note\n\nContent here.";
|
|
34
|
+
const result = handler.stringify(frontmatter, content);
|
|
35
|
+
expect(result).toContain("---");
|
|
36
|
+
expect(result).toContain("title: Test Note");
|
|
37
|
+
expect(result).toContain("tags:");
|
|
38
|
+
expect(result).toContain("# Test Note");
|
|
39
|
+
});
|
|
40
|
+
test("stringify without frontmatter", () => {
|
|
41
|
+
const content = "# Test Note\n\nContent here.";
|
|
42
|
+
const result = handler.stringify({}, content);
|
|
43
|
+
expect(result).toBe(content);
|
|
44
|
+
});
|
|
45
|
+
test("validate valid frontmatter", () => {
|
|
46
|
+
const frontmatter = {
|
|
47
|
+
title: "Valid Title",
|
|
48
|
+
tags: ["tag1", "tag2"],
|
|
49
|
+
date: new Date("2023-01-01"),
|
|
50
|
+
count: 42,
|
|
51
|
+
enabled: true
|
|
52
|
+
};
|
|
53
|
+
const result = handler.validate(frontmatter);
|
|
54
|
+
expect(result.isValid).toBe(true);
|
|
55
|
+
expect(result.errors).toHaveLength(0);
|
|
56
|
+
});
|
|
57
|
+
test("validate invalid frontmatter with function", () => {
|
|
58
|
+
const frontmatter = {
|
|
59
|
+
title: "Invalid",
|
|
60
|
+
badFunction: () => "not allowed"
|
|
61
|
+
};
|
|
62
|
+
const result = handler.validate(frontmatter);
|
|
63
|
+
expect(result.isValid).toBe(false);
|
|
64
|
+
expect(result.errors.length).toBeGreaterThan(0);
|
|
65
|
+
// The specific error message may vary between YAML libraries
|
|
66
|
+
expect(result.errors[0]).toMatch(/Functions are not allowed|Invalid YAML structure/);
|
|
67
|
+
});
|
|
68
|
+
test("update frontmatter in existing content", () => {
|
|
69
|
+
const content = `---
|
|
70
|
+
title: Old Title
|
|
71
|
+
tags: [old]
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
# Content
|
|
75
|
+
|
|
76
|
+
Some content here.`;
|
|
77
|
+
const updates = {
|
|
78
|
+
title: "New Title",
|
|
79
|
+
modified: "2023-12-01"
|
|
80
|
+
};
|
|
81
|
+
const result = handler.updateFrontmatter(content, updates);
|
|
82
|
+
expect(result).toContain("title: New Title");
|
|
83
|
+
expect(result).toContain("modified: '2023-12-01'");
|
|
84
|
+
expect(result).toContain("tags:");
|
|
85
|
+
expect(result).toContain("# Content");
|
|
86
|
+
});
|
|
87
|
+
describe("parseFrontmatter", () => {
|
|
88
|
+
test("returns undefined for null and undefined", () => {
|
|
89
|
+
expect(parseFrontmatter(null)).toBeUndefined();
|
|
90
|
+
expect(parseFrontmatter(undefined)).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
test("passes through a plain object", () => {
|
|
93
|
+
const obj = { tags: ["test"], title: "Hello" };
|
|
94
|
+
expect(parseFrontmatter(obj)).toBe(obj);
|
|
95
|
+
});
|
|
96
|
+
test("parses a JSON string into an object", () => {
|
|
97
|
+
const input = '{"tags": ["test"], "title": "Hello"}';
|
|
98
|
+
expect(parseFrontmatter(input)).toEqual({ tags: ["test"], title: "Hello" });
|
|
99
|
+
});
|
|
100
|
+
test("parses an empty JSON object string", () => {
|
|
101
|
+
expect(parseFrontmatter("{}")).toEqual({});
|
|
102
|
+
});
|
|
103
|
+
test("throws for a non-JSON string", () => {
|
|
104
|
+
expect(() => parseFrontmatter("not json")).toThrow("frontmatter must be a JSON object");
|
|
105
|
+
});
|
|
106
|
+
test("throws for a JSON array string", () => {
|
|
107
|
+
expect(() => parseFrontmatter('[1, 2, 3]')).toThrow("frontmatter must be a JSON object");
|
|
108
|
+
});
|
|
109
|
+
test("throws for a JSON primitive string", () => {
|
|
110
|
+
expect(() => parseFrontmatter('"just a string"')).toThrow("frontmatter must be a JSON object");
|
|
111
|
+
});
|
|
112
|
+
test("throws for an array value", () => {
|
|
113
|
+
expect(() => parseFrontmatter([1, 2, 3])).toThrow("frontmatter must be a JSON object");
|
|
114
|
+
});
|
|
115
|
+
test("throws for a number value", () => {
|
|
116
|
+
expect(() => parseFrontmatter(42)).toThrow("frontmatter must be a JSON object");
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { test, expect, beforeEach, afterEach, describe } from "vitest";
|
|
2
|
+
import { FileSystemService } from "./filesystem.js";
|
|
3
|
+
import { FrontmatterHandler } from "./frontmatter.js";
|
|
4
|
+
import { PathFilter } from "./pathfilter.js";
|
|
5
|
+
import { SearchService } from "./search.js";
|
|
6
|
+
import { writeFile, mkdir, mkdtemp, rm } from "fs/promises";
|
|
7
|
+
import { join } from "path";
|
|
8
|
+
import { tmpdir } from "os";
|
|
9
|
+
let testVaultPath;
|
|
10
|
+
let pathFilter;
|
|
11
|
+
let frontmatterHandler;
|
|
12
|
+
let fileSystem;
|
|
13
|
+
let searchService;
|
|
14
|
+
beforeEach(async () => {
|
|
15
|
+
testVaultPath = await mkdtemp(join(tmpdir(), "mcpvault-integration-"));
|
|
16
|
+
// Initialize services (same as server.ts)
|
|
17
|
+
pathFilter = new PathFilter();
|
|
18
|
+
frontmatterHandler = new FrontmatterHandler();
|
|
19
|
+
fileSystem = new FileSystemService(testVaultPath, pathFilter, frontmatterHandler);
|
|
20
|
+
searchService = new SearchService(testVaultPath, pathFilter);
|
|
21
|
+
});
|
|
22
|
+
afterEach(async () => {
|
|
23
|
+
try {
|
|
24
|
+
await rm(testVaultPath, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
// Ignore cleanup errors
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
// ============================================================================
|
|
31
|
+
// INTEGRATION TESTS - END-TO-END WORKFLOW
|
|
32
|
+
// ============================================================================
|
|
33
|
+
describe("Integration: Service Layer Workflows", () => {
|
|
34
|
+
test("write, read, and delete note workflow", async () => {
|
|
35
|
+
// 1. Write a note with frontmatter
|
|
36
|
+
await fileSystem.writeNote({
|
|
37
|
+
path: "test-note.md",
|
|
38
|
+
content: "# Test Note\n\nThis is a test.",
|
|
39
|
+
frontmatter: { tags: ["test"], status: "draft" }
|
|
40
|
+
});
|
|
41
|
+
// 2. Read the note back
|
|
42
|
+
const note = await fileSystem.readNote("test-note.md");
|
|
43
|
+
expect(note.content).toContain("This is a test");
|
|
44
|
+
expect(note.frontmatter?.tags).toEqual(["test"]);
|
|
45
|
+
expect(note.frontmatter?.status).toBe("draft");
|
|
46
|
+
// 3. Delete the note
|
|
47
|
+
const deleteResult = await fileSystem.deleteNote({
|
|
48
|
+
path: "test-note.md",
|
|
49
|
+
confirmPath: "test-note.md"
|
|
50
|
+
});
|
|
51
|
+
expect(deleteResult.success).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
test("search notes with special characters in filenames", async () => {
|
|
54
|
+
// Create notes with special characters in paths
|
|
55
|
+
const testCases = [
|
|
56
|
+
{ path: "folder (archive)/note [old].md", content: "# Old Note\n\nArchived keyword." },
|
|
57
|
+
{ path: "C++/notes.md", content: "# C++ Notes\n\nProgramming keyword." },
|
|
58
|
+
{ path: "backup.2024/important.md", content: "# Important\n\nBackup keyword." },
|
|
59
|
+
{ path: "price$100.md", content: "# Pricing\n\nCost keyword." }
|
|
60
|
+
];
|
|
61
|
+
// Write all test notes
|
|
62
|
+
for (const { path, content } of testCases) {
|
|
63
|
+
if (path.includes('/')) {
|
|
64
|
+
const dirName = path.split('/')[0];
|
|
65
|
+
if (dirName) {
|
|
66
|
+
await mkdir(join(testVaultPath, dirName), { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
await writeFile(join(testVaultPath, path), content);
|
|
70
|
+
}
|
|
71
|
+
// Search for keyword
|
|
72
|
+
const results = await searchService.search({
|
|
73
|
+
query: "keyword",
|
|
74
|
+
limit: 10
|
|
75
|
+
});
|
|
76
|
+
expect(results.length).toBe(4);
|
|
77
|
+
// Verify paths with special characters are returned correctly
|
|
78
|
+
const paths = results.map((r) => r.p);
|
|
79
|
+
expect(paths).toContain("folder (archive)/note [old].md");
|
|
80
|
+
expect(paths).toContain("C++/notes.md");
|
|
81
|
+
});
|
|
82
|
+
test("write note with regex special chars in content", async () => {
|
|
83
|
+
const content = `# Price List
|
|
84
|
+
|
|
85
|
+
Item: Widget ($10.50)
|
|
86
|
+
Regex: [a-z]+ matches lowercase
|
|
87
|
+
Math: 2 + 2 = 4
|
|
88
|
+
Pattern: backup.2024/**/*.md`;
|
|
89
|
+
await fileSystem.writeNote({
|
|
90
|
+
path: "special-chars.md",
|
|
91
|
+
content
|
|
92
|
+
});
|
|
93
|
+
// Read back and verify exact content
|
|
94
|
+
const note = await fileSystem.readNote("special-chars.md");
|
|
95
|
+
expect(note.content).toContain("($10.50)");
|
|
96
|
+
expect(note.content).toContain("[a-z]+");
|
|
97
|
+
expect(note.content).toContain("2 + 2 = 4");
|
|
98
|
+
expect(note.content).toContain("backup.2024/**/*.md");
|
|
99
|
+
});
|
|
100
|
+
test("search matches note filename even without content match", async () => {
|
|
101
|
+
// Issue #30: notes without a heading that rely on filename for discovery
|
|
102
|
+
await fileSystem.writeNote({
|
|
103
|
+
path: "Yard.md",
|
|
104
|
+
content: "Some info about lawn care and gardening tips."
|
|
105
|
+
});
|
|
106
|
+
await fileSystem.writeNote({
|
|
107
|
+
path: "Kitchen.md",
|
|
108
|
+
content: "Recipes and kitchen organization."
|
|
109
|
+
});
|
|
110
|
+
// Search for "yard" — should match Yard.md by filename
|
|
111
|
+
const results = await searchService.search({
|
|
112
|
+
query: "yard",
|
|
113
|
+
searchContent: true,
|
|
114
|
+
limit: 10
|
|
115
|
+
});
|
|
116
|
+
expect(results.length).toBeGreaterThanOrEqual(1);
|
|
117
|
+
expect(results.some(r => r.p === "Yard.md")).toBe(true);
|
|
118
|
+
// Verify filename-only match has reasonable fields
|
|
119
|
+
const yardResult = results.find(r => r.p === "Yard.md");
|
|
120
|
+
expect(yardResult.t).toBe("Yard");
|
|
121
|
+
expect(yardResult.mc).toBeGreaterThanOrEqual(1);
|
|
122
|
+
// Search for "kitchen" — should match Kitchen.md by filename
|
|
123
|
+
const kitchenResults = await searchService.search({
|
|
124
|
+
query: "kitchen",
|
|
125
|
+
searchContent: true,
|
|
126
|
+
limit: 10
|
|
127
|
+
});
|
|
128
|
+
// Should match both filename AND content (content contains "kitchen")
|
|
129
|
+
expect(kitchenResults.some(r => r.p === "Kitchen.md")).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
test("multi-step workflow: search, read multiple, update frontmatter", async () => {
|
|
132
|
+
// Create several notes
|
|
133
|
+
for (let i = 1; i <= 3; i++) {
|
|
134
|
+
await fileSystem.writeNote({
|
|
135
|
+
path: `note-${i}.md`,
|
|
136
|
+
content: `# Note ${i}\n\nThis contains searchterm.`,
|
|
137
|
+
frontmatter: { id: i, processed: false }
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
// Search for notes
|
|
141
|
+
const searchResults = await searchService.search({
|
|
142
|
+
query: "searchterm",
|
|
143
|
+
limit: 10
|
|
144
|
+
});
|
|
145
|
+
expect(searchResults.length).toBe(3);
|
|
146
|
+
// Read multiple notes
|
|
147
|
+
const paths = searchResults.map(r => r.p);
|
|
148
|
+
const readResult = await fileSystem.readMultipleNotes({
|
|
149
|
+
paths,
|
|
150
|
+
includeContent: true,
|
|
151
|
+
includeFrontmatter: true
|
|
152
|
+
});
|
|
153
|
+
expect(readResult.successful.length).toBe(3);
|
|
154
|
+
// Update frontmatter on all notes
|
|
155
|
+
for (const path of paths) {
|
|
156
|
+
await fileSystem.updateFrontmatter({
|
|
157
|
+
path,
|
|
158
|
+
frontmatter: { processed: true },
|
|
159
|
+
merge: true
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
// Verify updates
|
|
163
|
+
for (const path of paths) {
|
|
164
|
+
const note = await fileSystem.readNote(path);
|
|
165
|
+
expect(note.frontmatter?.processed).toBe(true);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export class PathFilter {
|
|
2
|
+
ignoredPatterns;
|
|
3
|
+
allowedExtensions;
|
|
4
|
+
constructor(config) {
|
|
5
|
+
this.ignoredPatterns = [
|
|
6
|
+
'.obsidian',
|
|
7
|
+
'.obsidian/**',
|
|
8
|
+
'.git',
|
|
9
|
+
'.git/**',
|
|
10
|
+
'node_modules',
|
|
11
|
+
'node_modules/**',
|
|
12
|
+
'.DS_Store',
|
|
13
|
+
'Thumbs.db',
|
|
14
|
+
...config?.ignoredPatterns || []
|
|
15
|
+
];
|
|
16
|
+
this.allowedExtensions = [
|
|
17
|
+
'.md',
|
|
18
|
+
'.markdown',
|
|
19
|
+
'.txt',
|
|
20
|
+
'.base', // Obsidian Bases (YAML)
|
|
21
|
+
'.canvas', // Obsidian Canvas (JSON)
|
|
22
|
+
...config?.allowedExtensions || []
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
simpleGlobMatch(pattern, path) {
|
|
26
|
+
// Normalize pattern path separators (Windows compatibility)
|
|
27
|
+
const normalizedPattern = pattern.replace(/\\/g, '/');
|
|
28
|
+
// Convert glob pattern to regex, escaping special regex chars first
|
|
29
|
+
let regexPattern = normalizedPattern
|
|
30
|
+
.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&') // Escape all regex special chars
|
|
31
|
+
.replace(/\\\*\\\*/g, '.*') // ** matches any number of directories (unescape)
|
|
32
|
+
.replace(/\\\*/g, '[^/]*') // * matches anything except / (unescape)
|
|
33
|
+
.replace(/\\\?/g, '[^/]'); // ? matches single character except / (unescape)
|
|
34
|
+
// Ensure we match the full path
|
|
35
|
+
regexPattern = '^' + regexPattern + '$';
|
|
36
|
+
const regex = new RegExp(regexPattern);
|
|
37
|
+
return regex.test(path);
|
|
38
|
+
}
|
|
39
|
+
isAllowed(path) {
|
|
40
|
+
// Normalize path separators
|
|
41
|
+
const normalizedPath = path.replace(/\\/g, '/');
|
|
42
|
+
if (this.isIgnoredPath(normalizedPath)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
// For files, check extension if allowedExtensions is configured
|
|
46
|
+
if (this.allowedExtensions.length > 0 && this.isFile(normalizedPath)) {
|
|
47
|
+
const hasAllowedExtension = this.allowedExtensions.some(ext => normalizedPath.toLowerCase().endsWith(ext.toLowerCase()));
|
|
48
|
+
if (!hasAllowedExtension) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
isAllowedForListing(path) {
|
|
55
|
+
// Normalize path separators
|
|
56
|
+
const normalizedPath = path.replace(/\\/g, '/');
|
|
57
|
+
// Listing includes non-note files, but still blocks restricted system paths
|
|
58
|
+
return !this.isIgnoredPath(normalizedPath);
|
|
59
|
+
}
|
|
60
|
+
isIgnoredPath(normalizedPath) {
|
|
61
|
+
// Check if path matches any ignored pattern
|
|
62
|
+
for (const pattern of this.ignoredPatterns) {
|
|
63
|
+
if (this.simpleGlobMatch(pattern, normalizedPath)) {
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
isFile(path) {
|
|
70
|
+
// A path is a file if it has a file extension at the end
|
|
71
|
+
// Paths ending with '/' are always directories
|
|
72
|
+
if (path.endsWith('/')) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
// Get the last component of the path
|
|
76
|
+
const lastSlashIndex = path.lastIndexOf('/');
|
|
77
|
+
const lastComponent = lastSlashIndex === -1 ? path : path.substring(lastSlashIndex + 1);
|
|
78
|
+
// Check if the last component has a file extension
|
|
79
|
+
// A file extension is a dot followed by 1-10 alphanumeric characters at the end
|
|
80
|
+
// This distinguishes "file.md" (file) from "1. Project" (directory with dot in name)
|
|
81
|
+
const lastDotIndex = lastComponent.lastIndexOf('.');
|
|
82
|
+
if (lastDotIndex === -1 || lastDotIndex === 0) {
|
|
83
|
+
// No dot, or dot at the start (like .gitignore) - treat as no extension
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
const extension = lastComponent.substring(lastDotIndex + 1);
|
|
87
|
+
// Extension should be 1-10 characters and contain only alphanumeric characters
|
|
88
|
+
// This allows .md, .txt, .markdown but not ". Project" (space after dot)
|
|
89
|
+
return extension.length >= 1 && extension.length <= 10 && /^[a-zA-Z0-9]+$/.test(extension);
|
|
90
|
+
}
|
|
91
|
+
filterPaths(paths) {
|
|
92
|
+
return paths.filter(path => this.isAllowed(path));
|
|
93
|
+
}
|
|
94
|
+
}
|