obsidian-mcp-server 1.2.3 → 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/README.md +75 -5
- package/build/obsidian.js +181 -32
- package/build/properties.js +7 -7
- package/build/propertyTools.js +11 -21
- package/build/propertyTypes.js +4 -13
- package/build/resources.js +111 -0
- package/build/server.js +36 -12
- package/build/tools.js +425 -48
- package/build/types.js +21 -4
- package/package.json +3 -3
- package/src/obsidian.ts +260 -38
- package/src/properties.ts +9 -7
- package/src/propertyTools.ts +13 -21
- package/src/propertyTypes.ts +5 -13
- package/src/resources.ts +123 -0
- package/src/server.ts +39 -14
- package/src/tools.ts +461 -49
- package/src/types.ts +89 -4
package/src/obsidian.ts
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import axios from "axios";
|
|
2
2
|
import type { AxiosInstance, AxiosError, AxiosRequestConfig } from "axios";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
ObsidianConfig,
|
|
5
|
+
ObsidianError,
|
|
6
|
+
ObsidianFile,
|
|
7
|
+
SearchResult,
|
|
8
|
+
SimpleSearchResult,
|
|
9
|
+
SearchResponse,
|
|
10
|
+
DEFAULT_OBSIDIAN_CONFIG,
|
|
11
|
+
ObsidianServerConfig,
|
|
12
|
+
JsonLogicQuery,
|
|
13
|
+
ObsidianStatus,
|
|
14
|
+
ObsidianCommand,
|
|
15
|
+
NoteJson,
|
|
16
|
+
PeriodType,
|
|
17
|
+
ApiError
|
|
18
|
+
} from "./types.js";
|
|
4
19
|
import { Agent } from "node:https";
|
|
5
20
|
import { readFileSync } from "fs";
|
|
6
21
|
import { fileURLToPath } from 'url';
|
|
@@ -26,15 +41,22 @@ export class ObsidianClient {
|
|
|
26
41
|
|
|
27
42
|
constructor(config: ObsidianConfig) {
|
|
28
43
|
if (!config.apiKey) {
|
|
29
|
-
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
|
+
);
|
|
30
52
|
}
|
|
31
53
|
|
|
32
54
|
// Combine defaults with provided config
|
|
33
55
|
this.config = {
|
|
34
56
|
...DEFAULT_OBSIDIAN_CONFIG,
|
|
35
|
-
verifySSL: config.verifySSL ??
|
|
57
|
+
verifySSL: config.verifySSL ?? true, // Default to true as required by Obsidian REST API plugin
|
|
36
58
|
apiKey: config.apiKey,
|
|
37
|
-
timeout: config.timeout ?? 5000,
|
|
59
|
+
timeout: config.timeout ?? 5000, // 5 second default timeout
|
|
38
60
|
maxContentLength: config.maxContentLength ?? 50 * 1024 * 1024, // 50MB
|
|
39
61
|
maxBodyLength: config.maxBodyLength ?? 50 * 1024 * 1024 // 50MB
|
|
40
62
|
};
|
|
@@ -69,8 +91,9 @@ export class ObsidianClient {
|
|
|
69
91
|
|
|
70
92
|
if (!this.config.verifySSL) {
|
|
71
93
|
console.warn(
|
|
72
|
-
"WARNING: SSL verification is disabled.
|
|
73
|
-
|
|
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."
|
|
74
97
|
);
|
|
75
98
|
}
|
|
76
99
|
|
|
@@ -106,12 +129,33 @@ export class ObsidianClient {
|
|
|
106
129
|
// Prevent path traversal attacks
|
|
107
130
|
const normalizedPath = filepath.replace(/\\/g, '/');
|
|
108
131
|
if (normalizedPath.includes('../') || normalizedPath.includes('..\\')) {
|
|
109
|
-
throw new ObsidianError('Invalid file path: Path traversal not allowed',
|
|
132
|
+
throw new ObsidianError('Invalid file path: Path traversal not allowed', 40001);
|
|
110
133
|
}
|
|
111
134
|
|
|
112
135
|
// Additional path validations
|
|
113
136
|
if (normalizedPath.startsWith('/') || /^[a-zA-Z]:/.test(normalizedPath)) {
|
|
114
|
-
throw new ObsidianError('Invalid file path: Absolute paths not allowed',
|
|
137
|
+
throw new ObsidianError('Invalid file path: Absolute paths not allowed', 40002);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private getErrorCode(status: number): number {
|
|
142
|
+
switch (status) {
|
|
143
|
+
case 400: return 40000; // Bad request
|
|
144
|
+
case 401: return 40100; // Unauthorized
|
|
145
|
+
case 403: return 40300; // Forbidden
|
|
146
|
+
case 404: return 40400; // Not found
|
|
147
|
+
case 405: return 40500; // Method not allowed
|
|
148
|
+
case 409: return 40900; // Conflict
|
|
149
|
+
case 429: return 42900; // Too many requests
|
|
150
|
+
case 500: return 50000; // Internal server error
|
|
151
|
+
case 501: return 50100; // Not implemented
|
|
152
|
+
case 502: return 50200; // Bad gateway
|
|
153
|
+
case 503: return 50300; // Service unavailable
|
|
154
|
+
case 504: return 50400; // Gateway timeout
|
|
155
|
+
default:
|
|
156
|
+
if (status >= 400 && status < 500) return 40000 + (status - 400) * 100;
|
|
157
|
+
if (status >= 500 && status < 600) return 50000 + (status - 500) * 100;
|
|
158
|
+
return 50000;
|
|
115
159
|
}
|
|
116
160
|
}
|
|
117
161
|
|
|
@@ -120,21 +164,64 @@ export class ObsidianClient {
|
|
|
120
164
|
return await operation();
|
|
121
165
|
} catch (error) {
|
|
122
166
|
if (axios.isAxiosError(error)) {
|
|
123
|
-
const axiosError = error as AxiosError<
|
|
167
|
+
const axiosError = error as AxiosError<ApiError>;
|
|
124
168
|
const response = axiosError.response;
|
|
125
169
|
const errorData = response?.data;
|
|
126
|
-
|
|
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);
|
|
127
211
|
const message = errorData?.message ?? axiosError.message ?? "Unknown error";
|
|
128
|
-
throw new ObsidianError(message,
|
|
212
|
+
throw new ObsidianError(message, errorCode, errorData);
|
|
129
213
|
}
|
|
130
|
-
|
|
214
|
+
|
|
215
|
+
if (error instanceof Error) {
|
|
216
|
+
throw new ObsidianError(error.message, 50000, error);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
throw new ObsidianError("Unknown error occurred", 50000, error);
|
|
131
220
|
}
|
|
132
221
|
}
|
|
133
222
|
|
|
134
223
|
async listFilesInVault(): Promise<ObsidianFile[]> {
|
|
135
224
|
return this.safeRequest(async () => {
|
|
136
|
-
const requestId = crypto.randomUUID();
|
|
137
|
-
console.debug(`[${requestId}] Listing vault files`);
|
|
138
225
|
const response = await this.client.get<{ files: ObsidianFile[] }>("/vault/");
|
|
139
226
|
return response.data.files;
|
|
140
227
|
});
|
|
@@ -143,8 +230,6 @@ export class ObsidianClient {
|
|
|
143
230
|
async listFilesInDir(dirpath: string): Promise<ObsidianFile[]> {
|
|
144
231
|
this.validateFilePath(dirpath);
|
|
145
232
|
return this.safeRequest(async () => {
|
|
146
|
-
const requestId = crypto.randomUUID();
|
|
147
|
-
console.debug(`[${requestId}] Listing files in directory: ${dirpath}`);
|
|
148
233
|
const response = await this.client.get<{ files: ObsidianFile[] }>(`/vault/${dirpath}/`);
|
|
149
234
|
return response.data.files;
|
|
150
235
|
});
|
|
@@ -153,23 +238,18 @@ export class ObsidianClient {
|
|
|
153
238
|
async getFileContents(filepath: string): Promise<string> {
|
|
154
239
|
this.validateFilePath(filepath);
|
|
155
240
|
return this.safeRequest(async () => {
|
|
156
|
-
const requestId = crypto.randomUUID();
|
|
157
|
-
console.debug(`[${requestId}] Getting file contents: ${filepath}`);
|
|
158
241
|
const response = await this.client.get<string>(`/vault/${filepath}`);
|
|
159
242
|
return response.data;
|
|
160
243
|
});
|
|
161
244
|
}
|
|
162
245
|
|
|
163
|
-
async search(query: string, contextLength: number = 100): Promise<
|
|
246
|
+
async search(query: string, contextLength: number = 100): Promise<SimpleSearchResult[]> {
|
|
164
247
|
return this.safeRequest(async () => {
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
params: {
|
|
169
|
-
|
|
170
|
-
contextLength
|
|
171
|
-
}
|
|
172
|
-
});
|
|
248
|
+
const response = await this.client.post<SimpleSearchResult[]>(
|
|
249
|
+
"/search/simple/",
|
|
250
|
+
null,
|
|
251
|
+
{ params: { query, contextLength } }
|
|
252
|
+
);
|
|
173
253
|
return response.data;
|
|
174
254
|
});
|
|
175
255
|
}
|
|
@@ -177,11 +257,9 @@ export class ObsidianClient {
|
|
|
177
257
|
async appendContent(filepath: string, content: string): Promise<void> {
|
|
178
258
|
this.validateFilePath(filepath);
|
|
179
259
|
if (!content || typeof content !== 'string') {
|
|
180
|
-
throw new ObsidianError('Invalid content: Content must be a non-empty string',
|
|
260
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
181
261
|
}
|
|
182
262
|
return this.safeRequest(async () => {
|
|
183
|
-
const requestId = crypto.randomUUID();
|
|
184
|
-
console.debug(`[${requestId}] Appending content to: ${filepath}`);
|
|
185
263
|
await this.client.post(
|
|
186
264
|
`/vault/${filepath}`,
|
|
187
265
|
content,
|
|
@@ -197,12 +275,10 @@ export class ObsidianClient {
|
|
|
197
275
|
async updateContent(filepath: string, content: string): Promise<void> {
|
|
198
276
|
this.validateFilePath(filepath);
|
|
199
277
|
if (!content || typeof content !== 'string') {
|
|
200
|
-
throw new ObsidianError('Invalid content: Content must be a non-empty string',
|
|
278
|
+
throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003);
|
|
201
279
|
}
|
|
202
280
|
|
|
203
281
|
return this.safeRequest(async () => {
|
|
204
|
-
const requestId = crypto.randomUUID();
|
|
205
|
-
console.debug(`[${requestId}] Updating content in: ${filepath}`);
|
|
206
282
|
await this.client.put(
|
|
207
283
|
`/vault/${filepath}`,
|
|
208
284
|
content,
|
|
@@ -215,21 +291,167 @@ export class ObsidianClient {
|
|
|
215
291
|
});
|
|
216
292
|
}
|
|
217
293
|
|
|
218
|
-
async searchJson(query: JsonLogicQuery): Promise<
|
|
294
|
+
async searchJson(query: JsonLogicQuery): Promise<SearchResponse[]> {
|
|
219
295
|
return this.safeRequest(async () => {
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
296
|
+
const isTagSearch = JSON.stringify(query).includes('"contains"') &&
|
|
297
|
+
JSON.stringify(query).includes('"#"');
|
|
298
|
+
|
|
299
|
+
const response = await this.client.post(
|
|
223
300
|
"/search/",
|
|
224
301
|
query,
|
|
225
302
|
{
|
|
226
303
|
headers: {
|
|
227
304
|
"Content-Type": "application/vnd.olrapi.jsonlogic+json",
|
|
228
|
-
"Accept": "application/json"
|
|
305
|
+
"Accept": "application/vnd.olrapi.note+json"
|
|
229
306
|
}
|
|
230
307
|
}
|
|
231
308
|
);
|
|
309
|
+
|
|
310
|
+
return isTagSearch ? response.data as SimpleSearchResult[] : response.data as SearchResult[];
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async getStatus(): Promise<ObsidianStatus> {
|
|
315
|
+
return this.safeRequest(async () => {
|
|
316
|
+
const response = await this.client.get<ObsidianStatus>("/");
|
|
317
|
+
return response.data;
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async listCommands(): Promise<ObsidianCommand[]> {
|
|
322
|
+
return this.safeRequest(async () => {
|
|
323
|
+
const response = await this.client.get<{commands: ObsidianCommand[]}>("/commands/");
|
|
324
|
+
return response.data.commands;
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async executeCommand(commandId: string): Promise<void> {
|
|
329
|
+
return this.safeRequest(async () => {
|
|
330
|
+
await this.client.post(`/commands/${commandId}/`);
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async openFile(filepath: string, newLeaf: boolean = false): Promise<void> {
|
|
335
|
+
this.validateFilePath(filepath);
|
|
336
|
+
return this.safeRequest(async () => {
|
|
337
|
+
await this.client.post(`/open/${filepath}`, null, {
|
|
338
|
+
params: { newLeaf }
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async getActiveFile(): Promise<NoteJson> {
|
|
344
|
+
return this.safeRequest(async () => {
|
|
345
|
+
const response = await this.client.get<NoteJson>("/active/", {
|
|
346
|
+
headers: {
|
|
347
|
+
"Accept": "application/vnd.olrapi.note+json"
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
return response.data;
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async updateActiveFile(content: string): Promise<void> {
|
|
355
|
+
return this.safeRequest(async () => {
|
|
356
|
+
await this.client.put("/active/", content, {
|
|
357
|
+
headers: {
|
|
358
|
+
"Content-Type": "text/markdown"
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async deleteActiveFile(): Promise<void> {
|
|
365
|
+
return this.safeRequest(async () => {
|
|
366
|
+
await this.client.delete("/active/");
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
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> {
|
|
381
|
+
return this.safeRequest(async () => {
|
|
382
|
+
const headers: Record<string, string> = {
|
|
383
|
+
"Operation": operation,
|
|
384
|
+
"Target-Type": targetType,
|
|
385
|
+
"Target": target,
|
|
386
|
+
"Content-Type": options?.contentType || "text/markdown"
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
if (options?.delimiter) {
|
|
390
|
+
headers["Target-Delimiter"] = options.delimiter;
|
|
391
|
+
}
|
|
392
|
+
if (options?.trimWhitespace !== undefined) {
|
|
393
|
+
headers["Trim-Target-Whitespace"] = options.trimWhitespace.toString();
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
await this.client.patch("/active/", content, { headers });
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async getPeriodicNote(period: PeriodType["type"]): Promise<NoteJson> {
|
|
401
|
+
return this.safeRequest(async () => {
|
|
402
|
+
const response = await this.client.get<NoteJson>(`/periodic/${period}/`, {
|
|
403
|
+
headers: {
|
|
404
|
+
"Accept": "application/vnd.olrapi.note+json"
|
|
405
|
+
}
|
|
406
|
+
});
|
|
232
407
|
return response.data;
|
|
233
408
|
});
|
|
234
409
|
}
|
|
410
|
+
|
|
411
|
+
async updatePeriodicNote(period: PeriodType["type"], content: string): Promise<void> {
|
|
412
|
+
return this.safeRequest(async () => {
|
|
413
|
+
await this.client.put(`/periodic/${period}/`, content, {
|
|
414
|
+
headers: {
|
|
415
|
+
"Content-Type": "text/markdown"
|
|
416
|
+
}
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async deletePeriodicNote(period: PeriodType["type"]): Promise<void> {
|
|
422
|
+
return this.safeRequest(async () => {
|
|
423
|
+
await this.client.delete(`/periodic/${period}/`);
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
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> {
|
|
439
|
+
return this.safeRequest(async () => {
|
|
440
|
+
const headers: Record<string, string> = {
|
|
441
|
+
"Operation": operation,
|
|
442
|
+
"Target-Type": targetType,
|
|
443
|
+
"Target": target,
|
|
444
|
+
"Content-Type": options?.contentType || "text/markdown"
|
|
445
|
+
};
|
|
446
|
+
|
|
447
|
+
if (options?.delimiter) {
|
|
448
|
+
headers["Target-Delimiter"] = options.delimiter;
|
|
449
|
+
}
|
|
450
|
+
if (options?.trimWhitespace !== undefined) {
|
|
451
|
+
headers["Trim-Target-Whitespace"] = options.trimWhitespace.toString();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
await this.client.patch(`/periodic/${period}/`, content, { headers });
|
|
455
|
+
});
|
|
456
|
+
}
|
|
235
457
|
}
|
package/src/properties.ts
CHANGED
|
@@ -82,7 +82,8 @@ export class PropertyManager {
|
|
|
82
82
|
*/
|
|
83
83
|
mergeProperties(
|
|
84
84
|
existing: ObsidianProperties,
|
|
85
|
-
updates: Partial<ObsidianProperties
|
|
85
|
+
updates: Partial<ObsidianProperties>,
|
|
86
|
+
replace: boolean = false
|
|
86
87
|
): ObsidianProperties {
|
|
87
88
|
const merged = { ...existing };
|
|
88
89
|
|
|
@@ -92,11 +93,11 @@ export class PropertyManager {
|
|
|
92
93
|
|
|
93
94
|
const currentValue = merged[key as keyof ObsidianProperties];
|
|
94
95
|
|
|
95
|
-
//
|
|
96
|
+
// Handle arrays based on replace flag
|
|
96
97
|
if (Array.isArray(value) && Array.isArray(currentValue)) {
|
|
97
|
-
merged[key as keyof ObsidianProperties] =
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
merged[key as keyof ObsidianProperties] = replace ?
|
|
99
|
+
value :
|
|
100
|
+
[...new Set([...currentValue, ...value])] as any;
|
|
100
101
|
}
|
|
101
102
|
// Special handling for custom object - deep merge
|
|
102
103
|
else if (key === 'custom' && typeof value === 'object' && value !== null) {
|
|
@@ -144,7 +145,8 @@ export class PropertyManager {
|
|
|
144
145
|
*/
|
|
145
146
|
async updateProperties(
|
|
146
147
|
filepath: string,
|
|
147
|
-
newProperties: Partial<ObsidianProperties
|
|
148
|
+
newProperties: Partial<ObsidianProperties>,
|
|
149
|
+
replace: boolean = false
|
|
148
150
|
): Promise<PropertyManagerResult> {
|
|
149
151
|
try {
|
|
150
152
|
// Validate new properties
|
|
@@ -162,7 +164,7 @@ export class PropertyManager {
|
|
|
162
164
|
const existingProperties = this.parseProperties(content);
|
|
163
165
|
|
|
164
166
|
// Merge properties
|
|
165
|
-
const mergedProperties = this.mergeProperties(existingProperties, newProperties);
|
|
167
|
+
const mergedProperties = this.mergeProperties(existingProperties, newProperties, replace);
|
|
166
168
|
|
|
167
169
|
// Generate new frontmatter
|
|
168
170
|
const newFrontmatter = this.generateProperties(mergedProperties);
|
package/src/propertyTools.ts
CHANGED
|
@@ -16,6 +16,7 @@ interface GetPropertiesArgs {
|
|
|
16
16
|
interface UpdatePropertiesArgs {
|
|
17
17
|
filepath: string;
|
|
18
18
|
properties: Partial<ObsidianProperties>;
|
|
19
|
+
replace?: boolean;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export class GetPropertiesToolHandler extends BaseToolHandler<GetPropertiesArgs> {
|
|
@@ -79,7 +80,7 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
|
|
|
79
80
|
getToolDescription(): Tool {
|
|
80
81
|
return {
|
|
81
82
|
name: this.name,
|
|
82
|
-
description: "Update properties in an Obsidian note's YAML frontmatter. Intelligently merges arrays (tags, type, status), handles custom fields, and automatically manages timestamps
|
|
83
|
+
description: "Update properties in an Obsidian note's YAML frontmatter. Intelligently merges arrays (tags, type, status), handles custom fields, and automatically manages timestamps. Valid property types:\n- type: Any string value\n- status: ['draft', 'in-progress', 'review', 'complete']\n- tags: Array of strings starting with '#'\n- Other fields: title, author, version, platform, repository (URI), dependencies, sources, urls (URI), papers, custom (object)",
|
|
83
84
|
examples: [
|
|
84
85
|
{
|
|
85
86
|
description: "Update basic metadata",
|
|
@@ -93,14 +94,14 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
|
|
|
93
94
|
}
|
|
94
95
|
},
|
|
95
96
|
{
|
|
96
|
-
description: "Update tags and status",
|
|
97
|
+
description: "Update tags and status with replace",
|
|
97
98
|
args: {
|
|
98
99
|
filepath: "docs/feature.md",
|
|
99
100
|
properties: {
|
|
100
101
|
tags: ["#feature", "#in-development", "#high-priority"],
|
|
101
|
-
status: ["in-progress"]
|
|
102
|
-
|
|
103
|
-
|
|
102
|
+
status: ["in-progress"]
|
|
103
|
+
},
|
|
104
|
+
replace: true
|
|
104
105
|
}
|
|
105
106
|
},
|
|
106
107
|
{
|
|
@@ -131,23 +132,9 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
|
|
|
131
132
|
properties: {
|
|
132
133
|
title: { type: "string" },
|
|
133
134
|
author: { type: "string" },
|
|
134
|
-
// Note: created and modified timestamps are managed automatically
|
|
135
135
|
type: {
|
|
136
136
|
type: "array",
|
|
137
|
-
items: {
|
|
138
|
-
type: "string",
|
|
139
|
-
enum: [
|
|
140
|
-
"concept",
|
|
141
|
-
"architecture",
|
|
142
|
-
"specification",
|
|
143
|
-
"protocol",
|
|
144
|
-
"api",
|
|
145
|
-
"research",
|
|
146
|
-
"implementation",
|
|
147
|
-
"guide",
|
|
148
|
-
"reference"
|
|
149
|
-
]
|
|
150
|
-
}
|
|
137
|
+
items: { type: "string" }
|
|
151
138
|
},
|
|
152
139
|
tags: {
|
|
153
140
|
type: "array",
|
|
@@ -185,6 +172,10 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
|
|
|
185
172
|
}
|
|
186
173
|
},
|
|
187
174
|
additionalProperties: false
|
|
175
|
+
},
|
|
176
|
+
replace: {
|
|
177
|
+
type: "boolean",
|
|
178
|
+
description: "If true, arrays will be replaced instead of merged"
|
|
188
179
|
}
|
|
189
180
|
},
|
|
190
181
|
required: ["filepath", "properties"]
|
|
@@ -196,7 +187,8 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
|
|
|
196
187
|
try {
|
|
197
188
|
const result = await this.propertyManager.updateProperties(
|
|
198
189
|
args.filepath,
|
|
199
|
-
args.properties
|
|
190
|
+
args.properties,
|
|
191
|
+
args.replace
|
|
200
192
|
);
|
|
201
193
|
return this.createResponse(result);
|
|
202
194
|
} catch (error) {
|
package/src/propertyTypes.ts
CHANGED
|
@@ -1,17 +1,8 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
|
|
3
3
|
// Define validation schemas
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
"architecture",
|
|
7
|
-
"specification",
|
|
8
|
-
"protocol",
|
|
9
|
-
"api",
|
|
10
|
-
"research",
|
|
11
|
-
"implementation",
|
|
12
|
-
"guide",
|
|
13
|
-
"reference"
|
|
14
|
-
]);
|
|
4
|
+
// Allow any string for type to be more flexible
|
|
5
|
+
export const PropertyType = z.string();
|
|
15
6
|
|
|
16
7
|
export const StatusEnum = z.enum([
|
|
17
8
|
"draft",
|
|
@@ -29,7 +20,7 @@ export const ObsidianPropertiesSchema = z.object({
|
|
|
29
20
|
author: z.string().optional(),
|
|
30
21
|
|
|
31
22
|
// Classification
|
|
32
|
-
type: z.array(
|
|
23
|
+
type: z.array(PropertyType).optional(),
|
|
33
24
|
|
|
34
25
|
// Organization
|
|
35
26
|
tags: z.array(z.string().startsWith("#")).optional(),
|
|
@@ -57,7 +48,7 @@ export const PropertyUpdateSchema = z.object({
|
|
|
57
48
|
author: z.string().optional(),
|
|
58
49
|
|
|
59
50
|
// Classification
|
|
60
|
-
type: z.array(
|
|
51
|
+
type: z.array(PropertyType).optional(),
|
|
61
52
|
|
|
62
53
|
// Organization
|
|
63
54
|
tags: z.array(z.string().startsWith("#")).optional(),
|
|
@@ -85,6 +76,7 @@ export interface PropertyOperation {
|
|
|
85
76
|
operation: 'get' | 'update' | 'patch';
|
|
86
77
|
filepath: string;
|
|
87
78
|
properties?: Partial<ObsidianProperties>;
|
|
79
|
+
replace?: boolean; // Add replace flag
|
|
88
80
|
}
|
|
89
81
|
|
|
90
82
|
export interface ValidationResult {
|