obsidian-mcp-server 1.2.4 → 1.2.6
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/build/obsidian.js +54 -66
- package/build/resources.js +71 -29
- package/build/tools.js +2 -1
- package/build/types.js +1 -1
- package/package.json +1 -1
- package/src/obsidian.ts +92 -85
- package/src/resources.ts +81 -34
- package/src/tools.ts +2 -1
- package/src/types.ts +2 -1
package/build/obsidian.js
CHANGED
|
@@ -23,12 +23,17 @@ export class ObsidianClient {
|
|
|
23
23
|
config;
|
|
24
24
|
constructor(config) {
|
|
25
25
|
if (!config.apiKey) {
|
|
26
|
-
throw new ObsidianError("API key
|
|
26
|
+
throw new ObsidianError("Missing API key. To fix this:\n" +
|
|
27
|
+
"1. Install the 'Local REST API' plugin in Obsidian\n" +
|
|
28
|
+
"2. Enable the plugin in Obsidian Settings\n" +
|
|
29
|
+
"3. Copy your API key from Obsidian Settings > Local REST API\n" +
|
|
30
|
+
"4. Provide the API key in your configuration", 40100 // Unauthorized
|
|
31
|
+
);
|
|
27
32
|
}
|
|
28
33
|
// Combine defaults with provided config
|
|
29
34
|
this.config = {
|
|
30
35
|
...DEFAULT_OBSIDIAN_CONFIG,
|
|
31
|
-
verifySSL: config.verifySSL ??
|
|
36
|
+
verifySSL: config.verifySSL ?? true, // Default to true as required by Obsidian REST API plugin
|
|
32
37
|
apiKey: config.apiKey,
|
|
33
38
|
timeout: config.timeout ?? 5000, // 5 second default timeout
|
|
34
39
|
maxContentLength: config.maxContentLength ?? 50 * 1024 * 1024, // 50MB
|
|
@@ -61,7 +66,9 @@ export class ObsidianClient {
|
|
|
61
66
|
decompress: true
|
|
62
67
|
};
|
|
63
68
|
if (!this.config.verifySSL) {
|
|
64
|
-
console.warn("WARNING: SSL verification is disabled.
|
|
69
|
+
console.warn("WARNING: SSL verification is disabled. The Obsidian REST API plugin requires HTTPS by default.\n" +
|
|
70
|
+
"Make sure you have configured the certificate as a trusted certificate authority.\n" +
|
|
71
|
+
"See Obsidian Settings > Local REST API > 'How to Access' for setup instructions.");
|
|
65
72
|
}
|
|
66
73
|
this.client = axios.create(axiosConfig);
|
|
67
74
|
}
|
|
@@ -88,17 +95,15 @@ export class ObsidianClient {
|
|
|
88
95
|
// Prevent path traversal attacks
|
|
89
96
|
const normalizedPath = filepath.replace(/\\/g, '/');
|
|
90
97
|
if (normalizedPath.includes('../') || normalizedPath.includes('..\\')) {
|
|
91
|
-
throw new ObsidianError('Invalid file path: Path traversal not allowed', 40001);
|
|
98
|
+
throw new ObsidianError('Invalid file path: Path traversal not allowed', 40001);
|
|
92
99
|
}
|
|
93
100
|
// Additional path validations
|
|
94
101
|
if (normalizedPath.startsWith('/') || /^[a-zA-Z]:/.test(normalizedPath)) {
|
|
95
|
-
throw new ObsidianError('Invalid file path: Absolute paths not allowed', 40002);
|
|
102
|
+
throw new ObsidianError('Invalid file path: Absolute paths not allowed', 40002);
|
|
96
103
|
}
|
|
97
104
|
}
|
|
98
105
|
getErrorCode(status) {
|
|
99
|
-
// Convert HTTP status codes to 5-digit error codes
|
|
100
106
|
switch (status) {
|
|
101
|
-
// Client errors (400-499)
|
|
102
107
|
case 400: return 40000; // Bad request
|
|
103
108
|
case 401: return 40100; // Unauthorized
|
|
104
109
|
case 403: return 40300; // Forbidden
|
|
@@ -106,19 +111,17 @@ export class ObsidianClient {
|
|
|
106
111
|
case 405: return 40500; // Method not allowed
|
|
107
112
|
case 409: return 40900; // Conflict
|
|
108
113
|
case 429: return 42900; // Too many requests
|
|
109
|
-
// Server errors (500-599)
|
|
110
114
|
case 500: return 50000; // Internal server error
|
|
111
115
|
case 501: return 50100; // Not implemented
|
|
112
116
|
case 502: return 50200; // Bad gateway
|
|
113
117
|
case 503: return 50300; // Service unavailable
|
|
114
118
|
case 504: return 50400; // Gateway timeout
|
|
115
|
-
// Default cases
|
|
116
119
|
default:
|
|
117
120
|
if (status >= 400 && status < 500)
|
|
118
121
|
return 40000 + (status - 400) * 100;
|
|
119
122
|
if (status >= 500 && status < 600)
|
|
120
123
|
return 50000 + (status - 500) * 100;
|
|
121
|
-
return 50000;
|
|
124
|
+
return 50000;
|
|
122
125
|
}
|
|
123
126
|
}
|
|
124
127
|
async safeRequest(operation) {
|
|
@@ -130,16 +133,45 @@ export class ObsidianClient {
|
|
|
130
133
|
const axiosError = error;
|
|
131
134
|
const response = axiosError.response;
|
|
132
135
|
const errorData = response?.data;
|
|
133
|
-
//
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
136
|
+
// Handle common connection errors with helpful messages
|
|
137
|
+
if (error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
|
|
138
|
+
throw new ObsidianError(`SSL certificate verification failed. You have two options:\n\n` +
|
|
139
|
+
`Option 1 - Enable HTTP (not recommended for production):\n` +
|
|
140
|
+
`1. Go to Obsidian Settings > Local REST API\n` +
|
|
141
|
+
`2. Enable "Enable Non-encrypted (HTTP) Server"\n` +
|
|
142
|
+
`3. Update your client config to use "http" protocol\n\n` +
|
|
143
|
+
`Option 2 - Configure HTTPS (recommended):\n` +
|
|
144
|
+
`1. Go to Obsidian Settings > Local REST API\n` +
|
|
145
|
+
`2. Under 'How to Access', copy the certificate\n` +
|
|
146
|
+
`3. Add the certificate to your system's trusted certificates:\n` +
|
|
147
|
+
` - On macOS: Add to Keychain Access\n` +
|
|
148
|
+
` - On Windows: Add to Certificate Manager\n` +
|
|
149
|
+
` - On Linux: Add to ca-certificates\n` +
|
|
150
|
+
` For development only: Set verifySSL: false in client config\n\n` +
|
|
151
|
+
`Original error: ${error.message}`, 50001, // SSL error code
|
|
152
|
+
{ code: error.code, config: { verifySSL: this.config.verifySSL } });
|
|
153
|
+
}
|
|
154
|
+
if (error.code === 'ECONNREFUSED') {
|
|
155
|
+
throw new ObsidianError(`Connection refused. To fix this:\n` +
|
|
156
|
+
`1. Ensure Obsidian is running\n` +
|
|
157
|
+
`2. Verify the 'Local REST API' plugin is enabled in Obsidian Settings\n` +
|
|
158
|
+
`3. Check that you're using the correct host (${this.config.host}) and port (${this.config.port})\n` +
|
|
159
|
+
`4. Make sure HTTPS is enabled in the plugin settings`, 50002, // Connection refused
|
|
160
|
+
{ code: error.code });
|
|
161
|
+
}
|
|
162
|
+
if (response?.status === 401) {
|
|
163
|
+
throw new ObsidianError(`Authentication failed. To fix this:\n` +
|
|
164
|
+
`1. Go to Obsidian Settings > Local REST API\n` +
|
|
165
|
+
`2. Copy your API key from the settings\n` +
|
|
166
|
+
`3. Update your configuration with the new API key\n` +
|
|
167
|
+
`Note: The API key changes when you regenerate certificates`, 40100, // Unauthorized
|
|
168
|
+
{ code: error.code });
|
|
169
|
+
}
|
|
170
|
+
// For other errors, use API error code if available
|
|
171
|
+
const errorCode = errorData?.errorCode ?? this.getErrorCode(response?.status ?? 500);
|
|
172
|
+
const message = errorData?.message ?? axiosError.message ?? "Unknown error";
|
|
140
173
|
throw new ObsidianError(message, errorCode, errorData);
|
|
141
174
|
}
|
|
142
|
-
// For non-Axios errors, use a generic server error code
|
|
143
175
|
if (error instanceof Error) {
|
|
144
176
|
throw new ObsidianError(error.message, 50000, error);
|
|
145
177
|
}
|
|
@@ -148,8 +180,6 @@ export class ObsidianClient {
|
|
|
148
180
|
}
|
|
149
181
|
async listFilesInVault() {
|
|
150
182
|
return this.safeRequest(async () => {
|
|
151
|
-
const requestId = crypto.randomUUID();
|
|
152
|
-
console.debug(`[${requestId}] Listing vault files`);
|
|
153
183
|
const response = await this.client.get("/vault/");
|
|
154
184
|
return response.data.files;
|
|
155
185
|
});
|
|
@@ -157,8 +187,6 @@ export class ObsidianClient {
|
|
|
157
187
|
async listFilesInDir(dirpath) {
|
|
158
188
|
this.validateFilePath(dirpath);
|
|
159
189
|
return this.safeRequest(async () => {
|
|
160
|
-
const requestId = crypto.randomUUID();
|
|
161
|
-
console.debug(`[${requestId}] Listing files in directory: ${dirpath}`);
|
|
162
190
|
const response = await this.client.get(`/vault/${dirpath}/`);
|
|
163
191
|
return response.data.files;
|
|
164
192
|
});
|
|
@@ -166,30 +194,22 @@ export class ObsidianClient {
|
|
|
166
194
|
async getFileContents(filepath) {
|
|
167
195
|
this.validateFilePath(filepath);
|
|
168
196
|
return this.safeRequest(async () => {
|
|
169
|
-
const requestId = crypto.randomUUID();
|
|
170
|
-
console.debug(`[${requestId}] Getting file contents: ${filepath}`);
|
|
171
197
|
const response = await this.client.get(`/vault/${filepath}`);
|
|
172
198
|
return response.data;
|
|
173
199
|
});
|
|
174
200
|
}
|
|
175
201
|
async search(query, contextLength = 100) {
|
|
176
202
|
return this.safeRequest(async () => {
|
|
177
|
-
const
|
|
178
|
-
console.debug(`[${requestId}] Performing simple search: ${query}`);
|
|
179
|
-
const response = await this.client.post("/search/simple/", null, {
|
|
180
|
-
params: { query, contextLength }
|
|
181
|
-
});
|
|
203
|
+
const response = await this.client.post("/search/simple/", null, { params: { query, contextLength } });
|
|
182
204
|
return response.data;
|
|
183
205
|
});
|
|
184
206
|
}
|
|
185
207
|
async appendContent(filepath, content) {
|
|
186
208
|
this.validateFilePath(filepath);
|
|
187
209
|
if (!content || typeof content !== 'string') {
|
|
188
|
-
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
210
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
189
211
|
}
|
|
190
212
|
return this.safeRequest(async () => {
|
|
191
|
-
const requestId = crypto.randomUUID();
|
|
192
|
-
console.debug(`[${requestId}] Appending content to: ${filepath}`);
|
|
193
213
|
await this.client.post(`/vault/${filepath}`, content, {
|
|
194
214
|
headers: {
|
|
195
215
|
"Content-Type": "text/markdown"
|
|
@@ -200,11 +220,9 @@ export class ObsidianClient {
|
|
|
200
220
|
async updateContent(filepath, content) {
|
|
201
221
|
this.validateFilePath(filepath);
|
|
202
222
|
if (!content || typeof content !== 'string') {
|
|
203
|
-
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
223
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
204
224
|
}
|
|
205
225
|
return this.safeRequest(async () => {
|
|
206
|
-
const requestId = crypto.randomUUID();
|
|
207
|
-
console.debug(`[${requestId}] Updating content in: ${filepath}`);
|
|
208
226
|
await this.client.put(`/vault/${filepath}`, content, {
|
|
209
227
|
headers: {
|
|
210
228
|
"Content-Type": "text/markdown"
|
|
@@ -214,9 +232,6 @@ export class ObsidianClient {
|
|
|
214
232
|
}
|
|
215
233
|
async searchJson(query) {
|
|
216
234
|
return this.safeRequest(async () => {
|
|
217
|
-
const requestId = crypto.randomUUID();
|
|
218
|
-
console.debug(`[${requestId}] Performing complex search with query:`, JSON.stringify(query));
|
|
219
|
-
// Check if this is a tag-based search
|
|
220
235
|
const isTagSearch = JSON.stringify(query).includes('"contains"') &&
|
|
221
236
|
JSON.stringify(query).includes('"#"');
|
|
222
237
|
const response = await this.client.post("/search/", query, {
|
|
@@ -225,40 +240,29 @@ export class ObsidianClient {
|
|
|
225
240
|
"Accept": "application/vnd.olrapi.note+json"
|
|
226
241
|
}
|
|
227
242
|
});
|
|
228
|
-
|
|
229
|
-
return response.data;
|
|
230
|
-
}
|
|
231
|
-
return response.data;
|
|
243
|
+
return isTagSearch ? response.data : response.data;
|
|
232
244
|
});
|
|
233
245
|
}
|
|
234
246
|
async getStatus() {
|
|
235
247
|
return this.safeRequest(async () => {
|
|
236
|
-
const requestId = crypto.randomUUID();
|
|
237
|
-
console.debug(`[${requestId}] Getting server status`);
|
|
238
248
|
const response = await this.client.get("/");
|
|
239
249
|
return response.data;
|
|
240
250
|
});
|
|
241
251
|
}
|
|
242
252
|
async listCommands() {
|
|
243
253
|
return this.safeRequest(async () => {
|
|
244
|
-
const requestId = crypto.randomUUID();
|
|
245
|
-
console.debug(`[${requestId}] Listing available commands`);
|
|
246
254
|
const response = await this.client.get("/commands/");
|
|
247
255
|
return response.data.commands;
|
|
248
256
|
});
|
|
249
257
|
}
|
|
250
258
|
async executeCommand(commandId) {
|
|
251
259
|
return this.safeRequest(async () => {
|
|
252
|
-
const requestId = crypto.randomUUID();
|
|
253
|
-
console.debug(`[${requestId}] Executing command: ${commandId}`);
|
|
254
260
|
await this.client.post(`/commands/${commandId}/`);
|
|
255
261
|
});
|
|
256
262
|
}
|
|
257
263
|
async openFile(filepath, newLeaf = false) {
|
|
258
264
|
this.validateFilePath(filepath);
|
|
259
265
|
return this.safeRequest(async () => {
|
|
260
|
-
const requestId = crypto.randomUUID();
|
|
261
|
-
console.debug(`[${requestId}] Opening file: ${filepath}`);
|
|
262
266
|
await this.client.post(`/open/${filepath}`, null, {
|
|
263
267
|
params: { newLeaf }
|
|
264
268
|
});
|
|
@@ -266,8 +270,6 @@ export class ObsidianClient {
|
|
|
266
270
|
}
|
|
267
271
|
async getActiveFile() {
|
|
268
272
|
return this.safeRequest(async () => {
|
|
269
|
-
const requestId = crypto.randomUUID();
|
|
270
|
-
console.debug(`[${requestId}] Getting active file`);
|
|
271
273
|
const response = await this.client.get("/active/", {
|
|
272
274
|
headers: {
|
|
273
275
|
"Accept": "application/vnd.olrapi.note+json"
|
|
@@ -278,8 +280,6 @@ export class ObsidianClient {
|
|
|
278
280
|
}
|
|
279
281
|
async updateActiveFile(content) {
|
|
280
282
|
return this.safeRequest(async () => {
|
|
281
|
-
const requestId = crypto.randomUUID();
|
|
282
|
-
console.debug(`[${requestId}] Updating active file`);
|
|
283
283
|
await this.client.put("/active/", content, {
|
|
284
284
|
headers: {
|
|
285
285
|
"Content-Type": "text/markdown"
|
|
@@ -289,15 +289,11 @@ export class ObsidianClient {
|
|
|
289
289
|
}
|
|
290
290
|
async deleteActiveFile() {
|
|
291
291
|
return this.safeRequest(async () => {
|
|
292
|
-
const requestId = crypto.randomUUID();
|
|
293
|
-
console.debug(`[${requestId}] Deleting active file`);
|
|
294
292
|
await this.client.delete("/active/");
|
|
295
293
|
});
|
|
296
294
|
}
|
|
297
295
|
async patchActiveFile(operation, targetType, target, content, options) {
|
|
298
296
|
return this.safeRequest(async () => {
|
|
299
|
-
const requestId = crypto.randomUUID();
|
|
300
|
-
console.debug(`[${requestId}] Patching active file: ${operation} ${targetType} ${target}`);
|
|
301
297
|
const headers = {
|
|
302
298
|
"Operation": operation,
|
|
303
299
|
"Target-Type": targetType,
|
|
@@ -315,8 +311,6 @@ export class ObsidianClient {
|
|
|
315
311
|
}
|
|
316
312
|
async getPeriodicNote(period) {
|
|
317
313
|
return this.safeRequest(async () => {
|
|
318
|
-
const requestId = crypto.randomUUID();
|
|
319
|
-
console.debug(`[${requestId}] Getting ${period} note`);
|
|
320
314
|
const response = await this.client.get(`/periodic/${period}/`, {
|
|
321
315
|
headers: {
|
|
322
316
|
"Accept": "application/vnd.olrapi.note+json"
|
|
@@ -327,8 +321,6 @@ export class ObsidianClient {
|
|
|
327
321
|
}
|
|
328
322
|
async updatePeriodicNote(period, content) {
|
|
329
323
|
return this.safeRequest(async () => {
|
|
330
|
-
const requestId = crypto.randomUUID();
|
|
331
|
-
console.debug(`[${requestId}] Updating ${period} note`);
|
|
332
324
|
await this.client.put(`/periodic/${period}/`, content, {
|
|
333
325
|
headers: {
|
|
334
326
|
"Content-Type": "text/markdown"
|
|
@@ -338,15 +330,11 @@ export class ObsidianClient {
|
|
|
338
330
|
}
|
|
339
331
|
async deletePeriodicNote(period) {
|
|
340
332
|
return this.safeRequest(async () => {
|
|
341
|
-
const requestId = crypto.randomUUID();
|
|
342
|
-
console.debug(`[${requestId}] Deleting ${period} note`);
|
|
343
333
|
await this.client.delete(`/periodic/${period}/`);
|
|
344
334
|
});
|
|
345
335
|
}
|
|
346
336
|
async patchPeriodicNote(period, operation, targetType, target, content, options) {
|
|
347
337
|
return this.safeRequest(async () => {
|
|
348
|
-
const requestId = crypto.randomUUID();
|
|
349
|
-
console.debug(`[${requestId}] Patching ${period} note: ${operation} ${targetType} ${target}`);
|
|
350
338
|
const headers = {
|
|
351
339
|
"Operation": operation,
|
|
352
340
|
"Target-Type": targetType,
|
package/build/resources.js
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
+
import { PropertyManager } from "./properties.js";
|
|
1
2
|
export class TagResource {
|
|
2
3
|
client;
|
|
3
4
|
static TAG_PATTERN = /#[a-zA-Z0-9_-]+/g;
|
|
5
|
+
tagCache = new Map();
|
|
6
|
+
propertyManager;
|
|
7
|
+
isInitialized = false;
|
|
8
|
+
lastUpdate = 0;
|
|
9
|
+
updateInterval = 5000; // 5 seconds
|
|
4
10
|
constructor(client) {
|
|
5
11
|
this.client = client;
|
|
12
|
+
this.propertyManager = new PropertyManager(client);
|
|
13
|
+
this.initializeCache();
|
|
6
14
|
}
|
|
7
15
|
getResourceDescription() {
|
|
8
16
|
return {
|
|
@@ -12,37 +20,67 @@ export class TagResource {
|
|
|
12
20
|
mimeType: "application/json"
|
|
13
21
|
};
|
|
14
22
|
}
|
|
15
|
-
async
|
|
23
|
+
async initializeCache() {
|
|
16
24
|
try {
|
|
17
|
-
//
|
|
25
|
+
// Get all markdown files
|
|
18
26
|
const query = {
|
|
19
|
-
"
|
|
27
|
+
"glob": ["**/*.md", { "var": "path" }]
|
|
20
28
|
};
|
|
21
29
|
const results = await this.client.searchJson(query);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
30
|
+
this.tagCache.clear();
|
|
31
|
+
// Process each file
|
|
32
|
+
for (const result of results) {
|
|
33
|
+
if (!('filename' in result))
|
|
34
|
+
continue;
|
|
35
|
+
try {
|
|
36
|
+
const content = await this.client.getFileContents(result.filename);
|
|
37
|
+
// Extract tags from frontmatter
|
|
38
|
+
const properties = this.propertyManager.parseProperties(content);
|
|
39
|
+
if (properties.tags) {
|
|
40
|
+
properties.tags.forEach((tag) => {
|
|
41
|
+
this.addTag(tag, result.filename);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
// Extract inline tags
|
|
45
|
+
const inlineTags = content.match(TagResource.TAG_PATTERN) || [];
|
|
46
|
+
inlineTags.forEach(tag => {
|
|
47
|
+
this.addTag(tag, result.filename);
|
|
40
48
|
});
|
|
41
49
|
}
|
|
42
|
-
|
|
43
|
-
|
|
50
|
+
catch (error) {
|
|
51
|
+
console.error(`Failed to process file ${result.filename}:`, error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
this.isInitialized = true;
|
|
55
|
+
this.lastUpdate = Date.now();
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
console.error("Failed to initialize tag cache:", error);
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
addTag(tag, filepath) {
|
|
63
|
+
if (!this.tagCache.has(tag)) {
|
|
64
|
+
this.tagCache.set(tag, new Set());
|
|
65
|
+
}
|
|
66
|
+
this.tagCache.get(tag).add(filepath);
|
|
67
|
+
}
|
|
68
|
+
async updateCacheIfNeeded() {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
if (now - this.lastUpdate > this.updateInterval) {
|
|
71
|
+
await this.initializeCache();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
async getContent() {
|
|
75
|
+
try {
|
|
76
|
+
if (!this.isInitialized) {
|
|
77
|
+
await this.initializeCache();
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
await this.updateCacheIfNeeded();
|
|
81
|
+
}
|
|
44
82
|
const response = {
|
|
45
|
-
tags: Array.from(
|
|
83
|
+
tags: Array.from(this.tagCache.entries())
|
|
46
84
|
.map(([name, files]) => ({
|
|
47
85
|
name,
|
|
48
86
|
count: files.size,
|
|
@@ -50,18 +88,22 @@ export class TagResource {
|
|
|
50
88
|
}))
|
|
51
89
|
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)),
|
|
52
90
|
metadata: {
|
|
53
|
-
totalOccurrences
|
|
54
|
-
|
|
55
|
-
|
|
91
|
+
totalOccurrences: Array.from(this.tagCache.values())
|
|
92
|
+
.reduce((sum, files) => sum + files.size, 0),
|
|
93
|
+
uniqueTags: this.tagCache.size,
|
|
94
|
+
scannedFiles: new Set(Array.from(this.tagCache.values())
|
|
95
|
+
.flatMap(files => Array.from(files))).size,
|
|
96
|
+
lastUpdate: this.lastUpdate
|
|
56
97
|
}
|
|
57
98
|
};
|
|
58
99
|
return [{
|
|
59
100
|
type: "text",
|
|
60
|
-
text: JSON.stringify(response, null, 2)
|
|
101
|
+
text: JSON.stringify(response, null, 2),
|
|
102
|
+
uri: this.getResourceDescription().uri
|
|
61
103
|
}];
|
|
62
104
|
}
|
|
63
105
|
catch (error) {
|
|
64
|
-
console.error("Failed to
|
|
106
|
+
console.error("Failed to get tags:", error);
|
|
65
107
|
throw error;
|
|
66
108
|
}
|
|
67
109
|
}
|
package/build/tools.js
CHANGED
package/build/types.js
CHANGED
package/package.json
CHANGED
package/src/obsidian.ts
CHANGED
|
@@ -41,13 +41,20 @@ export class ObsidianClient {
|
|
|
41
41
|
|
|
42
42
|
constructor(config: ObsidianConfig) {
|
|
43
43
|
if (!config.apiKey) {
|
|
44
|
-
throw new ObsidianError(
|
|
44
|
+
throw new ObsidianError(
|
|
45
|
+
"Missing API key. To fix this:\n" +
|
|
46
|
+
"1. Install the 'Local REST API' plugin in Obsidian\n" +
|
|
47
|
+
"2. Enable the plugin in Obsidian Settings\n" +
|
|
48
|
+
"3. Copy your API key from Obsidian Settings > Local REST API\n" +
|
|
49
|
+
"4. Provide the API key in your configuration",
|
|
50
|
+
40100 // Unauthorized
|
|
51
|
+
);
|
|
45
52
|
}
|
|
46
53
|
|
|
47
54
|
// Combine defaults with provided config
|
|
48
55
|
this.config = {
|
|
49
56
|
...DEFAULT_OBSIDIAN_CONFIG,
|
|
50
|
-
verifySSL: config.verifySSL ??
|
|
57
|
+
verifySSL: config.verifySSL ?? true, // Default to true as required by Obsidian REST API plugin
|
|
51
58
|
apiKey: config.apiKey,
|
|
52
59
|
timeout: config.timeout ?? 5000, // 5 second default timeout
|
|
53
60
|
maxContentLength: config.maxContentLength ?? 50 * 1024 * 1024, // 50MB
|
|
@@ -84,8 +91,9 @@ export class ObsidianClient {
|
|
|
84
91
|
|
|
85
92
|
if (!this.config.verifySSL) {
|
|
86
93
|
console.warn(
|
|
87
|
-
"WARNING: SSL verification is disabled.
|
|
88
|
-
|
|
94
|
+
"WARNING: SSL verification is disabled. The Obsidian REST API plugin requires HTTPS by default.\n" +
|
|
95
|
+
"Make sure you have configured the certificate as a trusted certificate authority.\n" +
|
|
96
|
+
"See Obsidian Settings > Local REST API > 'How to Access' for setup instructions."
|
|
89
97
|
);
|
|
90
98
|
}
|
|
91
99
|
|
|
@@ -121,19 +129,17 @@ export class ObsidianClient {
|
|
|
121
129
|
// Prevent path traversal attacks
|
|
122
130
|
const normalizedPath = filepath.replace(/\\/g, '/');
|
|
123
131
|
if (normalizedPath.includes('../') || normalizedPath.includes('..\\')) {
|
|
124
|
-
throw new ObsidianError('Invalid file path: Path traversal not allowed', 40001);
|
|
132
|
+
throw new ObsidianError('Invalid file path: Path traversal not allowed', 40001);
|
|
125
133
|
}
|
|
126
134
|
|
|
127
135
|
// Additional path validations
|
|
128
136
|
if (normalizedPath.startsWith('/') || /^[a-zA-Z]:/.test(normalizedPath)) {
|
|
129
|
-
throw new ObsidianError('Invalid file path: Absolute paths not allowed', 40002);
|
|
137
|
+
throw new ObsidianError('Invalid file path: Absolute paths not allowed', 40002);
|
|
130
138
|
}
|
|
131
139
|
}
|
|
132
140
|
|
|
133
141
|
private getErrorCode(status: number): number {
|
|
134
|
-
// Convert HTTP status codes to 5-digit error codes
|
|
135
142
|
switch (status) {
|
|
136
|
-
// Client errors (400-499)
|
|
137
143
|
case 400: return 40000; // Bad request
|
|
138
144
|
case 401: return 40100; // Unauthorized
|
|
139
145
|
case 403: return 40300; // Forbidden
|
|
@@ -141,19 +147,15 @@ export class ObsidianClient {
|
|
|
141
147
|
case 405: return 40500; // Method not allowed
|
|
142
148
|
case 409: return 40900; // Conflict
|
|
143
149
|
case 429: return 42900; // Too many requests
|
|
144
|
-
|
|
145
|
-
// Server errors (500-599)
|
|
146
150
|
case 500: return 50000; // Internal server error
|
|
147
151
|
case 501: return 50100; // Not implemented
|
|
148
152
|
case 502: return 50200; // Bad gateway
|
|
149
153
|
case 503: return 50300; // Service unavailable
|
|
150
154
|
case 504: return 50400; // Gateway timeout
|
|
151
|
-
|
|
152
|
-
// Default cases
|
|
153
155
|
default:
|
|
154
156
|
if (status >= 400 && status < 500) return 40000 + (status - 400) * 100;
|
|
155
157
|
if (status >= 500 && status < 600) return 50000 + (status - 500) * 100;
|
|
156
|
-
return 50000;
|
|
158
|
+
return 50000;
|
|
157
159
|
}
|
|
158
160
|
}
|
|
159
161
|
|
|
@@ -165,20 +167,59 @@ export class ObsidianClient {
|
|
|
165
167
|
const axiosError = error as AxiosError<ApiError>;
|
|
166
168
|
const response = axiosError.response;
|
|
167
169
|
const errorData = response?.data;
|
|
168
|
-
|
|
169
|
-
//
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
170
|
+
|
|
171
|
+
// Handle common connection errors with helpful messages
|
|
172
|
+
if (error.code === 'DEPTH_ZERO_SELF_SIGNED_CERT' || error.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') {
|
|
173
|
+
throw new ObsidianError(
|
|
174
|
+
`SSL certificate verification failed. You have two options:\n\n` +
|
|
175
|
+
`Option 1 - Enable HTTP (not recommended for production):\n` +
|
|
176
|
+
`1. Go to Obsidian Settings > Local REST API\n` +
|
|
177
|
+
`2. Enable "Enable Non-encrypted (HTTP) Server"\n` +
|
|
178
|
+
`3. Update your client config to use "http" protocol\n\n` +
|
|
179
|
+
`Option 2 - Configure HTTPS (recommended):\n` +
|
|
180
|
+
`1. Go to Obsidian Settings > Local REST API\n` +
|
|
181
|
+
`2. Under 'How to Access', copy the certificate\n` +
|
|
182
|
+
`3. Add the certificate to your system's trusted certificates:\n` +
|
|
183
|
+
` - On macOS: Add to Keychain Access\n` +
|
|
184
|
+
` - On Windows: Add to Certificate Manager\n` +
|
|
185
|
+
` - On Linux: Add to ca-certificates\n` +
|
|
186
|
+
` For development only: Set verifySSL: false in client config\n\n` +
|
|
187
|
+
`Original error: ${error.message}`,
|
|
188
|
+
50001, // SSL error code
|
|
189
|
+
{ code: error.code, config: { verifySSL: this.config.verifySSL } }
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (error.code === 'ECONNREFUSED') {
|
|
194
|
+
throw new ObsidianError(
|
|
195
|
+
`Connection refused. To fix this:\n` +
|
|
196
|
+
`1. Ensure Obsidian is running\n` +
|
|
197
|
+
`2. Verify the 'Local REST API' plugin is enabled in Obsidian Settings\n` +
|
|
198
|
+
`3. Check that you're using the correct host (${this.config.host}) and port (${this.config.port})\n` +
|
|
199
|
+
`4. Make sure HTTPS is enabled in the plugin settings`,
|
|
200
|
+
50002, // Connection refused
|
|
201
|
+
{ code: error.code }
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (response?.status === 401) {
|
|
206
|
+
throw new ObsidianError(
|
|
207
|
+
`Authentication failed. To fix this:\n` +
|
|
208
|
+
`1. Go to Obsidian Settings > Local REST API\n` +
|
|
209
|
+
`2. Copy your API key from the settings\n` +
|
|
210
|
+
`3. Update your configuration with the new API key\n` +
|
|
211
|
+
`Note: The API key changes when you regenerate certificates`,
|
|
212
|
+
40100, // Unauthorized
|
|
213
|
+
{ code: error.code }
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// For other errors, use API error code if available
|
|
218
|
+
const errorCode = errorData?.errorCode ?? this.getErrorCode(response?.status ?? 500);
|
|
219
|
+
const message = errorData?.message ?? axiosError.message ?? "Unknown error";
|
|
178
220
|
throw new ObsidianError(message, errorCode, errorData);
|
|
179
221
|
}
|
|
180
222
|
|
|
181
|
-
// For non-Axios errors, use a generic server error code
|
|
182
223
|
if (error instanceof Error) {
|
|
183
224
|
throw new ObsidianError(error.message, 50000, error);
|
|
184
225
|
}
|
|
@@ -189,8 +230,6 @@ export class ObsidianClient {
|
|
|
189
230
|
|
|
190
231
|
async listFilesInVault(): Promise<ObsidianFile[]> {
|
|
191
232
|
return this.safeRequest(async () => {
|
|
192
|
-
const requestId = crypto.randomUUID();
|
|
193
|
-
console.debug(`[${requestId}] Listing vault files`);
|
|
194
233
|
const response = await this.client.get<{ files: ObsidianFile[] }>("/vault/");
|
|
195
234
|
return response.data.files;
|
|
196
235
|
});
|
|
@@ -199,8 +238,6 @@ export class ObsidianClient {
|
|
|
199
238
|
async listFilesInDir(dirpath: string): Promise<ObsidianFile[]> {
|
|
200
239
|
this.validateFilePath(dirpath);
|
|
201
240
|
return this.safeRequest(async () => {
|
|
202
|
-
const requestId = crypto.randomUUID();
|
|
203
|
-
console.debug(`[${requestId}] Listing files in directory: ${dirpath}`);
|
|
204
241
|
const response = await this.client.get<{ files: ObsidianFile[] }>(`/vault/${dirpath}/`);
|
|
205
242
|
return response.data.files;
|
|
206
243
|
});
|
|
@@ -209,8 +246,6 @@ export class ObsidianClient {
|
|
|
209
246
|
async getFileContents(filepath: string): Promise<string> {
|
|
210
247
|
this.validateFilePath(filepath);
|
|
211
248
|
return this.safeRequest(async () => {
|
|
212
|
-
const requestId = crypto.randomUUID();
|
|
213
|
-
console.debug(`[${requestId}] Getting file contents: ${filepath}`);
|
|
214
249
|
const response = await this.client.get<string>(`/vault/${filepath}`);
|
|
215
250
|
return response.data;
|
|
216
251
|
});
|
|
@@ -218,14 +253,10 @@ export class ObsidianClient {
|
|
|
218
253
|
|
|
219
254
|
async search(query: string, contextLength: number = 100): Promise<SimpleSearchResult[]> {
|
|
220
255
|
return this.safeRequest(async () => {
|
|
221
|
-
const requestId = crypto.randomUUID();
|
|
222
|
-
console.debug(`[${requestId}] Performing simple search: ${query}`);
|
|
223
256
|
const response = await this.client.post<SimpleSearchResult[]>(
|
|
224
257
|
"/search/simple/",
|
|
225
258
|
null,
|
|
226
|
-
{
|
|
227
|
-
params: { query, contextLength }
|
|
228
|
-
}
|
|
259
|
+
{ params: { query, contextLength } }
|
|
229
260
|
);
|
|
230
261
|
return response.data;
|
|
231
262
|
});
|
|
@@ -234,11 +265,9 @@ export class ObsidianClient {
|
|
|
234
265
|
async appendContent(filepath: string, content: string): Promise<void> {
|
|
235
266
|
this.validateFilePath(filepath);
|
|
236
267
|
if (!content || typeof content !== 'string') {
|
|
237
|
-
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
268
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
238
269
|
}
|
|
239
270
|
return this.safeRequest(async () => {
|
|
240
|
-
const requestId = crypto.randomUUID();
|
|
241
|
-
console.debug(`[${requestId}] Appending content to: ${filepath}`);
|
|
242
271
|
await this.client.post(
|
|
243
272
|
`/vault/${filepath}`,
|
|
244
273
|
content,
|
|
@@ -254,12 +283,10 @@ export class ObsidianClient {
|
|
|
254
283
|
async updateContent(filepath: string, content: string): Promise<void> {
|
|
255
284
|
this.validateFilePath(filepath);
|
|
256
285
|
if (!content || typeof content !== 'string') {
|
|
257
|
-
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
286
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
258
287
|
}
|
|
259
288
|
|
|
260
289
|
return this.safeRequest(async () => {
|
|
261
|
-
const requestId = crypto.randomUUID();
|
|
262
|
-
console.debug(`[${requestId}] Updating content in: ${filepath}`);
|
|
263
290
|
await this.client.put(
|
|
264
291
|
`/vault/${filepath}`,
|
|
265
292
|
content,
|
|
@@ -274,10 +301,6 @@ export class ObsidianClient {
|
|
|
274
301
|
|
|
275
302
|
async searchJson(query: JsonLogicQuery): Promise<SearchResponse[]> {
|
|
276
303
|
return this.safeRequest(async () => {
|
|
277
|
-
const requestId = crypto.randomUUID();
|
|
278
|
-
console.debug(`[${requestId}] Performing complex search with query:`, JSON.stringify(query));
|
|
279
|
-
|
|
280
|
-
// Check if this is a tag-based search
|
|
281
304
|
const isTagSearch = JSON.stringify(query).includes('"contains"') &&
|
|
282
305
|
JSON.stringify(query).includes('"#"');
|
|
283
306
|
|
|
@@ -292,17 +315,12 @@ export class ObsidianClient {
|
|
|
292
315
|
}
|
|
293
316
|
);
|
|
294
317
|
|
|
295
|
-
|
|
296
|
-
return response.data as SimpleSearchResult[];
|
|
297
|
-
}
|
|
298
|
-
return response.data as SearchResult[];
|
|
318
|
+
return isTagSearch ? response.data as SimpleSearchResult[] : response.data as SearchResult[];
|
|
299
319
|
});
|
|
300
320
|
}
|
|
301
321
|
|
|
302
322
|
async getStatus(): Promise<ObsidianStatus> {
|
|
303
323
|
return this.safeRequest(async () => {
|
|
304
|
-
const requestId = crypto.randomUUID();
|
|
305
|
-
console.debug(`[${requestId}] Getting server status`);
|
|
306
324
|
const response = await this.client.get<ObsidianStatus>("/");
|
|
307
325
|
return response.data;
|
|
308
326
|
});
|
|
@@ -310,8 +328,6 @@ export class ObsidianClient {
|
|
|
310
328
|
|
|
311
329
|
async listCommands(): Promise<ObsidianCommand[]> {
|
|
312
330
|
return this.safeRequest(async () => {
|
|
313
|
-
const requestId = crypto.randomUUID();
|
|
314
|
-
console.debug(`[${requestId}] Listing available commands`);
|
|
315
331
|
const response = await this.client.get<{commands: ObsidianCommand[]}>("/commands/");
|
|
316
332
|
return response.data.commands;
|
|
317
333
|
});
|
|
@@ -319,8 +335,6 @@ export class ObsidianClient {
|
|
|
319
335
|
|
|
320
336
|
async executeCommand(commandId: string): Promise<void> {
|
|
321
337
|
return this.safeRequest(async () => {
|
|
322
|
-
const requestId = crypto.randomUUID();
|
|
323
|
-
console.debug(`[${requestId}] Executing command: ${commandId}`);
|
|
324
338
|
await this.client.post(`/commands/${commandId}/`);
|
|
325
339
|
});
|
|
326
340
|
}
|
|
@@ -328,8 +342,6 @@ export class ObsidianClient {
|
|
|
328
342
|
async openFile(filepath: string, newLeaf: boolean = false): Promise<void> {
|
|
329
343
|
this.validateFilePath(filepath);
|
|
330
344
|
return this.safeRequest(async () => {
|
|
331
|
-
const requestId = crypto.randomUUID();
|
|
332
|
-
console.debug(`[${requestId}] Opening file: ${filepath}`);
|
|
333
345
|
await this.client.post(`/open/${filepath}`, null, {
|
|
334
346
|
params: { newLeaf }
|
|
335
347
|
});
|
|
@@ -338,8 +350,6 @@ export class ObsidianClient {
|
|
|
338
350
|
|
|
339
351
|
async getActiveFile(): Promise<NoteJson> {
|
|
340
352
|
return this.safeRequest(async () => {
|
|
341
|
-
const requestId = crypto.randomUUID();
|
|
342
|
-
console.debug(`[${requestId}] Getting active file`);
|
|
343
353
|
const response = await this.client.get<NoteJson>("/active/", {
|
|
344
354
|
headers: {
|
|
345
355
|
"Accept": "application/vnd.olrapi.note+json"
|
|
@@ -351,8 +361,6 @@ export class ObsidianClient {
|
|
|
351
361
|
|
|
352
362
|
async updateActiveFile(content: string): Promise<void> {
|
|
353
363
|
return this.safeRequest(async () => {
|
|
354
|
-
const requestId = crypto.randomUUID();
|
|
355
|
-
console.debug(`[${requestId}] Updating active file`);
|
|
356
364
|
await this.client.put("/active/", content, {
|
|
357
365
|
headers: {
|
|
358
366
|
"Content-Type": "text/markdown"
|
|
@@ -363,21 +371,22 @@ export class ObsidianClient {
|
|
|
363
371
|
|
|
364
372
|
async deleteActiveFile(): Promise<void> {
|
|
365
373
|
return this.safeRequest(async () => {
|
|
366
|
-
const requestId = crypto.randomUUID();
|
|
367
|
-
console.debug(`[${requestId}] Deleting active file`);
|
|
368
374
|
await this.client.delete("/active/");
|
|
369
375
|
});
|
|
370
376
|
}
|
|
371
377
|
|
|
372
|
-
async patchActiveFile(
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
378
|
+
async patchActiveFile(
|
|
379
|
+
operation: "append" | "prepend" | "replace",
|
|
380
|
+
targetType: "heading" | "block" | "frontmatter",
|
|
381
|
+
target: string,
|
|
382
|
+
content: string,
|
|
383
|
+
options?: {
|
|
384
|
+
delimiter?: string;
|
|
385
|
+
trimWhitespace?: boolean;
|
|
386
|
+
contentType?: "text/markdown" | "application/json";
|
|
387
|
+
}
|
|
388
|
+
): Promise<void> {
|
|
377
389
|
return this.safeRequest(async () => {
|
|
378
|
-
const requestId = crypto.randomUUID();
|
|
379
|
-
console.debug(`[${requestId}] Patching active file: ${operation} ${targetType} ${target}`);
|
|
380
|
-
|
|
381
390
|
const headers: Record<string, string> = {
|
|
382
391
|
"Operation": operation,
|
|
383
392
|
"Target-Type": targetType,
|
|
@@ -398,8 +407,6 @@ export class ObsidianClient {
|
|
|
398
407
|
|
|
399
408
|
async getPeriodicNote(period: PeriodType["type"]): Promise<NoteJson> {
|
|
400
409
|
return this.safeRequest(async () => {
|
|
401
|
-
const requestId = crypto.randomUUID();
|
|
402
|
-
console.debug(`[${requestId}] Getting ${period} note`);
|
|
403
410
|
const response = await this.client.get<NoteJson>(`/periodic/${period}/`, {
|
|
404
411
|
headers: {
|
|
405
412
|
"Accept": "application/vnd.olrapi.note+json"
|
|
@@ -411,8 +418,6 @@ export class ObsidianClient {
|
|
|
411
418
|
|
|
412
419
|
async updatePeriodicNote(period: PeriodType["type"], content: string): Promise<void> {
|
|
413
420
|
return this.safeRequest(async () => {
|
|
414
|
-
const requestId = crypto.randomUUID();
|
|
415
|
-
console.debug(`[${requestId}] Updating ${period} note`);
|
|
416
421
|
await this.client.put(`/periodic/${period}/`, content, {
|
|
417
422
|
headers: {
|
|
418
423
|
"Content-Type": "text/markdown"
|
|
@@ -423,21 +428,23 @@ export class ObsidianClient {
|
|
|
423
428
|
|
|
424
429
|
async deletePeriodicNote(period: PeriodType["type"]): Promise<void> {
|
|
425
430
|
return this.safeRequest(async () => {
|
|
426
|
-
const requestId = crypto.randomUUID();
|
|
427
|
-
console.debug(`[${requestId}] Deleting ${period} note`);
|
|
428
431
|
await this.client.delete(`/periodic/${period}/`);
|
|
429
432
|
});
|
|
430
433
|
}
|
|
431
434
|
|
|
432
|
-
async patchPeriodicNote(
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
435
|
+
async patchPeriodicNote(
|
|
436
|
+
period: PeriodType["type"],
|
|
437
|
+
operation: "append" | "prepend" | "replace",
|
|
438
|
+
targetType: "heading" | "block" | "frontmatter",
|
|
439
|
+
target: string,
|
|
440
|
+
content: string,
|
|
441
|
+
options?: {
|
|
442
|
+
delimiter?: string;
|
|
443
|
+
trimWhitespace?: boolean;
|
|
444
|
+
contentType?: "text/markdown" | "application/json";
|
|
445
|
+
}
|
|
446
|
+
): Promise<void> {
|
|
437
447
|
return this.safeRequest(async () => {
|
|
438
|
-
const requestId = crypto.randomUUID();
|
|
439
|
-
console.debug(`[${requestId}] Patching ${period} note: ${operation} ${targetType} ${target}`);
|
|
440
|
-
|
|
441
448
|
const headers: Record<string, string> = {
|
|
442
449
|
"Operation": operation,
|
|
443
450
|
"Target-Type": targetType,
|
package/src/resources.ts
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { Resource, TextContent } from "@modelcontextprotocol/sdk/types.js";
|
|
2
2
|
import { ObsidianClient } from "./obsidian.js";
|
|
3
|
-
import { TagResponse,
|
|
3
|
+
import { TagResponse, ObsidianFile, JsonLogicQuery } from "./types.js";
|
|
4
|
+
import { PropertyManager } from "./properties.js";
|
|
4
5
|
|
|
5
6
|
export class TagResource {
|
|
6
7
|
private static readonly TAG_PATTERN = /#[a-zA-Z0-9_-]+/g;
|
|
8
|
+
private tagCache: Map<string, Set<string>> = new Map();
|
|
9
|
+
private propertyManager: PropertyManager;
|
|
10
|
+
private isInitialized = false;
|
|
11
|
+
private lastUpdate = 0;
|
|
12
|
+
private updateInterval = 5000; // 5 seconds
|
|
7
13
|
|
|
8
|
-
constructor(private client: ObsidianClient) {
|
|
14
|
+
constructor(private client: ObsidianClient) {
|
|
15
|
+
this.propertyManager = new PropertyManager(client);
|
|
16
|
+
this.initializeCache();
|
|
17
|
+
}
|
|
9
18
|
|
|
10
19
|
getResourceDescription(): Resource {
|
|
11
20
|
return {
|
|
@@ -16,41 +25,73 @@ export class TagResource {
|
|
|
16
25
|
};
|
|
17
26
|
}
|
|
18
27
|
|
|
19
|
-
async
|
|
28
|
+
private async initializeCache() {
|
|
20
29
|
try {
|
|
21
|
-
//
|
|
22
|
-
const query = {
|
|
23
|
-
"
|
|
30
|
+
// Get all markdown files
|
|
31
|
+
const query: JsonLogicQuery = {
|
|
32
|
+
"glob": ["**/*.md", { "var": "path" }]
|
|
24
33
|
};
|
|
25
|
-
|
|
34
|
+
|
|
26
35
|
const results = await this.client.searchJson(query);
|
|
36
|
+
this.tagCache.clear();
|
|
27
37
|
|
|
28
|
-
// Process
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
38
|
+
// Process each file
|
|
39
|
+
for (const result of results) {
|
|
40
|
+
if (!('filename' in result)) continue;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const content = await this.client.getFileContents(result.filename);
|
|
44
|
+
|
|
45
|
+
// Extract tags from frontmatter
|
|
46
|
+
const properties = this.propertyManager.parseProperties(content);
|
|
47
|
+
if (properties.tags) {
|
|
48
|
+
properties.tags.forEach((tag: string) => {
|
|
49
|
+
this.addTag(tag, result.filename);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Extract inline tags
|
|
54
|
+
const inlineTags = content.match(TagResource.TAG_PATTERN) || [];
|
|
55
|
+
inlineTags.forEach(tag => {
|
|
56
|
+
this.addTag(tag, result.filename);
|
|
47
57
|
});
|
|
58
|
+
} catch (error) {
|
|
59
|
+
console.error(`Failed to process file ${result.filename}:`, error);
|
|
48
60
|
}
|
|
49
|
-
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
this.isInitialized = true;
|
|
64
|
+
this.lastUpdate = Date.now();
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error("Failed to initialize tag cache:", error);
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private addTag(tag: string, filepath: string) {
|
|
72
|
+
if (!this.tagCache.has(tag)) {
|
|
73
|
+
this.tagCache.set(tag, new Set());
|
|
74
|
+
}
|
|
75
|
+
this.tagCache.get(tag)!.add(filepath);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private async updateCacheIfNeeded() {
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
if (now - this.lastUpdate > this.updateInterval) {
|
|
81
|
+
await this.initializeCache();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async getContent(): Promise<TextContent[]> {
|
|
86
|
+
try {
|
|
87
|
+
if (!this.isInitialized) {
|
|
88
|
+
await this.initializeCache();
|
|
89
|
+
} else {
|
|
90
|
+
await this.updateCacheIfNeeded();
|
|
91
|
+
}
|
|
50
92
|
|
|
51
|
-
// Convert to sorted response format
|
|
52
93
|
const response: TagResponse = {
|
|
53
|
-
tags: Array.from(
|
|
94
|
+
tags: Array.from(this.tagCache.entries())
|
|
54
95
|
.map(([name, files]) => ({
|
|
55
96
|
name,
|
|
56
97
|
count: files.size,
|
|
@@ -58,18 +99,24 @@ export class TagResource {
|
|
|
58
99
|
}))
|
|
59
100
|
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)),
|
|
60
101
|
metadata: {
|
|
61
|
-
totalOccurrences
|
|
62
|
-
|
|
63
|
-
|
|
102
|
+
totalOccurrences: Array.from(this.tagCache.values())
|
|
103
|
+
.reduce((sum, files) => sum + files.size, 0),
|
|
104
|
+
uniqueTags: this.tagCache.size,
|
|
105
|
+
scannedFiles: new Set(
|
|
106
|
+
Array.from(this.tagCache.values())
|
|
107
|
+
.flatMap(files => Array.from(files))
|
|
108
|
+
).size,
|
|
109
|
+
lastUpdate: this.lastUpdate
|
|
64
110
|
}
|
|
65
111
|
};
|
|
66
112
|
|
|
67
113
|
return [{
|
|
68
114
|
type: "text",
|
|
69
|
-
text: JSON.stringify(response, null, 2)
|
|
115
|
+
text: JSON.stringify(response, null, 2),
|
|
116
|
+
uri: this.getResourceDescription().uri
|
|
70
117
|
}];
|
|
71
118
|
} catch (error) {
|
|
72
|
-
console.error("Failed to
|
|
119
|
+
console.error("Failed to get tags:", error);
|
|
73
120
|
throw error;
|
|
74
121
|
}
|
|
75
122
|
}
|
package/src/tools.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -15,7 +15,7 @@ export interface ObsidianServerConfig {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export const DEFAULT_OBSIDIAN_CONFIG: ObsidianServerConfig = {
|
|
18
|
-
protocol: "https",
|
|
18
|
+
protocol: "https", // HTTPS required by default in Obsidian REST API plugin
|
|
19
19
|
host: "127.0.0.1",
|
|
20
20
|
port: 27124
|
|
21
21
|
} as const;
|
|
@@ -129,6 +129,7 @@ export interface TagMetadata {
|
|
|
129
129
|
totalOccurrences: number;
|
|
130
130
|
uniqueTags: number;
|
|
131
131
|
scannedFiles: number;
|
|
132
|
+
lastUpdate: number; // Timestamp of last cache update
|
|
132
133
|
}
|
|
133
134
|
|
|
134
135
|
export interface TagResponse {
|