obsidian-mcp-server 1.2.2 → 1.2.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "obsidian-mcp-server",
3
- "version": "1.2.2",
3
+ "version": "1.2.4",
4
4
  "description": "Model Context Protocol server for Obsidian integration with token-aware response handling",
5
5
  "main": "build/index.js",
6
6
  "type": "module",
@@ -18,7 +18,7 @@
18
18
  "format": "prettier --write \"src/**/*.ts\""
19
19
  },
20
20
  "dependencies": {
21
- "@modelcontextprotocol/sdk": "^1.4.0",
21
+ "@modelcontextprotocol/sdk": "^1.4.1",
22
22
  "axios": "^1.7.9",
23
23
  "dotenv": "^16.4.7",
24
24
  "tiktoken": "^1.0.18",
@@ -29,7 +29,7 @@
29
29
  "@types/node": "^22.10.10",
30
30
  "@typescript-eslint/eslint-plugin": "^8.21.0",
31
31
  "@typescript-eslint/parser": "^8.21.0",
32
- "eslint": "^9.18.0",
32
+ "eslint": "^9.19.0",
33
33
  "eslint-config-prettier": "^10.0.1",
34
34
  "eslint-plugin-prettier": "^5.2.3",
35
35
  "prettier": "^3.4.2",
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 { ObsidianConfig, ObsidianError, ObsidianFile, SearchResult, DEFAULT_OBSIDIAN_CONFIG, ObsidianServerConfig, JsonLogicQuery } from "./types.js";
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,7 +41,7 @@ export class ObsidianClient {
26
41
 
27
42
  constructor(config: ObsidianConfig) {
28
43
  if (!config.apiKey) {
29
- throw new ObsidianError("API key is required", 401);
44
+ throw new ObsidianError("API key is required", 40100); // 40100 = Unauthorized
30
45
  }
31
46
 
32
47
  // Combine defaults with provided config
@@ -34,7 +49,7 @@ export class ObsidianClient {
34
49
  ...DEFAULT_OBSIDIAN_CONFIG,
35
50
  verifySSL: config.verifySSL ?? process.env.NODE_ENV === 'production', // Enable SSL verification in production by default
36
51
  apiKey: config.apiKey,
37
- timeout: config.timeout ?? 5000,
52
+ timeout: config.timeout ?? 5000, // 5 second default timeout
38
53
  maxContentLength: config.maxContentLength ?? 50 * 1024 * 1024, // 50MB
39
54
  maxBodyLength: config.maxBodyLength ?? 50 * 1024 * 1024 // 50MB
40
55
  };
@@ -106,12 +121,39 @@ export class ObsidianClient {
106
121
  // Prevent path traversal attacks
107
122
  const normalizedPath = filepath.replace(/\\/g, '/');
108
123
  if (normalizedPath.includes('../') || normalizedPath.includes('..\\')) {
109
- throw new ObsidianError('Invalid file path: Path traversal not allowed', 400);
124
+ throw new ObsidianError('Invalid file path: Path traversal not allowed', 40001); // 40001 = Path traversal error
110
125
  }
111
126
 
112
127
  // Additional path validations
113
128
  if (normalizedPath.startsWith('/') || /^[a-zA-Z]:/.test(normalizedPath)) {
114
- throw new ObsidianError('Invalid file path: Absolute paths not allowed', 400);
129
+ throw new ObsidianError('Invalid file path: Absolute paths not allowed', 40002); // 40002 = Invalid path format
130
+ }
131
+ }
132
+
133
+ private getErrorCode(status: number): number {
134
+ // Convert HTTP status codes to 5-digit error codes
135
+ switch (status) {
136
+ // Client errors (400-499)
137
+ case 400: return 40000; // Bad request
138
+ case 401: return 40100; // Unauthorized
139
+ case 403: return 40300; // Forbidden
140
+ case 404: return 40400; // Not found
141
+ case 405: return 40500; // Method not allowed
142
+ case 409: return 40900; // Conflict
143
+ case 429: return 42900; // Too many requests
144
+
145
+ // Server errors (500-599)
146
+ case 500: return 50000; // Internal server error
147
+ case 501: return 50100; // Not implemented
148
+ case 502: return 50200; // Bad gateway
149
+ case 503: return 50300; // Service unavailable
150
+ case 504: return 50400; // Gateway timeout
151
+
152
+ // Default cases
153
+ default:
154
+ if (status >= 400 && status < 500) return 40000 + (status - 400) * 100;
155
+ if (status >= 500 && status < 600) return 50000 + (status - 500) * 100;
156
+ return 50000; // Default to internal server error
115
157
  }
116
158
  }
117
159
 
@@ -120,14 +162,28 @@ export class ObsidianClient {
120
162
  return await operation();
121
163
  } catch (error) {
122
164
  if (axios.isAxiosError(error)) {
123
- const axiosError = error as AxiosError<{ errorCode?: number; message?: string }>;
165
+ const axiosError = error as AxiosError<ApiError>;
124
166
  const response = axiosError.response;
125
167
  const errorData = response?.data;
126
- const code = errorData?.errorCode ?? response?.status ?? 500;
127
- const message = errorData?.message ?? axiosError.message ?? "Unknown error";
128
- throw new ObsidianError(message, code, errorData);
168
+
169
+ // If the API returns a proper 5-digit error code, use it
170
+ // Otherwise, convert HTTP status to 5-digit code
171
+ const errorCode = errorData?.errorCode ??
172
+ this.getErrorCode(response?.status ?? 500);
173
+
174
+ const message = errorData?.message ??
175
+ axiosError.message ??
176
+ "Unknown error";
177
+
178
+ throw new ObsidianError(message, errorCode, errorData);
129
179
  }
130
- throw error;
180
+
181
+ // For non-Axios errors, use a generic server error code
182
+ if (error instanceof Error) {
183
+ throw new ObsidianError(error.message, 50000, error);
184
+ }
185
+
186
+ throw new ObsidianError("Unknown error occurred", 50000, error);
131
187
  }
132
188
  }
133
189
 
@@ -160,16 +216,17 @@ export class ObsidianClient {
160
216
  });
161
217
  }
162
218
 
163
- async search(query: string, contextLength: number = 100): Promise<SearchResult[]> {
219
+ async search(query: string, contextLength: number = 100): Promise<SimpleSearchResult[]> {
164
220
  return this.safeRequest(async () => {
165
221
  const requestId = crypto.randomUUID();
166
222
  console.debug(`[${requestId}] Performing simple search: ${query}`);
167
- const response = await this.client.post<SearchResult[]>("/search/simple/", undefined, {
168
- params: {
169
- query,
170
- contextLength
223
+ const response = await this.client.post<SimpleSearchResult[]>(
224
+ "/search/simple/",
225
+ null,
226
+ {
227
+ params: { query, contextLength }
171
228
  }
172
- });
229
+ );
173
230
  return response.data;
174
231
  });
175
232
  }
@@ -177,7 +234,7 @@ export class ObsidianClient {
177
234
  async appendContent(filepath: string, content: string): Promise<void> {
178
235
  this.validateFilePath(filepath);
179
236
  if (!content || typeof content !== 'string') {
180
- throw new ObsidianError('Invalid content: Content must be a non-empty string', 400);
237
+ throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003); // 40003 = Invalid content
181
238
  }
182
239
  return this.safeRequest(async () => {
183
240
  const requestId = crypto.randomUUID();
@@ -197,7 +254,7 @@ export class ObsidianClient {
197
254
  async updateContent(filepath: string, content: string): Promise<void> {
198
255
  this.validateFilePath(filepath);
199
256
  if (!content || typeof content !== 'string') {
200
- throw new ObsidianError('Invalid content: Content must be a non-empty string', 400);
257
+ throw new ObsidianError('Invalid content: Content must be a non-empty string', 40003); // 40003 = Invalid content
201
258
  }
202
259
 
203
260
  return this.safeRequest(async () => {
@@ -215,21 +272,187 @@ export class ObsidianClient {
215
272
  });
216
273
  }
217
274
 
218
- async searchJson(query: JsonLogicQuery): Promise<SearchResult[]> {
275
+ async searchJson(query: JsonLogicQuery): Promise<SearchResponse[]> {
219
276
  return this.safeRequest(async () => {
220
277
  const requestId = crypto.randomUUID();
221
278
  console.debug(`[${requestId}] Performing complex search with query:`, JSON.stringify(query));
222
- const response = await this.client.post<SearchResult[]>(
279
+
280
+ // Check if this is a tag-based search
281
+ const isTagSearch = JSON.stringify(query).includes('"contains"') &&
282
+ JSON.stringify(query).includes('"#"');
283
+
284
+ const response = await this.client.post(
223
285
  "/search/",
224
286
  query,
225
287
  {
226
288
  headers: {
227
289
  "Content-Type": "application/vnd.olrapi.jsonlogic+json",
228
- "Accept": "application/json"
290
+ "Accept": "application/vnd.olrapi.note+json"
229
291
  }
230
292
  }
231
293
  );
294
+
295
+ if (isTagSearch) {
296
+ return response.data as SimpleSearchResult[];
297
+ }
298
+ return response.data as SearchResult[];
299
+ });
300
+ }
301
+
302
+ async getStatus(): Promise<ObsidianStatus> {
303
+ return this.safeRequest(async () => {
304
+ const requestId = crypto.randomUUID();
305
+ console.debug(`[${requestId}] Getting server status`);
306
+ const response = await this.client.get<ObsidianStatus>("/");
232
307
  return response.data;
233
308
  });
234
309
  }
310
+
311
+ async listCommands(): Promise<ObsidianCommand[]> {
312
+ return this.safeRequest(async () => {
313
+ const requestId = crypto.randomUUID();
314
+ console.debug(`[${requestId}] Listing available commands`);
315
+ const response = await this.client.get<{commands: ObsidianCommand[]}>("/commands/");
316
+ return response.data.commands;
317
+ });
318
+ }
319
+
320
+ async executeCommand(commandId: string): Promise<void> {
321
+ return this.safeRequest(async () => {
322
+ const requestId = crypto.randomUUID();
323
+ console.debug(`[${requestId}] Executing command: ${commandId}`);
324
+ await this.client.post(`/commands/${commandId}/`);
325
+ });
326
+ }
327
+
328
+ async openFile(filepath: string, newLeaf: boolean = false): Promise<void> {
329
+ this.validateFilePath(filepath);
330
+ return this.safeRequest(async () => {
331
+ const requestId = crypto.randomUUID();
332
+ console.debug(`[${requestId}] Opening file: ${filepath}`);
333
+ await this.client.post(`/open/${filepath}`, null, {
334
+ params: { newLeaf }
335
+ });
336
+ });
337
+ }
338
+
339
+ async getActiveFile(): Promise<NoteJson> {
340
+ return this.safeRequest(async () => {
341
+ const requestId = crypto.randomUUID();
342
+ console.debug(`[${requestId}] Getting active file`);
343
+ const response = await this.client.get<NoteJson>("/active/", {
344
+ headers: {
345
+ "Accept": "application/vnd.olrapi.note+json"
346
+ }
347
+ });
348
+ return response.data;
349
+ });
350
+ }
351
+
352
+ async updateActiveFile(content: string): Promise<void> {
353
+ return this.safeRequest(async () => {
354
+ const requestId = crypto.randomUUID();
355
+ console.debug(`[${requestId}] Updating active file`);
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
+ const requestId = crypto.randomUUID();
367
+ console.debug(`[${requestId}] Deleting active file`);
368
+ await this.client.delete("/active/");
369
+ });
370
+ }
371
+
372
+ async patchActiveFile(operation: "append" | "prepend" | "replace", targetType: "heading" | "block" | "frontmatter", target: string, content: string, options?: {
373
+ delimiter?: string;
374
+ trimWhitespace?: boolean;
375
+ contentType?: "text/markdown" | "application/json";
376
+ }): Promise<void> {
377
+ return this.safeRequest(async () => {
378
+ const requestId = crypto.randomUUID();
379
+ console.debug(`[${requestId}] Patching active file: ${operation} ${targetType} ${target}`);
380
+
381
+ const headers: Record<string, string> = {
382
+ "Operation": operation,
383
+ "Target-Type": targetType,
384
+ "Target": target,
385
+ "Content-Type": options?.contentType || "text/markdown"
386
+ };
387
+
388
+ if (options?.delimiter) {
389
+ headers["Target-Delimiter"] = options.delimiter;
390
+ }
391
+ if (options?.trimWhitespace !== undefined) {
392
+ headers["Trim-Target-Whitespace"] = options.trimWhitespace.toString();
393
+ }
394
+
395
+ await this.client.patch("/active/", content, { headers });
396
+ });
397
+ }
398
+
399
+ async getPeriodicNote(period: PeriodType["type"]): Promise<NoteJson> {
400
+ return this.safeRequest(async () => {
401
+ const requestId = crypto.randomUUID();
402
+ console.debug(`[${requestId}] Getting ${period} note`);
403
+ const response = await this.client.get<NoteJson>(`/periodic/${period}/`, {
404
+ headers: {
405
+ "Accept": "application/vnd.olrapi.note+json"
406
+ }
407
+ });
408
+ return response.data;
409
+ });
410
+ }
411
+
412
+ async updatePeriodicNote(period: PeriodType["type"], content: string): Promise<void> {
413
+ return this.safeRequest(async () => {
414
+ const requestId = crypto.randomUUID();
415
+ console.debug(`[${requestId}] Updating ${period} note`);
416
+ await this.client.put(`/periodic/${period}/`, content, {
417
+ headers: {
418
+ "Content-Type": "text/markdown"
419
+ }
420
+ });
421
+ });
422
+ }
423
+
424
+ async deletePeriodicNote(period: PeriodType["type"]): Promise<void> {
425
+ return this.safeRequest(async () => {
426
+ const requestId = crypto.randomUUID();
427
+ console.debug(`[${requestId}] Deleting ${period} note`);
428
+ await this.client.delete(`/periodic/${period}/`);
429
+ });
430
+ }
431
+
432
+ async patchPeriodicNote(period: PeriodType["type"], operation: "append" | "prepend" | "replace", targetType: "heading" | "block" | "frontmatter", target: string, content: string, options?: {
433
+ delimiter?: string;
434
+ trimWhitespace?: boolean;
435
+ contentType?: "text/markdown" | "application/json";
436
+ }): Promise<void> {
437
+ return this.safeRequest(async () => {
438
+ const requestId = crypto.randomUUID();
439
+ console.debug(`[${requestId}] Patching ${period} note: ${operation} ${targetType} ${target}`);
440
+
441
+ const headers: Record<string, string> = {
442
+ "Operation": operation,
443
+ "Target-Type": targetType,
444
+ "Target": target,
445
+ "Content-Type": options?.contentType || "text/markdown"
446
+ };
447
+
448
+ if (options?.delimiter) {
449
+ headers["Target-Delimiter"] = options.delimiter;
450
+ }
451
+ if (options?.trimWhitespace !== undefined) {
452
+ headers["Trim-Target-Whitespace"] = options.trimWhitespace.toString();
453
+ }
454
+
455
+ await this.client.patch(`/periodic/${period}/`, content, { headers });
456
+ });
457
+ }
235
458
  }
package/src/properties.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { parse, stringify } from 'yaml';
2
2
  import { ObsidianClient } from './obsidian.js';
3
- import {
4
- ObsidianProperties,
5
- ObsidianPropertiesSchema,
3
+ import {
4
+ ObsidianProperties,
5
+ ObsidianPropertiesSchema,
6
+ PropertyUpdateSchema,
6
7
  PropertyManagerResult,
7
- ValidationResult
8
+ ValidationResult
8
9
  } from './propertyTypes.js';
9
10
 
10
11
  export class PropertyManager {
@@ -62,7 +63,7 @@ export class PropertyManager {
62
63
  * Validate property values
63
64
  */
64
65
  validateProperties(properties: Partial<ObsidianProperties>): ValidationResult {
65
- const result = ObsidianPropertiesSchema.safeParse(properties);
66
+ const result = PropertyUpdateSchema.safeParse(properties);
66
67
 
67
68
  if (result.success) {
68
69
  return { valid: true, errors: [] };
@@ -70,7 +71,7 @@ export class PropertyManager {
70
71
 
71
72
  return {
72
73
  valid: false,
73
- errors: result.error.errors.map(err =>
74
+ errors: result.error.errors.map(err =>
74
75
  `${err.path.join('.')}: ${err.message}`
75
76
  )
76
77
  };
@@ -81,20 +82,22 @@ export class PropertyManager {
81
82
  */
82
83
  mergeProperties(
83
84
  existing: ObsidianProperties,
84
- updates: Partial<ObsidianProperties>
85
+ updates: Partial<ObsidianProperties>,
86
+ replace: boolean = false
85
87
  ): ObsidianProperties {
86
88
  const merged = { ...existing };
87
89
 
88
90
  for (const [key, value] of Object.entries(updates)) {
89
- if (value === undefined) continue;
91
+ // Skip undefined values and timestamp fields
92
+ if (value === undefined || key === 'created' || key === 'modified') continue;
90
93
 
91
94
  const currentValue = merged[key as keyof ObsidianProperties];
92
95
 
93
- // Special handling for arrays - merge rather than replace
96
+ // Handle arrays based on replace flag
94
97
  if (Array.isArray(value) && Array.isArray(currentValue)) {
95
- merged[key as keyof ObsidianProperties] = [
96
- ...new Set([...currentValue, ...value])
97
- ] as any;
98
+ merged[key as keyof ObsidianProperties] = replace ?
99
+ value :
100
+ [...new Set([...currentValue, ...value])] as any;
98
101
  }
99
102
  // Special handling for custom object - deep merge
100
103
  else if (key === 'custom' && typeof value === 'object' && value !== null) {
@@ -109,7 +112,7 @@ export class PropertyManager {
109
112
  }
110
113
  }
111
114
 
112
- // Always update modified date
115
+ // Always update modified date (this is the only place we set it)
113
116
  merged.modified = new Date().toISOString();
114
117
 
115
118
  return merged;
@@ -142,7 +145,8 @@ export class PropertyManager {
142
145
  */
143
146
  async updateProperties(
144
147
  filepath: string,
145
- newProperties: Partial<ObsidianProperties>
148
+ newProperties: Partial<ObsidianProperties>,
149
+ replace: boolean = false
146
150
  ): Promise<PropertyManagerResult> {
147
151
  try {
148
152
  // Validate new properties
@@ -160,7 +164,7 @@ export class PropertyManager {
160
164
  const existingProperties = this.parseProperties(content);
161
165
 
162
166
  // Merge properties
163
- const mergedProperties = this.mergeProperties(existingProperties, newProperties);
167
+ const mergedProperties = this.mergeProperties(existingProperties, newProperties, replace);
164
168
 
165
169
  // Generate new frontmatter
166
170
  const newFrontmatter = this.generateProperties(mergedProperties);
@@ -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 updates the modified timestamp. Existing properties not included in the update are preserved.",
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
- version: "2.0.0"
103
- }
102
+ status: ["in-progress"]
103
+ },
104
+ replace: true
104
105
  }
105
106
  },
106
107
  {
@@ -130,25 +131,10 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
130
131
  description: "Properties to update",
131
132
  properties: {
132
133
  title: { type: "string" },
133
- created: { type: "string", format: "date-time" },
134
- modified: { type: "string", format: "date-time" },
135
134
  author: { type: "string" },
136
135
  type: {
137
136
  type: "array",
138
- items: {
139
- type: "string",
140
- enum: [
141
- "concept",
142
- "architecture",
143
- "specification",
144
- "protocol",
145
- "api",
146
- "research",
147
- "implementation",
148
- "guide",
149
- "reference"
150
- ]
151
- }
137
+ items: { type: "string" }
152
138
  },
153
139
  tags: {
154
140
  type: "array",
@@ -186,6 +172,10 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
186
172
  }
187
173
  },
188
174
  additionalProperties: false
175
+ },
176
+ replace: {
177
+ type: "boolean",
178
+ description: "If true, arrays will be replaced instead of merged"
189
179
  }
190
180
  },
191
181
  required: ["filepath", "properties"]
@@ -197,7 +187,8 @@ export class UpdatePropertiesToolHandler extends BaseToolHandler<UpdatePropertie
197
187
  try {
198
188
  const result = await this.propertyManager.updateProperties(
199
189
  args.filepath,
200
- args.properties
190
+ args.properties,
191
+ args.replace
201
192
  );
202
193
  return this.createResponse(result);
203
194
  } catch (error) {
@@ -1,17 +1,8 @@
1
1
  import { z } from "zod";
2
2
 
3
3
  // Define validation schemas
4
- export const PropertyTypeEnum = z.enum([
5
- "concept",
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",
@@ -20,15 +11,44 @@ export const StatusEnum = z.enum([
20
11
  "complete"
21
12
  ]);
22
13
 
14
+ // Schema for reading properties (includes timestamps)
23
15
  export const ObsidianPropertiesSchema = z.object({
16
+ // Basic Metadata
17
+ // Note: Timestamps are managed automatically
18
+ title: z.string().optional(),
19
+ modified: z.string().datetime().optional(), // Read-only, managed by MCP server
20
+ author: z.string().optional(),
21
+
22
+ // Classification
23
+ type: z.array(PropertyType).optional(),
24
+
25
+ // Organization
26
+ tags: z.array(z.string().startsWith("#")).optional(),
27
+
28
+ // Technical Metadata
29
+ status: z.array(StatusEnum).optional(),
30
+ version: z.string().optional(),
31
+ platform: z.string().optional(),
32
+ repository: z.string().url().optional(),
33
+ dependencies: z.array(z.string()).optional(),
34
+
35
+ // References
36
+ sources: z.array(z.string()).optional(),
37
+ urls: z.array(z.string().url()).optional(),
38
+ papers: z.array(z.string()).optional(),
39
+
40
+ // Custom Fields
41
+ custom: z.record(z.unknown()).optional()
42
+ });
43
+
44
+ // Schema for validating property updates (excludes timestamps)
45
+ export const PropertyUpdateSchema = z.object({
24
46
  // Basic Metadata
25
47
  title: z.string().optional(),
26
- created: z.string().datetime().optional(),
27
- modified: z.string().datetime().optional(),
28
48
  author: z.string().optional(),
29
49
 
30
50
  // Classification
31
- type: z.array(PropertyTypeEnum).optional(),
51
+ type: z.array(PropertyType).optional(),
32
52
 
33
53
  // Organization
34
54
  tags: z.array(z.string().startsWith("#")).optional(),
@@ -50,11 +70,13 @@ export const ObsidianPropertiesSchema = z.object({
50
70
  });
51
71
 
52
72
  export type ObsidianProperties = z.infer<typeof ObsidianPropertiesSchema>;
73
+ export type PropertyUpdate = z.infer<typeof PropertyUpdateSchema>;
53
74
 
54
75
  export interface PropertyOperation {
55
76
  operation: 'get' | 'update' | 'patch';
56
77
  filepath: string;
57
78
  properties?: Partial<ObsidianProperties>;
79
+ replace?: boolean; // Add replace flag
58
80
  }
59
81
 
60
82
  export interface ValidationResult {