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:
2025-10-30 09:40:57 -04:00
parent c574a237ce
commit f0808c0346
10 changed files with 679 additions and 21 deletions

View File

@@ -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":

View 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
);
}

View File

@@ -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) }]
};

View File

@@ -248,6 +248,8 @@ export interface UpdateSectionsResult {
versionId: string;
modified: number;
sectionsUpdated: number;
wordCount?: number;
linkValidation?: LinkValidationResult;
}
/**
@@ -260,6 +262,8 @@ export interface CreateNoteResult {
created: number;
renamed?: boolean;
originalPath?: string;
wordCount?: number;
linkValidation?: LinkValidationResult;
}
/**
@@ -305,6 +309,35 @@ export interface UnresolvedLink {
suggestions: string[];
}
/**
* Broken link information (note doesn't exist)
*/
export interface BrokenNoteLink {
link: string;
line: number;
context: string;
}
/**
* Broken heading link information (note exists but heading doesn't)
*/
export interface BrokenHeadingLink {
link: string;
line: number;
context: string;
note: string;
}
/**
* Link validation result for write operations
*/
export interface LinkValidationResult {
valid: string[];
brokenNotes: BrokenNoteLink[];
brokenHeadings: BrokenHeadingLink[];
summary: string;
}
/**
* Result from validate_wikilinks operation
*/

View File

@@ -0,0 +1,42 @@
import { FrontmatterUtils } from './frontmatter-utils';
/**
* Utility class for content analysis and manipulation
*/
export class ContentUtils {
/**
* Count words in content, excluding frontmatter and Obsidian comments
* Includes all other content: headings, paragraphs, lists, code blocks, inline code
*
* @param content The full markdown content to analyze
* @returns Word count (excludes frontmatter and Obsidian comments only)
*/
static countWords(content: string): number {
// Extract frontmatter to get content without it
const { contentWithoutFrontmatter } = FrontmatterUtils.extractFrontmatter(content);
// Remove Obsidian comments (%% ... %%)
// Handle both single-line and multi-line comments
const withoutComments = this.removeObsidianComments(contentWithoutFrontmatter);
// Split by whitespace and count non-empty tokens
const words = withoutComments
.split(/\s+/)
.filter(word => word.trim().length > 0);
return words.length;
}
/**
* Remove Obsidian comments from content
* Handles both %% single line %% and multi-line comments
*
* @param content Content to process
* @returns Content with Obsidian comments removed
*/
private static removeObsidianComments(content: string): string {
// Remove Obsidian comments: %% ... %%
// Use non-greedy match to handle multiple comments
return content.replace(/%%[\s\S]*?%%/g, '');
}
}

View File

@@ -41,6 +41,46 @@ export interface UnresolvedLink {
suggestions: string[];
}
/**
* Broken link information (note doesn't exist)
*/
export interface BrokenNoteLink {
/** Original link text */
link: string;
/** Line number where the link appears */
line: number;
/** Context snippet around the link */
context: string;
}
/**
* Broken heading link information (note exists but heading doesn't)
*/
export interface BrokenHeadingLink {
/** Original link text */
link: string;
/** Line number where the link appears */
line: number;
/** Context snippet around the link */
context: string;
/** The note path that exists */
note: string;
}
/**
* Link validation result
*/
export interface LinkValidationResult {
/** Array of valid links */
valid: string[];
/** Array of broken note links (note doesn't exist) */
brokenNotes: BrokenNoteLink[];
/** Array of broken heading links (note exists but heading doesn't) */
brokenHeadings: BrokenHeadingLink[];
/** Human-readable summary */
summary: string;
}
/**
* Backlink occurrence in a file
*/
@@ -394,4 +434,108 @@ export class LinkUtils {
return { resolvedLinks, unresolvedLinks };
}
/**
* Validate all links in content (wikilinks, heading links, and embeds)
* Returns categorized results: valid, broken notes, and broken headings
*
* @param vault Vault adapter for file operations
* @param metadata Metadata cache adapter for link resolution
* @param content File content to validate
* @param sourcePath Path of the file containing the links
* @returns Structured validation result with categorized links
*/
static async validateLinks(
vault: IVaultAdapter,
metadata: IMetadataCacheAdapter,
content: string,
sourcePath: string
): Promise<LinkValidationResult> {
const valid: string[] = [];
const brokenNotes: BrokenNoteLink[] = [];
const brokenHeadings: BrokenHeadingLink[] = [];
// Parse all wikilinks from content (includes embeds which start with !)
const wikilinks = this.parseWikilinks(content);
const lines = content.split('\n');
for (const link of wikilinks) {
// Check if this is a heading link
const hasHeading = link.target.includes('#');
if (hasHeading) {
// Split note path and heading
const [notePath, ...headingParts] = link.target.split('#');
const heading = headingParts.join('#'); // Rejoin in case heading has # in it
// Try to resolve the note
const resolvedFile = this.resolveLink(vault, metadata, sourcePath, notePath || sourcePath);
if (!resolvedFile) {
// Note doesn't exist
const context = this.extractSnippet(lines, link.line - 1, 100);
brokenNotes.push({
link: link.raw,
line: link.line,
context
});
} else {
// Note exists, check if heading exists
const fileCache = metadata.getFileCache(resolvedFile);
const headings = fileCache?.headings || [];
// Normalize heading for comparison (remove # and trim)
const normalizedHeading = heading.trim().toLowerCase();
const headingExists = headings.some(h =>
h.heading.trim().toLowerCase() === normalizedHeading
);
if (headingExists) {
// Both note and heading exist
valid.push(link.raw);
} else {
// Note exists but heading doesn't
const context = this.extractSnippet(lines, link.line - 1, 100);
brokenHeadings.push({
link: link.raw,
line: link.line,
context,
note: resolvedFile.path
});
}
}
} else {
// Regular link or embed (no heading)
const resolvedFile = this.resolveLink(vault, metadata, sourcePath, link.target);
if (resolvedFile) {
valid.push(link.raw);
} else {
const context = this.extractSnippet(lines, link.line - 1, 100);
brokenNotes.push({
link: link.raw,
line: link.line,
context
});
}
}
}
// Generate summary
const totalLinks = valid.length + brokenNotes.length + brokenHeadings.length;
let summary = `${totalLinks} links: ${valid.length} valid`;
if (brokenNotes.length > 0) {
summary += `, ${brokenNotes.length} broken note${brokenNotes.length === 1 ? '' : 's'}`;
}
if (brokenHeadings.length > 0) {
summary += `, ${brokenHeadings.length} broken heading${brokenHeadings.length === 1 ? '' : 's'}`;
}
return {
valid,
brokenNotes,
brokenHeadings,
summary
};
}
}