feat: add automatic word count and link validation to write operations
Add automatic word count and link validation to create_note, update_note, and update_sections operations to provide immediate feedback on note content quality and link integrity. Features: - Word counting excludes frontmatter and Obsidian comments, includes all other content (code blocks, inline code, headings, lists, etc.) - Link validation checks wikilinks, heading links, and embeds - Results categorized as: valid links, broken notes (note doesn't exist), and broken headings (note exists but heading missing) - Detailed broken link info includes line number and context snippet - Human-readable summary (e.g., "15 links: 12 valid, 2 broken notes, 1 broken heading") - Opt-out capability via validateLinks parameter (default: true) for performance-critical operations Implementation: - New ContentUtils.countWords() for word counting logic - Enhanced LinkUtils.validateLinks() for comprehensive link validation - Updated create_note, update_note, update_sections to return wordCount and linkValidation fields - Updated MCP tool descriptions to document new features and parameters - update_note now returns structured JSON instead of simple success message Response format changes: - create_note: added wordCount and linkValidation fields - update_note: changed to structured response with wordCount and linkValidation - update_sections: added wordCount and linkValidation fields Breaking changes: - update_note response format changed from simple message to structured JSON
This commit is contained in:
@@ -53,7 +53,7 @@ export class ToolRegistry {
|
||||
},
|
||||
{
|
||||
name: "create_note",
|
||||
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.",
|
||||
description: "Create a new file in the Obsidian vault with conflict handling. Returns structured JSON with success status, path, versionId, created timestamp, conflict resolution details, word count (excluding frontmatter and Obsidian comments), and link validation results. Automatically validates all wikilinks, heading links, and embeds, categorizing them as valid, broken notes, or broken headings. 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 and automatic content analysis.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -73,6 +73,10 @@ export class ToolRegistry {
|
||||
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'"
|
||||
},
|
||||
validateLinks: {
|
||||
type: "boolean",
|
||||
description: "If true (default), automatically validate all wikilinks and embeds in the note, returning detailed broken link information. If false, skip link validation for better performance. Link validation checks [[wikilinks]], [[note#heading]] links, and ![[embeds]]. Default: true"
|
||||
}
|
||||
},
|
||||
required: ["path", "content"]
|
||||
@@ -80,7 +84,7 @@ export class ToolRegistry {
|
||||
},
|
||||
{
|
||||
name: "update_note",
|
||||
description: "Update (overwrite) an existing file in the Obsidian vault. Use this to modify the contents of an existing note. This REPLACES the entire file content. The file must already exist. Path must be vault-relative with file extension. Use read_note() first to get current content if you want to make partial changes.",
|
||||
description: "Update (overwrite) an existing file in the Obsidian vault. Returns structured JSON with success status, path, versionId, modified timestamp, word count (excluding frontmatter and Obsidian comments), and link validation results. Automatically validates all wikilinks, heading links, and embeds, categorizing them as valid, broken notes, or broken headings. This REPLACES the entire file content. The file must already exist. Path must be vault-relative with file extension. Use read_note() first to get current content if you want to make partial changes.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -91,6 +95,10 @@ export class ToolRegistry {
|
||||
content: {
|
||||
type: "string",
|
||||
description: "The complete new content that will replace the entire file. To make partial changes, read the file first, modify the content, then update."
|
||||
},
|
||||
validateLinks: {
|
||||
type: "boolean",
|
||||
description: "If true (default), automatically validate all wikilinks and embeds in the note, returning detailed broken link information. If false, skip link validation for better performance. Link validation checks [[wikilinks]], [[note#heading]] links, and ![[embeds]]. Default: true"
|
||||
}
|
||||
},
|
||||
required: ["path", "content"]
|
||||
@@ -151,7 +159,7 @@ export class ToolRegistry {
|
||||
},
|
||||
{
|
||||
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.",
|
||||
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, count of sections updated, word count for the entire note (excluding frontmatter and Obsidian comments), and link validation results for the entire note. Automatically validates all wikilinks, heading links, and embeds in the complete note after edits, categorizing them as valid, broken notes, or broken headings. 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 with automatic content analysis.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -175,6 +183,10 @@ export class ToolRegistry {
|
||||
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."
|
||||
},
|
||||
validateLinks: {
|
||||
type: "boolean",
|
||||
description: "If true (default), automatically validate all wikilinks and embeds in the entire note after applying section edits, returning detailed broken link information. If false, skip link validation for better performance. Link validation checks [[wikilinks]], [[note#heading]] links, and ![[embeds]]. Default: true"
|
||||
}
|
||||
},
|
||||
required: ["path", "edits"]
|
||||
@@ -475,14 +487,19 @@ export class ToolRegistry {
|
||||
break;
|
||||
case "create_note":
|
||||
result = await this.noteTools.createNote(
|
||||
args.path,
|
||||
args.content,
|
||||
args.path,
|
||||
args.content,
|
||||
args.createParents ?? false,
|
||||
args.onConflict ?? 'error'
|
||||
args.onConflict ?? 'error',
|
||||
args.validateLinks ?? true
|
||||
);
|
||||
break;
|
||||
case "update_note":
|
||||
result = await this.noteTools.updateNote(args.path, args.content);
|
||||
result = await this.noteTools.updateNote(
|
||||
args.path,
|
||||
args.content,
|
||||
args.validateLinks ?? true
|
||||
);
|
||||
break;
|
||||
case "update_frontmatter":
|
||||
result = await this.noteTools.updateFrontmatter(
|
||||
@@ -496,7 +513,8 @@ export class ToolRegistry {
|
||||
result = await this.noteTools.updateSections(
|
||||
args.path,
|
||||
args.edits,
|
||||
args.ifMatch
|
||||
args.ifMatch,
|
||||
args.validateLinks ?? true
|
||||
);
|
||||
break;
|
||||
case "rename_file":
|
||||
|
||||
@@ -2,6 +2,7 @@ import { App } from 'obsidian';
|
||||
import { NoteTools } from './note-tools';
|
||||
import { VaultAdapter } from '../adapters/vault-adapter';
|
||||
import { FileManagerAdapter } from '../adapters/file-manager-adapter';
|
||||
import { MetadataCacheAdapter } from '../adapters/metadata-cache-adapter';
|
||||
|
||||
/**
|
||||
* Factory function to create NoteTools with concrete adapters
|
||||
@@ -10,6 +11,7 @@ export function createNoteTools(app: App): NoteTools {
|
||||
return new NoteTools(
|
||||
new VaultAdapter(app.vault),
|
||||
new FileManagerAdapter(app.fileManager),
|
||||
new MetadataCacheAdapter(app.metadataCache),
|
||||
app
|
||||
);
|
||||
}
|
||||
@@ -16,12 +16,15 @@ import { ErrorMessages } from '../utils/error-messages';
|
||||
import { FrontmatterUtils } from '../utils/frontmatter-utils';
|
||||
import { WaypointUtils } from '../utils/waypoint-utils';
|
||||
import { VersionUtils } from '../utils/version-utils';
|
||||
import { IVaultAdapter, IFileManagerAdapter } from '../adapters/interfaces';
|
||||
import { ContentUtils } from '../utils/content-utils';
|
||||
import { LinkUtils } from '../utils/link-utils';
|
||||
import { IVaultAdapter, IFileManagerAdapter, IMetadataCacheAdapter } from '../adapters/interfaces';
|
||||
|
||||
export class NoteTools {
|
||||
constructor(
|
||||
private vault: IVaultAdapter,
|
||||
private fileManager: IFileManagerAdapter,
|
||||
private metadata: IMetadataCacheAdapter,
|
||||
private app: App // Keep temporarily for methods not yet migrated
|
||||
) {}
|
||||
|
||||
@@ -119,10 +122,11 @@ export class NoteTools {
|
||||
}
|
||||
|
||||
async createNote(
|
||||
path: string,
|
||||
content: string,
|
||||
path: string,
|
||||
content: string,
|
||||
createParents: boolean = false,
|
||||
onConflict: ConflictStrategy = 'error'
|
||||
onConflict: ConflictStrategy = 'error',
|
||||
validateLinks: boolean = true
|
||||
): Promise<CallToolResult> {
|
||||
// Validate path
|
||||
if (!path || path.trim() === '') {
|
||||
@@ -213,7 +217,7 @@ export class NoteTools {
|
||||
// Proceed with file creation
|
||||
try {
|
||||
const file = await this.vault.create(finalPath, content);
|
||||
|
||||
|
||||
const result: CreateNoteResult = {
|
||||
success: true,
|
||||
path: file.path,
|
||||
@@ -223,6 +227,19 @@ export class NoteTools {
|
||||
originalPath: originalPath
|
||||
};
|
||||
|
||||
// Add word count
|
||||
result.wordCount = ContentUtils.countWords(content);
|
||||
|
||||
// Add link validation if requested
|
||||
if (validateLinks) {
|
||||
result.linkValidation = await LinkUtils.validateLinks(
|
||||
this.vault,
|
||||
this.metadata,
|
||||
content,
|
||||
file.path
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
||||
};
|
||||
@@ -271,7 +288,7 @@ export class NoteTools {
|
||||
}
|
||||
}
|
||||
|
||||
async updateNote(path: string, content: string): Promise<CallToolResult> {
|
||||
async updateNote(path: string, content: string, validateLinks: boolean = true): Promise<CallToolResult> {
|
||||
// Validate path
|
||||
if (!path || path.trim() === '') {
|
||||
return {
|
||||
@@ -329,8 +346,30 @@ export class NoteTools {
|
||||
}
|
||||
|
||||
await this.vault.modify(file, content);
|
||||
|
||||
// Build response with word count and link validation
|
||||
const result: any = {
|
||||
success: true,
|
||||
path: file.path,
|
||||
versionId: VersionUtils.generateVersionId(file),
|
||||
modified: file.stat.mtime
|
||||
};
|
||||
|
||||
// Add word count
|
||||
result.wordCount = ContentUtils.countWords(content);
|
||||
|
||||
// Add link validation if requested
|
||||
if (validateLinks) {
|
||||
result.linkValidation = await LinkUtils.validateLinks(
|
||||
this.vault,
|
||||
this.metadata,
|
||||
content,
|
||||
file.path
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: `Note updated successfully: ${file.path}` }]
|
||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -813,7 +852,8 @@ export class NoteTools {
|
||||
async updateSections(
|
||||
path: string,
|
||||
edits: SectionEdit[],
|
||||
ifMatch?: string
|
||||
ifMatch?: string,
|
||||
validateLinks: boolean = true
|
||||
): Promise<CallToolResult> {
|
||||
// Validate path
|
||||
if (!path || path.trim() === '') {
|
||||
@@ -917,6 +957,19 @@ export class NoteTools {
|
||||
sectionsUpdated: edits.length
|
||||
};
|
||||
|
||||
// Add word count
|
||||
result.wordCount = ContentUtils.countWords(newContent);
|
||||
|
||||
// Add link validation if requested
|
||||
if (validateLinks) {
|
||||
result.linkValidation = await LinkUtils.validateLinks(
|
||||
this.vault,
|
||||
this.metadata,
|
||||
newContent,
|
||||
file.path
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user