obsidian-mcp-server 1.2.4 → 1.2.5
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 +46 -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 +84 -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,37 @@ 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. To fix this:\n` +
|
|
139
|
+
`1. Go to Obsidian Settings > Local REST API\n` +
|
|
140
|
+
`2. Under 'How to Access', copy the certificate\n` +
|
|
141
|
+
`3. Configure the certificate as a trusted certificate authority\n` +
|
|
142
|
+
`4. Ensure you're using HTTPS (HTTP is disabled by default)\n` +
|
|
143
|
+
`Original error: ${error.message}`, 50001, // SSL error code
|
|
144
|
+
{ code: error.code, config: { verifySSL: this.config.verifySSL } });
|
|
145
|
+
}
|
|
146
|
+
if (error.code === 'ECONNREFUSED') {
|
|
147
|
+
throw new ObsidianError(`Connection refused. To fix this:\n` +
|
|
148
|
+
`1. Ensure Obsidian is running\n` +
|
|
149
|
+
`2. Verify the 'Local REST API' plugin is enabled in Obsidian Settings\n` +
|
|
150
|
+
`3. Check that you're using the correct host (${this.config.host}) and port (${this.config.port})\n` +
|
|
151
|
+
`4. Make sure HTTPS is enabled in the plugin settings`, 50002, // Connection refused
|
|
152
|
+
{ code: error.code });
|
|
153
|
+
}
|
|
154
|
+
if (response?.status === 401) {
|
|
155
|
+
throw new ObsidianError(`Authentication failed. To fix this:\n` +
|
|
156
|
+
`1. Go to Obsidian Settings > Local REST API\n` +
|
|
157
|
+
`2. Copy your API key from the settings\n` +
|
|
158
|
+
`3. Update your configuration with the new API key\n` +
|
|
159
|
+
`Note: The API key changes when you regenerate certificates`, 40100, // Unauthorized
|
|
160
|
+
{ code: error.code });
|
|
161
|
+
}
|
|
162
|
+
// For other errors, use API error code if available
|
|
163
|
+
const errorCode = errorData?.errorCode ?? this.getErrorCode(response?.status ?? 500);
|
|
164
|
+
const message = errorData?.message ?? axiosError.message ?? "Unknown error";
|
|
140
165
|
throw new ObsidianError(message, errorCode, errorData);
|
|
141
166
|
}
|
|
142
|
-
// For non-Axios errors, use a generic server error code
|
|
143
167
|
if (error instanceof Error) {
|
|
144
168
|
throw new ObsidianError(error.message, 50000, error);
|
|
145
169
|
}
|
|
@@ -148,8 +172,6 @@ export class ObsidianClient {
|
|
|
148
172
|
}
|
|
149
173
|
async listFilesInVault() {
|
|
150
174
|
return this.safeRequest(async () => {
|
|
151
|
-
const requestId = crypto.randomUUID();
|
|
152
|
-
console.debug(`[${requestId}] Listing vault files`);
|
|
153
175
|
const response = await this.client.get("/vault/");
|
|
154
176
|
return response.data.files;
|
|
155
177
|
});
|
|
@@ -157,8 +179,6 @@ export class ObsidianClient {
|
|
|
157
179
|
async listFilesInDir(dirpath) {
|
|
158
180
|
this.validateFilePath(dirpath);
|
|
159
181
|
return this.safeRequest(async () => {
|
|
160
|
-
const requestId = crypto.randomUUID();
|
|
161
|
-
console.debug(`[${requestId}] Listing files in directory: ${dirpath}`);
|
|
162
182
|
const response = await this.client.get(`/vault/${dirpath}/`);
|
|
163
183
|
return response.data.files;
|
|
164
184
|
});
|
|
@@ -166,30 +186,22 @@ export class ObsidianClient {
|
|
|
166
186
|
async getFileContents(filepath) {
|
|
167
187
|
this.validateFilePath(filepath);
|
|
168
188
|
return this.safeRequest(async () => {
|
|
169
|
-
const requestId = crypto.randomUUID();
|
|
170
|
-
console.debug(`[${requestId}] Getting file contents: ${filepath}`);
|
|
171
189
|
const response = await this.client.get(`/vault/${filepath}`);
|
|
172
190
|
return response.data;
|
|
173
191
|
});
|
|
174
192
|
}
|
|
175
193
|
async search(query, contextLength = 100) {
|
|
176
194
|
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
|
-
});
|
|
195
|
+
const response = await this.client.post("/search/simple/", null, { params: { query, contextLength } });
|
|
182
196
|
return response.data;
|
|
183
197
|
});
|
|
184
198
|
}
|
|
185
199
|
async appendContent(filepath, content) {
|
|
186
200
|
this.validateFilePath(filepath);
|
|
187
201
|
if (!content || typeof content !== 'string') {
|
|
188
|
-
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
202
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
189
203
|
}
|
|
190
204
|
return this.safeRequest(async () => {
|
|
191
|
-
const requestId = crypto.randomUUID();
|
|
192
|
-
console.debug(`[${requestId}] Appending content to: ${filepath}`);
|
|
193
205
|
await this.client.post(`/vault/${filepath}`, content, {
|
|
194
206
|
headers: {
|
|
195
207
|
"Content-Type": "text/markdown"
|
|
@@ -200,11 +212,9 @@ export class ObsidianClient {
|
|
|
200
212
|
async updateContent(filepath, content) {
|
|
201
213
|
this.validateFilePath(filepath);
|
|
202
214
|
if (!content || typeof content !== 'string') {
|
|
203
|
-
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
215
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
204
216
|
}
|
|
205
217
|
return this.safeRequest(async () => {
|
|
206
|
-
const requestId = crypto.randomUUID();
|
|
207
|
-
console.debug(`[${requestId}] Updating content in: ${filepath}`);
|
|
208
218
|
await this.client.put(`/vault/${filepath}`, content, {
|
|
209
219
|
headers: {
|
|
210
220
|
"Content-Type": "text/markdown"
|
|
@@ -214,9 +224,6 @@ export class ObsidianClient {
|
|
|
214
224
|
}
|
|
215
225
|
async searchJson(query) {
|
|
216
226
|
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
227
|
const isTagSearch = JSON.stringify(query).includes('"contains"') &&
|
|
221
228
|
JSON.stringify(query).includes('"#"');
|
|
222
229
|
const response = await this.client.post("/search/", query, {
|
|
@@ -225,40 +232,29 @@ export class ObsidianClient {
|
|
|
225
232
|
"Accept": "application/vnd.olrapi.note+json"
|
|
226
233
|
}
|
|
227
234
|
});
|
|
228
|
-
|
|
229
|
-
return response.data;
|
|
230
|
-
}
|
|
231
|
-
return response.data;
|
|
235
|
+
return isTagSearch ? response.data : response.data;
|
|
232
236
|
});
|
|
233
237
|
}
|
|
234
238
|
async getStatus() {
|
|
235
239
|
return this.safeRequest(async () => {
|
|
236
|
-
const requestId = crypto.randomUUID();
|
|
237
|
-
console.debug(`[${requestId}] Getting server status`);
|
|
238
240
|
const response = await this.client.get("/");
|
|
239
241
|
return response.data;
|
|
240
242
|
});
|
|
241
243
|
}
|
|
242
244
|
async listCommands() {
|
|
243
245
|
return this.safeRequest(async () => {
|
|
244
|
-
const requestId = crypto.randomUUID();
|
|
245
|
-
console.debug(`[${requestId}] Listing available commands`);
|
|
246
246
|
const response = await this.client.get("/commands/");
|
|
247
247
|
return response.data.commands;
|
|
248
248
|
});
|
|
249
249
|
}
|
|
250
250
|
async executeCommand(commandId) {
|
|
251
251
|
return this.safeRequest(async () => {
|
|
252
|
-
const requestId = crypto.randomUUID();
|
|
253
|
-
console.debug(`[${requestId}] Executing command: ${commandId}`);
|
|
254
252
|
await this.client.post(`/commands/${commandId}/`);
|
|
255
253
|
});
|
|
256
254
|
}
|
|
257
255
|
async openFile(filepath, newLeaf = false) {
|
|
258
256
|
this.validateFilePath(filepath);
|
|
259
257
|
return this.safeRequest(async () => {
|
|
260
|
-
const requestId = crypto.randomUUID();
|
|
261
|
-
console.debug(`[${requestId}] Opening file: ${filepath}`);
|
|
262
258
|
await this.client.post(`/open/${filepath}`, null, {
|
|
263
259
|
params: { newLeaf }
|
|
264
260
|
});
|
|
@@ -266,8 +262,6 @@ export class ObsidianClient {
|
|
|
266
262
|
}
|
|
267
263
|
async getActiveFile() {
|
|
268
264
|
return this.safeRequest(async () => {
|
|
269
|
-
const requestId = crypto.randomUUID();
|
|
270
|
-
console.debug(`[${requestId}] Getting active file`);
|
|
271
265
|
const response = await this.client.get("/active/", {
|
|
272
266
|
headers: {
|
|
273
267
|
"Accept": "application/vnd.olrapi.note+json"
|
|
@@ -278,8 +272,6 @@ export class ObsidianClient {
|
|
|
278
272
|
}
|
|
279
273
|
async updateActiveFile(content) {
|
|
280
274
|
return this.safeRequest(async () => {
|
|
281
|
-
const requestId = crypto.randomUUID();
|
|
282
|
-
console.debug(`[${requestId}] Updating active file`);
|
|
283
275
|
await this.client.put("/active/", content, {
|
|
284
276
|
headers: {
|
|
285
277
|
"Content-Type": "text/markdown"
|
|
@@ -289,15 +281,11 @@ export class ObsidianClient {
|
|
|
289
281
|
}
|
|
290
282
|
async deleteActiveFile() {
|
|
291
283
|
return this.safeRequest(async () => {
|
|
292
|
-
const requestId = crypto.randomUUID();
|
|
293
|
-
console.debug(`[${requestId}] Deleting active file`);
|
|
294
284
|
await this.client.delete("/active/");
|
|
295
285
|
});
|
|
296
286
|
}
|
|
297
287
|
async patchActiveFile(operation, targetType, target, content, options) {
|
|
298
288
|
return this.safeRequest(async () => {
|
|
299
|
-
const requestId = crypto.randomUUID();
|
|
300
|
-
console.debug(`[${requestId}] Patching active file: ${operation} ${targetType} ${target}`);
|
|
301
289
|
const headers = {
|
|
302
290
|
"Operation": operation,
|
|
303
291
|
"Target-Type": targetType,
|
|
@@ -315,8 +303,6 @@ export class ObsidianClient {
|
|
|
315
303
|
}
|
|
316
304
|
async getPeriodicNote(period) {
|
|
317
305
|
return this.safeRequest(async () => {
|
|
318
|
-
const requestId = crypto.randomUUID();
|
|
319
|
-
console.debug(`[${requestId}] Getting ${period} note`);
|
|
320
306
|
const response = await this.client.get(`/periodic/${period}/`, {
|
|
321
307
|
headers: {
|
|
322
308
|
"Accept": "application/vnd.olrapi.note+json"
|
|
@@ -327,8 +313,6 @@ export class ObsidianClient {
|
|
|
327
313
|
}
|
|
328
314
|
async updatePeriodicNote(period, content) {
|
|
329
315
|
return this.safeRequest(async () => {
|
|
330
|
-
const requestId = crypto.randomUUID();
|
|
331
|
-
console.debug(`[${requestId}] Updating ${period} note`);
|
|
332
316
|
await this.client.put(`/periodic/${period}/`, content, {
|
|
333
317
|
headers: {
|
|
334
318
|
"Content-Type": "text/markdown"
|
|
@@ -338,15 +322,11 @@ export class ObsidianClient {
|
|
|
338
322
|
}
|
|
339
323
|
async deletePeriodicNote(period) {
|
|
340
324
|
return this.safeRequest(async () => {
|
|
341
|
-
const requestId = crypto.randomUUID();
|
|
342
|
-
console.debug(`[${requestId}] Deleting ${period} note`);
|
|
343
325
|
await this.client.delete(`/periodic/${period}/`);
|
|
344
326
|
});
|
|
345
327
|
}
|
|
346
328
|
async patchPeriodicNote(period, operation, targetType, target, content, options) {
|
|
347
329
|
return this.safeRequest(async () => {
|
|
348
|
-
const requestId = crypto.randomUUID();
|
|
349
|
-
console.debug(`[${requestId}] Patching ${period} note: ${operation} ${targetType} ${target}`);
|
|
350
330
|
const headers = {
|
|
351
331
|
"Operation": operation,
|
|
352
332
|
"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,51 @@ 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. To fix this:\n` +
|
|
175
|
+
`1. Go to Obsidian Settings > Local REST API\n` +
|
|
176
|
+
`2. Under 'How to Access', copy the certificate\n` +
|
|
177
|
+
`3. Configure the certificate as a trusted certificate authority\n` +
|
|
178
|
+
`4. Ensure you're using HTTPS (HTTP is disabled by default)\n` +
|
|
179
|
+
`Original error: ${error.message}`,
|
|
180
|
+
50001, // SSL error code
|
|
181
|
+
{ code: error.code, config: { verifySSL: this.config.verifySSL } }
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (error.code === 'ECONNREFUSED') {
|
|
186
|
+
throw new ObsidianError(
|
|
187
|
+
`Connection refused. To fix this:\n` +
|
|
188
|
+
`1. Ensure Obsidian is running\n` +
|
|
189
|
+
`2. Verify the 'Local REST API' plugin is enabled in Obsidian Settings\n` +
|
|
190
|
+
`3. Check that you're using the correct host (${this.config.host}) and port (${this.config.port})\n` +
|
|
191
|
+
`4. Make sure HTTPS is enabled in the plugin settings`,
|
|
192
|
+
50002, // Connection refused
|
|
193
|
+
{ code: error.code }
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (response?.status === 401) {
|
|
198
|
+
throw new ObsidianError(
|
|
199
|
+
`Authentication failed. To fix this:\n` +
|
|
200
|
+
`1. Go to Obsidian Settings > Local REST API\n` +
|
|
201
|
+
`2. Copy your API key from the settings\n` +
|
|
202
|
+
`3. Update your configuration with the new API key\n` +
|
|
203
|
+
`Note: The API key changes when you regenerate certificates`,
|
|
204
|
+
40100, // Unauthorized
|
|
205
|
+
{ code: error.code }
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// For other errors, use API error code if available
|
|
210
|
+
const errorCode = errorData?.errorCode ?? this.getErrorCode(response?.status ?? 500);
|
|
211
|
+
const message = errorData?.message ?? axiosError.message ?? "Unknown error";
|
|
178
212
|
throw new ObsidianError(message, errorCode, errorData);
|
|
179
213
|
}
|
|
180
214
|
|
|
181
|
-
// For non-Axios errors, use a generic server error code
|
|
182
215
|
if (error instanceof Error) {
|
|
183
216
|
throw new ObsidianError(error.message, 50000, error);
|
|
184
217
|
}
|
|
@@ -189,8 +222,6 @@ export class ObsidianClient {
|
|
|
189
222
|
|
|
190
223
|
async listFilesInVault(): Promise<ObsidianFile[]> {
|
|
191
224
|
return this.safeRequest(async () => {
|
|
192
|
-
const requestId = crypto.randomUUID();
|
|
193
|
-
console.debug(`[${requestId}] Listing vault files`);
|
|
194
225
|
const response = await this.client.get<{ files: ObsidianFile[] }>("/vault/");
|
|
195
226
|
return response.data.files;
|
|
196
227
|
});
|
|
@@ -199,8 +230,6 @@ export class ObsidianClient {
|
|
|
199
230
|
async listFilesInDir(dirpath: string): Promise<ObsidianFile[]> {
|
|
200
231
|
this.validateFilePath(dirpath);
|
|
201
232
|
return this.safeRequest(async () => {
|
|
202
|
-
const requestId = crypto.randomUUID();
|
|
203
|
-
console.debug(`[${requestId}] Listing files in directory: ${dirpath}`);
|
|
204
233
|
const response = await this.client.get<{ files: ObsidianFile[] }>(`/vault/${dirpath}/`);
|
|
205
234
|
return response.data.files;
|
|
206
235
|
});
|
|
@@ -209,8 +238,6 @@ export class ObsidianClient {
|
|
|
209
238
|
async getFileContents(filepath: string): Promise<string> {
|
|
210
239
|
this.validateFilePath(filepath);
|
|
211
240
|
return this.safeRequest(async () => {
|
|
212
|
-
const requestId = crypto.randomUUID();
|
|
213
|
-
console.debug(`[${requestId}] Getting file contents: ${filepath}`);
|
|
214
241
|
const response = await this.client.get<string>(`/vault/${filepath}`);
|
|
215
242
|
return response.data;
|
|
216
243
|
});
|
|
@@ -218,14 +245,10 @@ export class ObsidianClient {
|
|
|
218
245
|
|
|
219
246
|
async search(query: string, contextLength: number = 100): Promise<SimpleSearchResult[]> {
|
|
220
247
|
return this.safeRequest(async () => {
|
|
221
|
-
const requestId = crypto.randomUUID();
|
|
222
|
-
console.debug(`[${requestId}] Performing simple search: ${query}`);
|
|
223
248
|
const response = await this.client.post<SimpleSearchResult[]>(
|
|
224
249
|
"/search/simple/",
|
|
225
250
|
null,
|
|
226
|
-
{
|
|
227
|
-
params: { query, contextLength }
|
|
228
|
-
}
|
|
251
|
+
{ params: { query, contextLength } }
|
|
229
252
|
);
|
|
230
253
|
return response.data;
|
|
231
254
|
});
|
|
@@ -234,11 +257,9 @@ export class ObsidianClient {
|
|
|
234
257
|
async appendContent(filepath: string, content: string): Promise<void> {
|
|
235
258
|
this.validateFilePath(filepath);
|
|
236
259
|
if (!content || typeof content !== 'string') {
|
|
237
|
-
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
260
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
238
261
|
}
|
|
239
262
|
return this.safeRequest(async () => {
|
|
240
|
-
const requestId = crypto.randomUUID();
|
|
241
|
-
console.debug(`[${requestId}] Appending content to: ${filepath}`);
|
|
242
263
|
await this.client.post(
|
|
243
264
|
`/vault/${filepath}`,
|
|
244
265
|
content,
|
|
@@ -254,12 +275,10 @@ export class ObsidianClient {
|
|
|
254
275
|
async updateContent(filepath: string, content: string): Promise<void> {
|
|
255
276
|
this.validateFilePath(filepath);
|
|
256
277
|
if (!content || typeof content !== 'string') {
|
|
257
|
-
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
278
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
258
279
|
}
|
|
259
280
|
|
|
260
281
|
return this.safeRequest(async () => {
|
|
261
|
-
const requestId = crypto.randomUUID();
|
|
262
|
-
console.debug(`[${requestId}] Updating content in: ${filepath}`);
|
|
263
282
|
await this.client.put(
|
|
264
283
|
`/vault/${filepath}`,
|
|
265
284
|
content,
|
|
@@ -274,10 +293,6 @@ export class ObsidianClient {
|
|
|
274
293
|
|
|
275
294
|
async searchJson(query: JsonLogicQuery): Promise<SearchResponse[]> {
|
|
276
295
|
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
296
|
const isTagSearch = JSON.stringify(query).includes('"contains"') &&
|
|
282
297
|
JSON.stringify(query).includes('"#"');
|
|
283
298
|
|
|
@@ -292,17 +307,12 @@ export class ObsidianClient {
|
|
|
292
307
|
}
|
|
293
308
|
);
|
|
294
309
|
|
|
295
|
-
|
|
296
|
-
return response.data as SimpleSearchResult[];
|
|
297
|
-
}
|
|
298
|
-
return response.data as SearchResult[];
|
|
310
|
+
return isTagSearch ? response.data as SimpleSearchResult[] : response.data as SearchResult[];
|
|
299
311
|
});
|
|
300
312
|
}
|
|
301
313
|
|
|
302
314
|
async getStatus(): Promise<ObsidianStatus> {
|
|
303
315
|
return this.safeRequest(async () => {
|
|
304
|
-
const requestId = crypto.randomUUID();
|
|
305
|
-
console.debug(`[${requestId}] Getting server status`);
|
|
306
316
|
const response = await this.client.get<ObsidianStatus>("/");
|
|
307
317
|
return response.data;
|
|
308
318
|
});
|
|
@@ -310,8 +320,6 @@ export class ObsidianClient {
|
|
|
310
320
|
|
|
311
321
|
async listCommands(): Promise<ObsidianCommand[]> {
|
|
312
322
|
return this.safeRequest(async () => {
|
|
313
|
-
const requestId = crypto.randomUUID();
|
|
314
|
-
console.debug(`[${requestId}] Listing available commands`);
|
|
315
323
|
const response = await this.client.get<{commands: ObsidianCommand[]}>("/commands/");
|
|
316
324
|
return response.data.commands;
|
|
317
325
|
});
|
|
@@ -319,8 +327,6 @@ export class ObsidianClient {
|
|
|
319
327
|
|
|
320
328
|
async executeCommand(commandId: string): Promise<void> {
|
|
321
329
|
return this.safeRequest(async () => {
|
|
322
|
-
const requestId = crypto.randomUUID();
|
|
323
|
-
console.debug(`[${requestId}] Executing command: ${commandId}`);
|
|
324
330
|
await this.client.post(`/commands/${commandId}/`);
|
|
325
331
|
});
|
|
326
332
|
}
|
|
@@ -328,8 +334,6 @@ export class ObsidianClient {
|
|
|
328
334
|
async openFile(filepath: string, newLeaf: boolean = false): Promise<void> {
|
|
329
335
|
this.validateFilePath(filepath);
|
|
330
336
|
return this.safeRequest(async () => {
|
|
331
|
-
const requestId = crypto.randomUUID();
|
|
332
|
-
console.debug(`[${requestId}] Opening file: ${filepath}`);
|
|
333
337
|
await this.client.post(`/open/${filepath}`, null, {
|
|
334
338
|
params: { newLeaf }
|
|
335
339
|
});
|
|
@@ -338,8 +342,6 @@ export class ObsidianClient {
|
|
|
338
342
|
|
|
339
343
|
async getActiveFile(): Promise<NoteJson> {
|
|
340
344
|
return this.safeRequest(async () => {
|
|
341
|
-
const requestId = crypto.randomUUID();
|
|
342
|
-
console.debug(`[${requestId}] Getting active file`);
|
|
343
345
|
const response = await this.client.get<NoteJson>("/active/", {
|
|
344
346
|
headers: {
|
|
345
347
|
"Accept": "application/vnd.olrapi.note+json"
|
|
@@ -351,8 +353,6 @@ export class ObsidianClient {
|
|
|
351
353
|
|
|
352
354
|
async updateActiveFile(content: string): Promise<void> {
|
|
353
355
|
return this.safeRequest(async () => {
|
|
354
|
-
const requestId = crypto.randomUUID();
|
|
355
|
-
console.debug(`[${requestId}] Updating active file`);
|
|
356
356
|
await this.client.put("/active/", content, {
|
|
357
357
|
headers: {
|
|
358
358
|
"Content-Type": "text/markdown"
|
|
@@ -363,21 +363,22 @@ export class ObsidianClient {
|
|
|
363
363
|
|
|
364
364
|
async deleteActiveFile(): Promise<void> {
|
|
365
365
|
return this.safeRequest(async () => {
|
|
366
|
-
const requestId = crypto.randomUUID();
|
|
367
|
-
console.debug(`[${requestId}] Deleting active file`);
|
|
368
366
|
await this.client.delete("/active/");
|
|
369
367
|
});
|
|
370
368
|
}
|
|
371
369
|
|
|
372
|
-
async patchActiveFile(
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
370
|
+
async patchActiveFile(
|
|
371
|
+
operation: "append" | "prepend" | "replace",
|
|
372
|
+
targetType: "heading" | "block" | "frontmatter",
|
|
373
|
+
target: string,
|
|
374
|
+
content: string,
|
|
375
|
+
options?: {
|
|
376
|
+
delimiter?: string;
|
|
377
|
+
trimWhitespace?: boolean;
|
|
378
|
+
contentType?: "text/markdown" | "application/json";
|
|
379
|
+
}
|
|
380
|
+
): Promise<void> {
|
|
377
381
|
return this.safeRequest(async () => {
|
|
378
|
-
const requestId = crypto.randomUUID();
|
|
379
|
-
console.debug(`[${requestId}] Patching active file: ${operation} ${targetType} ${target}`);
|
|
380
|
-
|
|
381
382
|
const headers: Record<string, string> = {
|
|
382
383
|
"Operation": operation,
|
|
383
384
|
"Target-Type": targetType,
|
|
@@ -398,8 +399,6 @@ export class ObsidianClient {
|
|
|
398
399
|
|
|
399
400
|
async getPeriodicNote(period: PeriodType["type"]): Promise<NoteJson> {
|
|
400
401
|
return this.safeRequest(async () => {
|
|
401
|
-
const requestId = crypto.randomUUID();
|
|
402
|
-
console.debug(`[${requestId}] Getting ${period} note`);
|
|
403
402
|
const response = await this.client.get<NoteJson>(`/periodic/${period}/`, {
|
|
404
403
|
headers: {
|
|
405
404
|
"Accept": "application/vnd.olrapi.note+json"
|
|
@@ -411,8 +410,6 @@ export class ObsidianClient {
|
|
|
411
410
|
|
|
412
411
|
async updatePeriodicNote(period: PeriodType["type"], content: string): Promise<void> {
|
|
413
412
|
return this.safeRequest(async () => {
|
|
414
|
-
const requestId = crypto.randomUUID();
|
|
415
|
-
console.debug(`[${requestId}] Updating ${period} note`);
|
|
416
413
|
await this.client.put(`/periodic/${period}/`, content, {
|
|
417
414
|
headers: {
|
|
418
415
|
"Content-Type": "text/markdown"
|
|
@@ -423,21 +420,23 @@ export class ObsidianClient {
|
|
|
423
420
|
|
|
424
421
|
async deletePeriodicNote(period: PeriodType["type"]): Promise<void> {
|
|
425
422
|
return this.safeRequest(async () => {
|
|
426
|
-
const requestId = crypto.randomUUID();
|
|
427
|
-
console.debug(`[${requestId}] Deleting ${period} note`);
|
|
428
423
|
await this.client.delete(`/periodic/${period}/`);
|
|
429
424
|
});
|
|
430
425
|
}
|
|
431
426
|
|
|
432
|
-
async patchPeriodicNote(
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
427
|
+
async patchPeriodicNote(
|
|
428
|
+
period: PeriodType["type"],
|
|
429
|
+
operation: "append" | "prepend" | "replace",
|
|
430
|
+
targetType: "heading" | "block" | "frontmatter",
|
|
431
|
+
target: string,
|
|
432
|
+
content: string,
|
|
433
|
+
options?: {
|
|
434
|
+
delimiter?: string;
|
|
435
|
+
trimWhitespace?: boolean;
|
|
436
|
+
contentType?: "text/markdown" | "application/json";
|
|
437
|
+
}
|
|
438
|
+
): Promise<void> {
|
|
437
439
|
return this.safeRequest(async () => {
|
|
438
|
-
const requestId = crypto.randomUUID();
|
|
439
|
-
console.debug(`[${requestId}] Patching ${period} note: ${operation} ${targetType} ${target}`);
|
|
440
|
-
|
|
441
440
|
const headers: Record<string, string> = {
|
|
442
441
|
"Operation": operation,
|
|
443
442
|
"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 {
|