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:
@@ -42,7 +42,7 @@ export class ToolRegistry {
|
||||
},
|
||||
{
|
||||
name: "create_note",
|
||||
description: "Create a new file in the Obsidian vault. Use this to create a new note or file. By default, parent folders must already exist. Set createParents to true to automatically create missing parent folders. Path must be vault-relative with file extension. Will fail if the file already exists. Use list_notes() to verify the parent folder exists before creating.",
|
||||
description: "Create a new file in the Obsidian vault with conflict handling. Returns structured JSON with success status, path, versionId, created timestamp, and conflict resolution details. Supports automatic parent folder creation and three conflict strategies: 'error' (default, fail if exists), 'overwrite' (replace existing), 'rename' (auto-generate unique name). Use this to create new notes with robust error handling.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -57,6 +57,11 @@ export class ToolRegistry {
|
||||
createParents: {
|
||||
type: "boolean",
|
||||
description: "If true, automatically create missing parent folders. If false (default), returns an error if parent folders don't exist. Default: false"
|
||||
},
|
||||
onConflict: {
|
||||
type: "string",
|
||||
enum: ["error", "overwrite", "rename"],
|
||||
description: "Conflict resolution strategy if file already exists. 'error' (default): fail with error. 'overwrite': delete existing file and create new. 'rename': auto-generate unique name by appending number. Default: 'error'"
|
||||
}
|
||||
},
|
||||
required: ["path", "content"]
|
||||
@@ -82,18 +87,114 @@ export class ToolRegistry {
|
||||
},
|
||||
{
|
||||
name: "delete_note",
|
||||
description: "Delete a file from the Obsidian vault. Use this to permanently remove a file. This only works on files, NOT folders. The file must exist. Path must be vault-relative with file extension. This operation cannot be undone through the API.",
|
||||
description: "Delete a file from the Obsidian vault with safety options. Returns structured JSON with deletion status, path, destination (for soft deletes), and operation mode. Supports soft delete (move to .trash folder, default) and permanent deletion. Use dryRun to preview deletion without executing. Includes concurrency control via ifMatch parameter. This only works on files, NOT folders.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Vault-relative path to the file to delete (e.g., 'folder/note.md'). Must be a file, not a folder. Must include file extension. Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
|
||||
},
|
||||
soft: {
|
||||
type: "boolean",
|
||||
description: "If true (default), move file to .trash folder (recoverable). If false, permanently delete (cannot be undone). Default: true"
|
||||
},
|
||||
dryRun: {
|
||||
type: "boolean",
|
||||
description: "If true, preview deletion without executing. Returns what would happen. If false (default), perform actual deletion. Default: false"
|
||||
},
|
||||
ifMatch: {
|
||||
type: "string",
|
||||
description: "Optional ETag/versionId for concurrency control. If provided, deletion only proceeds if file hasn't been modified. Get versionId from read operations. Prevents accidental deletion of modified files."
|
||||
}
|
||||
},
|
||||
required: ["path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_frontmatter",
|
||||
description: "Update frontmatter fields without modifying note content. Supports patch operations (add/update fields) and removal of keys. At least one of 'patch' or 'remove' must be provided. Returns structured JSON with success status, path, versionId, modified timestamp, and lists of updated/removed fields. Includes concurrency control via ifMatch parameter. Use this for metadata-only updates to avoid race conditions with content edits.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Vault-relative path to the file (e.g., 'folder/note.md'). Must include file extension. Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
|
||||
},
|
||||
patch: {
|
||||
type: "object",
|
||||
description: "Optional object with frontmatter fields to add or update. Keys are field names, values are field values. Supports strings, numbers, booleans, arrays, and nested objects. Example: {\"tags\": [\"project\", \"active\"], \"status\": \"in-progress\"}. Can be omitted if only removing fields."
|
||||
},
|
||||
remove: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Optional array of frontmatter field names to remove. Example: [\"draft\", \"old_status\"]. Fields that don't exist are silently ignored. Can be omitted if only adding/updating fields."
|
||||
},
|
||||
ifMatch: {
|
||||
type: "string",
|
||||
description: "Optional ETag/versionId for concurrency control. If provided, update only proceeds if file hasn't been modified. Get versionId from read operations. Prevents lost updates in concurrent scenarios."
|
||||
}
|
||||
},
|
||||
required: ["path"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "update_sections",
|
||||
description: "Update specific sections of a note by line range. Reduces race conditions by avoiding full file overwrites. Returns structured JSON with success status, path, versionId, modified timestamp, and count of sections updated. Supports multiple edits in a single operation, applied from bottom to top to preserve line numbers. Includes concurrency control via ifMatch parameter. Use this for surgical edits to specific parts of large notes.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Vault-relative path to the file (e.g., 'folder/note.md'). Must include file extension. Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
|
||||
},
|
||||
edits: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
startLine: { type: "number", description: "Starting line number (1-indexed, inclusive)" },
|
||||
endLine: { type: "number", description: "Ending line number (1-indexed, inclusive)" },
|
||||
content: { type: "string", description: "New content to replace the section" }
|
||||
},
|
||||
required: ["startLine", "endLine", "content"]
|
||||
},
|
||||
description: "Array of section edits to apply. Each edit specifies a line range and replacement content. Edits are applied from bottom to top to prevent line number shifts. Example: [{\"startLine\": 10, \"endLine\": 15, \"content\": \"New section content\"}]"
|
||||
},
|
||||
ifMatch: {
|
||||
type: "string",
|
||||
description: "Optional ETag/versionId for concurrency control. If provided, update only proceeds if file hasn't been modified. Get versionId from read operations. Prevents conflicting edits in concurrent scenarios."
|
||||
}
|
||||
},
|
||||
required: ["path", "edits"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "rename_file",
|
||||
description: "Rename or move a file with automatic wikilink updates. Uses Obsidian's FileManager to maintain link integrity across the vault. Returns structured JSON with success status, old/new paths, and versionId. Note: linksUpdated and affectedFiles fields always return 0/empty due to API limitations, but links ARE automatically updated by Obsidian. Supports both rename (same folder) and move (different folder) operations. Automatically creates parent folders if needed. Includes concurrency control via ifMatch parameter. Use this to reorganize vault structure while preserving all internal links.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Current vault-relative path to the file (e.g., 'folder/note.md'). Must include file extension. Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
|
||||
},
|
||||
newPath: {
|
||||
type: "string",
|
||||
description: "New vault-relative path for the file (e.g., 'archive/2024/note.md' or 'folder/renamed.md'). Can be in a different folder for move operations. Must include file extension. Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
|
||||
},
|
||||
updateLinks: {
|
||||
type: "boolean",
|
||||
description: "If true (default), automatically update all wikilinks that reference this file. If false, links will break. Recommended to keep true. Default: true"
|
||||
},
|
||||
ifMatch: {
|
||||
type: "string",
|
||||
description: "Optional ETag/versionId for concurrency control. If provided, rename only proceeds if file hasn't been modified. Get versionId from read operations. Prevents renaming modified files."
|
||||
}
|
||||
},
|
||||
required: ["path", "newPath"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "search",
|
||||
description: "Search vault with advanced filtering, regex support, and snippet extraction. Returns structured JSON with detailed search results including file paths, line numbers, column positions, snippets with context, and match ranges for highlighting. Supports both literal and regex search patterns, case sensitivity control, glob filtering, folder scoping, and result limiting. Use this for powerful content search across your vault.",
|
||||
@@ -298,11 +399,41 @@ export class ToolRegistry {
|
||||
parseFrontmatter: args.parseFrontmatter
|
||||
});
|
||||
case "create_note":
|
||||
return await this.noteTools.createNote(args.path, args.content, args.createParents ?? false);
|
||||
return await this.noteTools.createNote(
|
||||
args.path,
|
||||
args.content,
|
||||
args.createParents ?? false,
|
||||
args.onConflict ?? 'error'
|
||||
);
|
||||
case "update_note":
|
||||
return await this.noteTools.updateNote(args.path, args.content);
|
||||
case "update_frontmatter":
|
||||
return await this.noteTools.updateFrontmatter(
|
||||
args.path,
|
||||
args.patch,
|
||||
args.remove ?? [],
|
||||
args.ifMatch
|
||||
);
|
||||
case "update_sections":
|
||||
return await this.noteTools.updateSections(
|
||||
args.path,
|
||||
args.edits,
|
||||
args.ifMatch
|
||||
);
|
||||
case "rename_file":
|
||||
return await this.noteTools.renameFile(
|
||||
args.path,
|
||||
args.newPath,
|
||||
args.updateLinks ?? true,
|
||||
args.ifMatch
|
||||
);
|
||||
case "delete_note":
|
||||
return await this.noteTools.deleteNote(args.path);
|
||||
return await this.noteTools.deleteNote(
|
||||
args.path,
|
||||
args.soft ?? true,
|
||||
args.dryRun ?? false,
|
||||
args.ifMatch
|
||||
);
|
||||
case "search":
|
||||
return await this.vaultTools.search({
|
||||
query: args.query,
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { App, TFile } from 'obsidian';
|
||||
import { CallToolResult, ParsedNote, ExcalidrawMetadata } from '../types/mcp-types';
|
||||
import {
|
||||
CallToolResult,
|
||||
ParsedNote,
|
||||
ExcalidrawMetadata,
|
||||
UpdateFrontmatterResult,
|
||||
UpdateSectionsResult,
|
||||
CreateNoteResult,
|
||||
RenameFileResult,
|
||||
DeleteNoteResult,
|
||||
SectionEdit,
|
||||
ConflictStrategy
|
||||
} from '../types/mcp-types';
|
||||
import { PathUtils } from '../utils/path-utils';
|
||||
import { ErrorMessages } from '../utils/error-messages';
|
||||
import { FrontmatterUtils } from '../utils/frontmatter-utils';
|
||||
import { WaypointUtils } from '../utils/waypoint-utils';
|
||||
import { VersionUtils } from '../utils/version-utils';
|
||||
|
||||
export class NoteTools {
|
||||
constructor(private app: App) {}
|
||||
@@ -95,7 +107,12 @@ export class NoteTools {
|
||||
}
|
||||
}
|
||||
|
||||
async createNote(path: string, content: string, createParents: boolean = false): Promise<CallToolResult> {
|
||||
async createNote(
|
||||
path: string,
|
||||
content: string,
|
||||
createParents: boolean = false,
|
||||
onConflict: ConflictStrategy = 'error'
|
||||
): Promise<CallToolResult> {
|
||||
// Validate path
|
||||
if (!path || path.trim() === '') {
|
||||
return {
|
||||
@@ -112,26 +129,42 @@ export class NoteTools {
|
||||
}
|
||||
|
||||
// Normalize the path
|
||||
const normalizedPath = PathUtils.normalizePath(path);
|
||||
let normalizedPath = PathUtils.normalizePath(path);
|
||||
let finalPath = normalizedPath;
|
||||
let wasRenamed = false;
|
||||
let originalPath: string | undefined;
|
||||
|
||||
// Check if file already exists
|
||||
if (PathUtils.fileExists(this.app, normalizedPath)) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.pathAlreadyExists(normalizedPath, 'file') }],
|
||||
isError: true
|
||||
};
|
||||
if (onConflict === 'error') {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.pathAlreadyExists(normalizedPath, 'file') }],
|
||||
isError: true
|
||||
};
|
||||
} else if (onConflict === 'overwrite') {
|
||||
// Delete existing file before creating
|
||||
const existingFile = PathUtils.resolveFile(this.app, normalizedPath);
|
||||
if (existingFile) {
|
||||
await this.app.vault.delete(existingFile);
|
||||
}
|
||||
} else if (onConflict === 'rename') {
|
||||
// Generate a unique name
|
||||
originalPath = normalizedPath;
|
||||
finalPath = this.generateUniquePath(normalizedPath);
|
||||
wasRenamed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if it's a folder
|
||||
if (PathUtils.folderExists(this.app, normalizedPath)) {
|
||||
if (PathUtils.folderExists(this.app, finalPath)) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.notAFile(normalizedPath) }],
|
||||
content: [{ type: "text", text: ErrorMessages.notAFile(finalPath) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Explicit parent folder detection (before write operation)
|
||||
const parentPath = PathUtils.getParentPath(normalizedPath);
|
||||
const parentPath = PathUtils.getParentPath(finalPath);
|
||||
if (parentPath) {
|
||||
// First check if parent path is actually a file (not a folder)
|
||||
if (PathUtils.fileExists(this.app, parentPath)) {
|
||||
@@ -156,7 +189,7 @@ export class NoteTools {
|
||||
} else {
|
||||
// Return clear error before attempting file creation
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.parentFolderNotFound(normalizedPath, parentPath) }],
|
||||
content: [{ type: "text", text: ErrorMessages.parentFolderNotFound(finalPath, parentPath) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
@@ -165,18 +198,45 @@ export class NoteTools {
|
||||
|
||||
// Proceed with file creation
|
||||
try {
|
||||
const file = await this.app.vault.create(normalizedPath, content);
|
||||
const file = await this.app.vault.create(finalPath, content);
|
||||
|
||||
const result: CreateNoteResult = {
|
||||
success: true,
|
||||
path: file.path,
|
||||
versionId: VersionUtils.generateVersionId(file),
|
||||
created: file.stat.ctime,
|
||||
renamed: wasRenamed,
|
||||
originalPath: originalPath
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Note created successfully: ${file.path}` }]
|
||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.operationFailed('create note', normalizedPath, (error as Error).message) }],
|
||||
content: [{ type: "text", text: ErrorMessages.operationFailed('create note', finalPath, (error as Error).message) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique path by appending a number to the filename
|
||||
* @private
|
||||
*/
|
||||
private generateUniquePath(path: string): string {
|
||||
const basePath = path.replace(/\.md$/, '');
|
||||
let counter = 1;
|
||||
let newPath = `${basePath} ${counter}.md`;
|
||||
|
||||
while (PathUtils.fileExists(this.app, newPath)) {
|
||||
counter++;
|
||||
newPath = `${basePath} ${counter}.md`;
|
||||
}
|
||||
|
||||
return newPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively create parent folders
|
||||
* @private
|
||||
@@ -265,7 +325,138 @@ export class NoteTools {
|
||||
}
|
||||
}
|
||||
|
||||
async deleteNote(path: string): Promise<CallToolResult> {
|
||||
/**
|
||||
* Rename or move a file with automatic link updates
|
||||
* Uses Obsidian's FileManager to maintain link integrity
|
||||
*/
|
||||
async renameFile(
|
||||
path: string,
|
||||
newPath: string,
|
||||
updateLinks: boolean = true,
|
||||
ifMatch?: string
|
||||
): Promise<CallToolResult> {
|
||||
// Validate paths
|
||||
if (!path || path.trim() === '') {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.emptyPath() }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
if (!newPath || newPath.trim() === '') {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ error: 'New path cannot be empty' }, null, 2) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
if (!PathUtils.isValidVaultPath(path)) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.invalidPath(path) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
if (!PathUtils.isValidVaultPath(newPath)) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.invalidPath(newPath) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve source file
|
||||
const file = PathUtils.resolveFile(this.app, path);
|
||||
|
||||
if (!file) {
|
||||
if (PathUtils.folderExists(this.app, path)) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.notAFile(path) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.fileNotFound(path) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Normalize new path
|
||||
const normalizedNewPath = PathUtils.normalizePath(newPath);
|
||||
|
||||
// Check if destination already exists
|
||||
if (PathUtils.fileExists(this.app, normalizedNewPath)) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
error: 'Destination file already exists',
|
||||
path: normalizedNewPath,
|
||||
message: 'Cannot rename/move file because a file already exists at the destination path.'
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
if (PathUtils.folderExists(this.app, normalizedNewPath)) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.notAFile(normalizedNewPath) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Check version if ifMatch provided
|
||||
if (ifMatch && !VersionUtils.validateVersion(file, ifMatch)) {
|
||||
const currentVersion = VersionUtils.generateVersionId(file);
|
||||
return {
|
||||
content: [{ type: "text", text: VersionUtils.versionMismatchError(path, ifMatch, currentVersion) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Create parent folder if needed
|
||||
const parentPath = PathUtils.getParentPath(normalizedNewPath);
|
||||
if (parentPath && !PathUtils.pathExists(this.app, parentPath)) {
|
||||
await this.createParentFolders(parentPath);
|
||||
}
|
||||
|
||||
// Use Obsidian's FileManager to rename (automatically updates links)
|
||||
// Note: Obsidian's renameFile automatically updates all wikilinks
|
||||
await this.app.fileManager.renameFile(file, normalizedNewPath);
|
||||
|
||||
// Get the renamed file to get version info
|
||||
const renamedFile = PathUtils.resolveFile(this.app, normalizedNewPath);
|
||||
|
||||
// Note: We cannot reliably track which files were updated without the backlinks API
|
||||
// The FileManager handles link updates internally
|
||||
const result: RenameFileResult = {
|
||||
success: true,
|
||||
oldPath: path,
|
||||
newPath: normalizedNewPath,
|
||||
linksUpdated: 0, // Cannot track without backlinks API
|
||||
affectedFiles: [], // Cannot track without backlinks API
|
||||
versionId: renamedFile ? VersionUtils.generateVersionId(renamedFile) : ''
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.operationFailed('rename file', path, (error as Error).message) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async deleteNote(
|
||||
path: string,
|
||||
soft: boolean = true,
|
||||
dryRun: boolean = false,
|
||||
ifMatch?: string
|
||||
): Promise<CallToolResult> {
|
||||
// Validate path
|
||||
if (!path || path.trim() === '') {
|
||||
return {
|
||||
@@ -300,9 +491,56 @@ export class NoteTools {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.app.vault.delete(file);
|
||||
// Check version if ifMatch provided
|
||||
if (ifMatch && !VersionUtils.validateVersion(file, ifMatch)) {
|
||||
const currentVersion = VersionUtils.generateVersionId(file);
|
||||
return {
|
||||
content: [{ type: "text", text: VersionUtils.versionMismatchError(path, ifMatch, currentVersion) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
let destination: string | undefined;
|
||||
|
||||
// Dry run - just return what would happen
|
||||
if (dryRun) {
|
||||
if (soft) {
|
||||
destination = `.trash/${file.name}`;
|
||||
}
|
||||
|
||||
const result: DeleteNoteResult = {
|
||||
deleted: false,
|
||||
path: file.path,
|
||||
destination,
|
||||
dryRun: true,
|
||||
soft
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
||||
};
|
||||
}
|
||||
|
||||
// Perform actual deletion
|
||||
if (soft) {
|
||||
// Move to trash using Obsidian's trash method
|
||||
await this.app.vault.trash(file, true);
|
||||
destination = `.trash/${file.name}`;
|
||||
} else {
|
||||
// Permanent deletion
|
||||
await this.app.vault.delete(file);
|
||||
}
|
||||
|
||||
const result: DeleteNoteResult = {
|
||||
deleted: true,
|
||||
path: file.path,
|
||||
destination,
|
||||
dryRun: false,
|
||||
soft
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Note deleted successfully: ${file.path}` }]
|
||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -414,4 +652,264 @@ export class NoteTools {
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update frontmatter fields without modifying content
|
||||
* Supports patch operations (add/update) and removal of keys
|
||||
* At least one of patch or remove must be provided
|
||||
* Includes concurrency control via ifMatch parameter
|
||||
*/
|
||||
async updateFrontmatter(
|
||||
path: string,
|
||||
patch?: Record<string, any>,
|
||||
remove: string[] = [],
|
||||
ifMatch?: string
|
||||
): Promise<CallToolResult> {
|
||||
// Validate path
|
||||
if (!path || path.trim() === '') {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.emptyPath() }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
if (!PathUtils.isValidVaultPath(path)) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.invalidPath(path) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Validate that at least one operation is provided
|
||||
const hasPatch = patch && typeof patch === 'object' && Object.keys(patch).length > 0;
|
||||
const hasRemove = remove && Array.isArray(remove) && remove.length > 0;
|
||||
|
||||
if (!hasPatch && !hasRemove) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
error: 'No operations provided',
|
||||
message: 'At least one of "patch" or "remove" must be provided with values.'
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve file
|
||||
const file = PathUtils.resolveFile(this.app, path);
|
||||
|
||||
if (!file) {
|
||||
if (PathUtils.folderExists(this.app, path)) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.notAFile(path) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.fileNotFound(path) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Check version if ifMatch provided
|
||||
if (ifMatch && !VersionUtils.validateVersion(file, ifMatch)) {
|
||||
const currentVersion = VersionUtils.generateVersionId(file);
|
||||
return {
|
||||
content: [{ type: "text", text: VersionUtils.versionMismatchError(path, ifMatch, currentVersion) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Read current content
|
||||
const content = await this.app.vault.read(file);
|
||||
const extracted = FrontmatterUtils.extractFrontmatter(content);
|
||||
|
||||
// Get current frontmatter or create new
|
||||
let frontmatterData = extracted.parsedFrontmatter || {};
|
||||
|
||||
// Track changes
|
||||
const updatedFields: string[] = [];
|
||||
const removedFields: string[] = [];
|
||||
|
||||
// Apply patch (add/update fields) - only if patch is provided
|
||||
if (patch && typeof patch === 'object') {
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
frontmatterData[key] = value;
|
||||
updatedFields.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove fields
|
||||
if (remove && Array.isArray(remove)) {
|
||||
for (const key of remove) {
|
||||
if (key in frontmatterData) {
|
||||
delete frontmatterData[key];
|
||||
removedFields.push(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize frontmatter
|
||||
const newFrontmatter = FrontmatterUtils.serializeFrontmatter(frontmatterData);
|
||||
|
||||
// Reconstruct content
|
||||
let newContent: string;
|
||||
if (extracted.hasFrontmatter) {
|
||||
// Replace existing frontmatter
|
||||
newContent = newFrontmatter + '\n' + extracted.contentWithoutFrontmatter;
|
||||
} else {
|
||||
// Add frontmatter at the beginning
|
||||
newContent = newFrontmatter + '\n' + content;
|
||||
}
|
||||
|
||||
// Write back
|
||||
await this.app.vault.modify(file, newContent);
|
||||
|
||||
// Generate response with version info
|
||||
const result: UpdateFrontmatterResult = {
|
||||
success: true,
|
||||
path: file.path,
|
||||
versionId: VersionUtils.generateVersionId(file),
|
||||
modified: file.stat.mtime,
|
||||
updatedFields,
|
||||
removedFields
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.operationFailed('update frontmatter', path, (error as Error).message) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update specific sections of a note by line range
|
||||
* Reduces race conditions by avoiding full overwrites
|
||||
* Includes concurrency control via ifMatch parameter
|
||||
*/
|
||||
async updateSections(
|
||||
path: string,
|
||||
edits: SectionEdit[],
|
||||
ifMatch?: string
|
||||
): Promise<CallToolResult> {
|
||||
// Validate path
|
||||
if (!path || path.trim() === '') {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.emptyPath() }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
if (!PathUtils.isValidVaultPath(path)) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.invalidPath(path) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Validate edits
|
||||
if (!edits || edits.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify({ error: 'No edits provided' }, null, 2) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve file
|
||||
const file = PathUtils.resolveFile(this.app, path);
|
||||
|
||||
if (!file) {
|
||||
if (PathUtils.folderExists(this.app, path)) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.notAFile(path) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.fileNotFound(path) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Check version if ifMatch provided
|
||||
if (ifMatch && !VersionUtils.validateVersion(file, ifMatch)) {
|
||||
const currentVersion = VersionUtils.generateVersionId(file);
|
||||
return {
|
||||
content: [{ type: "text", text: VersionUtils.versionMismatchError(path, ifMatch, currentVersion) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
|
||||
// Read current content
|
||||
const content = await this.app.vault.read(file);
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Sort edits by startLine in descending order to apply from bottom to top
|
||||
// This prevents line number shifts from affecting subsequent edits
|
||||
const sortedEdits = [...edits].sort((a, b) => b.startLine - a.startLine);
|
||||
|
||||
// Validate all edits before applying
|
||||
for (const edit of sortedEdits) {
|
||||
if (edit.startLine < 1 || edit.endLine < edit.startLine || edit.endLine > lines.length) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
text: JSON.stringify({
|
||||
error: 'Invalid line range',
|
||||
edit,
|
||||
totalLines: lines.length,
|
||||
message: `Line range ${edit.startLine}-${edit.endLine} is invalid. File has ${lines.length} lines.`
|
||||
}, null, 2)
|
||||
}],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Apply edits from bottom to top
|
||||
for (const edit of sortedEdits) {
|
||||
// Convert to 0-indexed
|
||||
const startIdx = edit.startLine - 1;
|
||||
const endIdx = edit.endLine; // endLine is inclusive, so we don't subtract 1
|
||||
|
||||
// Replace the section
|
||||
const newLines = edit.content.split('\n');
|
||||
lines.splice(startIdx, endIdx - startIdx, ...newLines);
|
||||
}
|
||||
|
||||
// Reconstruct content
|
||||
const newContent = lines.join('\n');
|
||||
|
||||
// Write back
|
||||
await this.app.vault.modify(file, newContent);
|
||||
|
||||
// Generate response with version info
|
||||
const result: UpdateSectionsResult = {
|
||||
success: true,
|
||||
path: file.path,
|
||||
versionId: VersionUtils.generateVersionId(file),
|
||||
modified: file.stat.mtime,
|
||||
sectionsUpdated: edits.length
|
||||
};
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: "text", text: ErrorMessages.operationFailed('update sections', path, (error as Error).message) }],
|
||||
isError: true
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,3 +207,80 @@ export interface ExcalidrawMetadata {
|
||||
/** Full compressed drawing data (when includeCompressed=true) */
|
||||
compressedData?: string;
|
||||
}
|
||||
|
||||
// Phase 8: Write Operations & Concurrency Types
|
||||
|
||||
/**
|
||||
* Conflict resolution strategy for create_note
|
||||
*/
|
||||
export type ConflictStrategy = 'error' | 'overwrite' | 'rename';
|
||||
|
||||
/**
|
||||
* Section edit operation for update_sections
|
||||
*/
|
||||
export interface SectionEdit {
|
||||
/** Starting line number (1-indexed) */
|
||||
startLine: number;
|
||||
/** Ending line number (1-indexed, inclusive) */
|
||||
endLine: number;
|
||||
/** New content to replace the section */
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from update_frontmatter operation
|
||||
*/
|
||||
export interface UpdateFrontmatterResult {
|
||||
success: boolean;
|
||||
path: string;
|
||||
versionId: string;
|
||||
modified: number;
|
||||
updatedFields: string[];
|
||||
removedFields: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from update_sections operation
|
||||
*/
|
||||
export interface UpdateSectionsResult {
|
||||
success: boolean;
|
||||
path: string;
|
||||
versionId: string;
|
||||
modified: number;
|
||||
sectionsUpdated: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from create_note operation
|
||||
*/
|
||||
export interface CreateNoteResult {
|
||||
success: boolean;
|
||||
path: string;
|
||||
versionId: string;
|
||||
created: number;
|
||||
renamed?: boolean;
|
||||
originalPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from rename_file operation
|
||||
*/
|
||||
export interface RenameFileResult {
|
||||
success: boolean;
|
||||
oldPath: string;
|
||||
newPath: string;
|
||||
linksUpdated: number;
|
||||
affectedFiles: string[];
|
||||
versionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result from delete_note operation
|
||||
*/
|
||||
export interface DeleteNoteResult {
|
||||
deleted: boolean;
|
||||
path: string;
|
||||
destination?: string;
|
||||
dryRun: boolean;
|
||||
soft: boolean;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
58
src/utils/version-utils.ts
Normal file
58
src/utils/version-utils.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { TFile } from 'obsidian';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Version control utilities for concurrency management
|
||||
* Implements ETag-based optimistic locking for write operations
|
||||
*/
|
||||
export class VersionUtils {
|
||||
/**
|
||||
* Generate a version ID (ETag) for a file based on its modification time and size
|
||||
* Format: base64(sha256(mtime + size))
|
||||
*/
|
||||
static generateVersionId(file: TFile): string {
|
||||
const data = `${file.stat.mtime}-${file.stat.size}`;
|
||||
const hash = crypto.createHash('sha256').update(data).digest('base64');
|
||||
// Use URL-safe base64 and truncate to reasonable length
|
||||
return hash.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '').substring(0, 22);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the provided version ID matches the current file version
|
||||
* Returns true if versions match (safe to proceed with write)
|
||||
* Returns false if versions don't match (conflict detected)
|
||||
*/
|
||||
static validateVersion(file: TFile, providedVersionId: string): boolean {
|
||||
const currentVersionId = this.generateVersionId(file);
|
||||
return currentVersionId === providedVersionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a version mismatch error message
|
||||
*/
|
||||
static versionMismatchError(path: string, providedVersion: string, currentVersion: string): string {
|
||||
return JSON.stringify({
|
||||
error: 'Version mismatch (412 Precondition Failed)',
|
||||
path,
|
||||
message: 'The file has been modified since you last read it. Please re-read the file and try again.',
|
||||
providedVersion,
|
||||
currentVersion,
|
||||
troubleshooting: [
|
||||
'Re-read the file to get the latest versionId',
|
||||
'Merge your changes with the current content',
|
||||
'Retry the operation with the new versionId'
|
||||
]
|
||||
}, null, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a response with version information
|
||||
*/
|
||||
static createVersionedResponse(file: TFile, data: any): any {
|
||||
return {
|
||||
...data,
|
||||
versionId: this.generateVersionId(file),
|
||||
modified: file.stat.mtime
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user