UNPKG

obsidian-mcp-server

Version:

Model Context Protocol (MCP) server designed for LLMs to interact with Obsidian vaults. Provides secure, token-aware tools for seamless knowledge base management through a standardized interface.

233 lines (206 loc) 7.56 kB
/** * Property manager for Obsidian notes */ import { EOL } from 'os'; import { parse, stringify } from 'yaml'; import { ObsidianClient } from '../../obsidian/client.js'; import { createLogger } from '../../utils/logging.js'; import { ObsidianProperties, ObsidianPropertiesSchema, PropertyManagerResult, PropertyUpdateSchema, ValidationResult } from './types.js'; // Create a logger for property operations const logger = createLogger('PropertyManager'); /** * Manages YAML frontmatter properties in Obsidian notes */ export class PropertyManager { constructor(private client: ObsidianClient) {} /** * Parse YAML frontmatter from note content * @param content The note content * @returns Extracted properties */ parseProperties(content: string): ObsidianProperties { try { // Extract frontmatter between --- markers (handles both \n and \r\n) const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!match) { logger.debug('No frontmatter found in content'); return {}; } const frontmatter = match[1]; const properties = parse(frontmatter); // Handle tags - don't add # prefix in frontmatter if (properties.tags && Array.isArray(properties.tags)) { properties.tags = properties.tags.map((tag: string) => tag.startsWith('#') ? tag.substring(1) : tag ); } // Validate against schema const result = ObsidianPropertiesSchema.safeParse(properties); if (!result.success) { logger.warn('Property validation warnings:', { validationError: result.error }); // Return the properties with fixed tags return properties; } return result.data; } catch (error) { logger.error('Error parsing properties:', error instanceof Error ? error : { error: String(error) }); return {}; } } /** * Generate YAML frontmatter from properties * @param properties The properties to convert to YAML * @returns YAML frontmatter string */ generateProperties(properties: Partial<ObsidianProperties>): string { try { // Remove undefined values const cleanProperties = Object.fromEntries( Object.entries(properties).filter(([_, v]) => v !== undefined) ); // Generate YAML with platform-specific line endings const yaml = stringify(cleanProperties); return `---${EOL}${yaml}---${EOL}`; } catch (error) { logger.error('Error generating properties:', error instanceof Error ? error : { error: String(error) }); throw error; } } /** * Validate property values against schema * @param properties The properties to validate * @returns Validation result */ validateProperties(properties: Partial<ObsidianProperties>): ValidationResult { const result = PropertyUpdateSchema.safeParse(properties); if (result.success) { return { valid: true, errors: [] }; } return { valid: false, errors: result.error.errors.map(err => `${err.path.join('.')}: ${err.message}` ) }; } /** * Merge new properties with existing ones * @param existing The existing properties * @param updates The new properties to merge * @param replace Whether to replace arrays instead of merging them * @returns The merged properties */ mergeProperties( existing: ObsidianProperties, updates: Partial<ObsidianProperties>, replace: boolean = false ): ObsidianProperties { const merged = { ...existing }; for (const [key, value] of Object.entries(updates)) { // Skip undefined values and timestamp fields if (value === undefined || key === 'created' || key === 'modified') continue; const currentValue = merged[key as keyof ObsidianProperties]; // Handle arrays based on replace flag if (Array.isArray(value) && Array.isArray(currentValue)) { merged[key as keyof ObsidianProperties] = replace ? value : [...new Set([...currentValue, ...value])] as any; } // Special handling for custom object - deep merge else if (key === 'custom' && typeof value === 'object' && value !== null) { merged.custom = { ...merged.custom, ...value }; } // Default case - replace value else { merged[key as keyof ObsidianProperties] = value as any; } } // Always update modified date (this is the only place we set it) merged.modified = new Date().toISOString(); return merged; } /** * Get properties from a note * @param filepath Path to the note * @returns The properties from the note */ async getProperties(filepath: string): Promise<PropertyManagerResult> { try { logger.debug(`Getting properties from file: ${filepath}`); const content = await this.client.getFileContents(filepath); const properties = this.parseProperties(content); return { success: true, message: 'Properties retrieved successfully', properties }; } catch (error) { logger.error(`Failed to get properties from ${filepath}:`, error instanceof Error ? error : { error: String(error) }); return { success: false, message: `Failed to get properties: ${error instanceof Error ? error.message : String(error)}`, errors: [String(error)] }; } } /** * Update properties of a note * @param filepath Path to the note * @param newProperties The new properties to apply * @param replace Whether to replace arrays instead of merging them * @returns The result of the update operation */ async updateProperties( filepath: string, newProperties: Partial<ObsidianProperties>, replace: boolean = false ): Promise<PropertyManagerResult> { try { // Validate new properties const validation = this.validateProperties(newProperties); if (!validation.valid) { logger.warn(`Invalid properties for ${filepath}:`, { errors: validation.errors }); return { success: false, message: 'Invalid properties', errors: validation.errors }; } // Get existing content and properties logger.debug(`Updating properties for file: ${filepath}`); const content = await this.client.getFileContents(filepath); const existingProperties = this.parseProperties(content); // Merge properties const mergedProperties = this.mergeProperties(existingProperties, newProperties, replace); // Generate new frontmatter const newFrontmatter = this.generateProperties(mergedProperties); // Replace existing frontmatter or prepend to file (handles both \n and \r\n) const newContent = content.replace(/^---[\s\S]*?---\r?\n/, '') || ''; const updatedContent = newFrontmatter + newContent; // Update file await this.client.updateContent(filepath, updatedContent); logger.debug(`Successfully updated properties for ${filepath}`); return { success: true, message: 'Properties updated successfully', properties: mergedProperties }; } catch (error) { logger.error(`Failed to update properties for ${filepath}:`, error instanceof Error ? error : { error: String(error) }); return { success: false, message: `Failed to update properties: ${error instanceof Error ? error.message : String(error)}`, errors: [String(error)] }; } } }