feat: Phase 8 - Write Operations & Concurrency

Implement safe write operations with concurrency control, partial updates,
conflict resolution, and file rename/move with automatic link updates.

New Tools:
- update_frontmatter: Partial frontmatter updates with concurrency control
- update_sections: Line-based section edits to reduce race conditions
- rename_file: File rename/move with automatic wikilink updates

Enhanced Tools:
- create_note: Added onConflict strategies (error, overwrite, rename)
- delete_note: Added soft delete, dryRun, and concurrency control

Key Features:
- ETag-based optimistic locking via ifMatch parameter
- Version tracking on all write operations
- Conflict resolution strategies
- Link integrity maintenance during file operations
- Safe operations with preview and recovery options

Files Created:
- src/utils/version-utils.ts

Files Modified:
- src/tools/note-tools.ts
- src/utils/frontmatter-utils.ts
- src/tools/index.ts
- src/types/mcp-types.ts
- ROADMAP.md
- CHANGELOG.md

Fixes:
- Fixed rename_file backlinks API issue (not available in Obsidian API)
- Fixed update_frontmatter null-object error when patch is undefined
This commit is contained in:
2025-10-17 00:38:45 -04:00
parent 4e399e00f8
commit 99e2ade3ca
7 changed files with 1115 additions and 64 deletions

View File

@@ -133,6 +133,69 @@ export class FrontmatterUtils {
return content.startsWith('---\n') || content.startsWith('---\r\n');
}
/**
* Serialize frontmatter object to YAML string with delimiters
* Returns the complete frontmatter block including --- delimiters
*/
static serializeFrontmatter(data: Record<string, any>): string {
if (!data || Object.keys(data).length === 0) {
return '';
}
const lines: string[] = ['---'];
for (const [key, value] of Object.entries(data)) {
if (value === undefined || value === null) {
continue;
}
// Handle different value types
if (Array.isArray(value)) {
// Array format
if (value.length === 0) {
lines.push(`${key}: []`);
} else {
lines.push(`${key}:`);
for (const item of value) {
const itemStr = typeof item === 'string' ? item : JSON.stringify(item);
lines.push(` - ${itemStr}`);
}
}
} else if (typeof value === 'object') {
// Object format (nested)
lines.push(`${key}:`);
for (const [subKey, subValue] of Object.entries(value)) {
const subValueStr = typeof subValue === 'string' ? subValue : JSON.stringify(subValue);
lines.push(` ${subKey}: ${subValueStr}`);
}
} else if (typeof value === 'string') {
// String - check if needs quoting
const needsQuotes = value.includes(':') || value.includes('#') ||
value.includes('[') || value.includes(']') ||
value.includes('{') || value.includes('}') ||
value.includes('|') || value.includes('>') ||
value.startsWith(' ') || value.endsWith(' ');
if (needsQuotes) {
// Escape quotes in the string
const escaped = value.replace(/"/g, '\\"');
lines.push(`${key}: "${escaped}"`);
} else {
lines.push(`${key}: ${value}`);
}
} else if (typeof value === 'number' || typeof value === 'boolean') {
// Number or boolean - direct serialization
lines.push(`${key}: ${value}`);
} else {
// Fallback to JSON stringification
lines.push(`${key}: ${JSON.stringify(value)}`);
}
}
lines.push('---');
return lines.join('\n');
}
/**
* Parse Excalidraw file metadata
* Excalidraw files are JSON with special structure