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

@@ -1,5 +1,5 @@
import { NoteTools } from '../src/tools/note-tools';
import { createMockVaultAdapter, createMockFileManagerAdapter, createMockTFile, createMockTFolder } from './__mocks__/adapters';
import { createMockVaultAdapter, createMockFileManagerAdapter, createMockMetadataCacheAdapter, createMockTFile, createMockTFolder } from './__mocks__/adapters';
import { App, Vault, TFile, TFolder } from 'obsidian';
// Mock PathUtils since NoteTools uses it extensively
@@ -25,13 +25,15 @@ describe('NoteTools', () => {
let noteTools: NoteTools;
let mockVault: ReturnType<typeof createMockVaultAdapter>;
let mockFileManager: ReturnType<typeof createMockFileManagerAdapter>;
let mockMetadata: ReturnType<typeof createMockMetadataCacheAdapter>;
let mockApp: App;
beforeEach(() => {
mockVault = createMockVaultAdapter();
mockFileManager = createMockFileManagerAdapter();
mockMetadata = createMockMetadataCacheAdapter();
mockApp = new App();
noteTools = new NoteTools(mockVault, mockFileManager, mockApp);
noteTools = new NoteTools(mockVault, mockFileManager, mockMetadata, mockApp);
// Reset all mocks
jest.clearAllMocks();
@@ -273,7 +275,10 @@ describe('NoteTools', () => {
expect(result.isError).toBeUndefined();
expect(mockVault.modify).toHaveBeenCalledWith(mockFile, newContent);
expect(result.content[0].text).toContain('updated successfully');
const parsed = JSON.parse(result.content[0].text);
expect(parsed.success).toBe(true);
expect(parsed.path).toBe('test.md');
expect(parsed.wordCount).toBeDefined();
});
it('should return error if file not found', async () => {
@@ -1017,4 +1022,198 @@ Some text
expect(result.content[0].text).toContain('empty');
});
});
describe('Word Count and Link Validation', () => {
beforeEach(() => {
// Setup default mocks for all word count/link validation tests
(PathUtils.fileExists as jest.Mock).mockReturnValue(false);
(PathUtils.folderExists as jest.Mock).mockReturnValue(false);
(PathUtils.getParentPath as jest.Mock).mockReturnValue('');
(PathUtils.resolveFile as jest.Mock).mockImplementation((app: any, path: string) => {
// Return null for non-existent files
return null;
});
});
describe('createNote with word count and link validation', () => {
it('should return word count when creating a note', async () => {
const content = 'This is a test note with some words.';
const mockFile = createMockTFile('test-note.md');
mockVault.create = jest.fn().mockResolvedValue(mockFile);
const result = await noteTools.createNote('test-note.md', content);
expect(result.isError).toBeFalsy();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.wordCount).toBe(8);
});
it('should return link validation structure when creating a note', async () => {
const content = 'This note has some [[links]].';
const mockFile = createMockTFile('test-note.md');
mockVault.create = jest.fn().mockResolvedValue(mockFile);
const result = await noteTools.createNote('test-note.md', content);
expect(result.isError).toBeFalsy();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.linkValidation).toBeDefined();
expect(parsed.linkValidation).toHaveProperty('valid');
expect(parsed.linkValidation).toHaveProperty('brokenNotes');
expect(parsed.linkValidation).toHaveProperty('brokenHeadings');
expect(parsed.linkValidation).toHaveProperty('summary');
});
it('should skip link validation when validateLinks is false', async () => {
const content = 'This note links to [[Some Note]].';
const mockFile = createMockTFile('test-note.md');
mockVault.create = jest.fn().mockResolvedValue(mockFile);
const result = await noteTools.createNote('test-note.md', content, false, 'error', false);
expect(result.isError).toBeFalsy();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.wordCount).toBeDefined();
expect(parsed.linkValidation).toBeUndefined();
});
});
describe('updateNote with word count and link validation', () => {
it('should return word count when updating a note', async () => {
const mockFile = createMockTFile('update-test.md');
const newContent = 'This is updated content with several more words.';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue('Old content');
mockVault.modify = jest.fn().mockResolvedValue(undefined);
const result = await noteTools.updateNote('update-test.md', newContent);
expect(result.isError).toBeFalsy();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.wordCount).toBe(8);
});
it('should return link validation structure when updating a note', async () => {
const mockFile = createMockTFile('update-test.md');
const newContent = 'Updated with [[Referenced]] link.';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue('Old content');
mockVault.modify = jest.fn().mockResolvedValue(undefined);
const result = await noteTools.updateNote('update-test.md', newContent);
expect(result.isError).toBeFalsy();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.linkValidation).toBeDefined();
expect(parsed.linkValidation).toHaveProperty('valid');
expect(parsed.linkValidation).toHaveProperty('brokenNotes');
expect(parsed.linkValidation).toHaveProperty('brokenHeadings');
});
it('should skip link validation when validateLinks is false', async () => {
const mockFile = createMockTFile('update-test.md');
const newContent = 'Updated content with [[Some Link]].';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue('Old content');
mockVault.modify = jest.fn().mockResolvedValue(undefined);
const result = await noteTools.updateNote('update-test.md', newContent, false);
expect(result.isError).toBeFalsy();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.wordCount).toBeDefined();
expect(parsed.linkValidation).toBeUndefined();
});
});
describe('updateSections with word count and link validation', () => {
it('should return word count for entire note after section update', async () => {
const mockFile = createMockTFile('sections-test.md');
const edits = [{ startLine: 2, endLine: 2, content: 'Updated line two with more words' }];
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue('Line 1\nLine 2\nLine 3');
mockVault.modify = jest.fn().mockResolvedValue(undefined);
const result = await noteTools.updateSections('sections-test.md', edits);
expect(result.isError).toBeFalsy();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.wordCount).toBeGreaterThan(0);
expect(parsed.sectionsUpdated).toBe(1);
});
it('should return link validation structure for entire note after section update', async () => {
const mockFile = createMockTFile('sections-test.md');
const edits = [{ startLine: 2, endLine: 2, content: 'See [[Link Target]] here' }];
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue('Line 1\nLine 2\nLine 3');
mockVault.modify = jest.fn().mockResolvedValue(undefined);
const result = await noteTools.updateSections('sections-test.md', edits);
expect(result.isError).toBeFalsy();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.linkValidation).toBeDefined();
expect(parsed.linkValidation).toHaveProperty('valid');
expect(parsed.linkValidation).toHaveProperty('brokenNotes');
});
it('should skip link validation when validateLinks is false', async () => {
const mockFile = createMockTFile('sections-test.md');
const edits = [{ startLine: 1, endLine: 1, content: 'Updated with [[Link]]' }];
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue('Line 1\nLine 2\nLine 3');
mockVault.modify = jest.fn().mockResolvedValue(undefined);
const result = await noteTools.updateSections('sections-test.md', edits, undefined, false);
expect(result.isError).toBeFalsy();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.wordCount).toBeDefined();
expect(parsed.linkValidation).toBeUndefined();
});
});
describe('Word count with frontmatter and comments', () => {
it('should exclude frontmatter from word count', async () => {
const content = `---
title: Test Note
tags: [test]
---
This is the actual content with words.`;
const mockFile = createMockTFile('test-note.md');
mockVault.create = jest.fn().mockResolvedValue(mockFile);
const result = await noteTools.createNote('test-note.md', content);
expect(result.isError).toBeFalsy();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.wordCount).toBe(7); // "This is the actual content with words."
});
it('should exclude Obsidian comments from word count', async () => {
const content = `This is visible. %% This is hidden %% More visible.`;
const mockFile = createMockTFile('test-note.md');
mockVault.create = jest.fn().mockResolvedValue(mockFile);
const result = await noteTools.createNote('test-note.md', content);
expect(result.isError).toBeFalsy();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.wordCount).toBe(6); // "This is visible. More visible."
});
});
});
});

View File

@@ -1,6 +1,6 @@
import { App } from 'obsidian';
import { NoteTools } from '../src/tools/note-tools';
import { createMockVaultAdapter, createMockFileManagerAdapter, createMockTFile, createMockTFolder } from './__mocks__/adapters';
import { createMockVaultAdapter, createMockFileManagerAdapter, createMockMetadataCacheAdapter, createMockTFile, createMockTFolder } from './__mocks__/adapters';
// Mock Obsidian API
jest.mock('obsidian');
@@ -9,11 +9,13 @@ describe('Enhanced Parent Folder Detection', () => {
let noteTools: NoteTools;
let mockVault: ReturnType<typeof createMockVaultAdapter>;
let mockFileManager: ReturnType<typeof createMockFileManagerAdapter>;
let mockMetadata: ReturnType<typeof createMockMetadataCacheAdapter>;
let mockApp: App;
beforeEach(() => {
mockVault = createMockVaultAdapter();
mockFileManager = createMockFileManagerAdapter();
mockMetadata = createMockMetadataCacheAdapter();
// Create a minimal mock App that supports PathUtils
// Use a getter to ensure it always uses the current mock
@@ -25,7 +27,7 @@ describe('Enhanced Parent Folder Detection', () => {
}
} as any;
noteTools = new NoteTools(mockVault, mockFileManager, mockApp);
noteTools = new NoteTools(mockVault, mockFileManager, mockMetadata, mockApp);
});
describe('Explicit parent folder detection', () => {

View File

@@ -0,0 +1,146 @@
import { ContentUtils } from '../../src/utils/content-utils';
describe('ContentUtils', () => {
describe('countWords', () => {
it('should count words in simple text', () => {
const content = 'This is a simple test.';
expect(ContentUtils.countWords(content)).toBe(5);
});
it('should count words with multiple spaces', () => {
const content = 'This is a test';
expect(ContentUtils.countWords(content)).toBe(4);
});
it('should exclude frontmatter from word count', () => {
const content = `---
title: My Note
tags: [test, example]
---
This is the actual content with seven words.`;
expect(ContentUtils.countWords(content)).toBe(8); // "This is the actual content with seven words."
});
it('should include code blocks in word count', () => {
const content = `This is text.
\`\`\`javascript
function test() {
return true;
}
\`\`\`
More text here.`;
// Counts: This, is, text., ```javascript, function, test(), {, return, true;, }, ```, More, text, here.
expect(ContentUtils.countWords(content)).toBe(14);
});
it('should include inline code in word count', () => {
const content = 'Use the `console.log` function to debug.';
// Counts: Use, the, `console.log`, function, to, debug.
expect(ContentUtils.countWords(content)).toBe(6);
});
it('should exclude Obsidian comments from word count', () => {
const content = `This is visible text.
%% This is a comment and should not be counted %%
More visible text.`;
expect(ContentUtils.countWords(content)).toBe(7); // "This is visible text. More visible text."
});
it('should exclude multi-line Obsidian comments', () => {
const content = `Start of note.
%%
This is a multi-line comment
that spans several lines
and should not be counted
%%
End of note.`;
expect(ContentUtils.countWords(content)).toBe(6); // "Start of note. End of note."
});
it('should handle multiple Obsidian comments', () => {
const content = `First section. %% comment one %% Second section. %% comment two %% Third section.`;
expect(ContentUtils.countWords(content)).toBe(6); // "First section. Second section. Third section."
});
it('should count zero words for empty content', () => {
expect(ContentUtils.countWords('')).toBe(0);
});
it('should count zero words for only whitespace', () => {
expect(ContentUtils.countWords(' \n\n \t ')).toBe(0);
});
it('should count zero words for only frontmatter', () => {
const content = `---
title: Test
---`;
expect(ContentUtils.countWords(content)).toBe(0);
});
it('should count zero words for only comments', () => {
const content = '%% This is just a comment %%';
expect(ContentUtils.countWords(content)).toBe(0);
});
it('should handle content with headings', () => {
const content = `# Main Heading
This is a paragraph with some text.
## Subheading
More text here.`;
// Counts: #, Main, Heading, This, is, a, paragraph, with, some, text., ##, Subheading, More, text, here.
expect(ContentUtils.countWords(content)).toBe(15);
});
it('should handle content with lists', () => {
const content = `- Item one
- Item two
- Item three
1. Numbered one
2. Numbered two`;
// Counts: -, Item, one, -, Item, two, -, Item, three, 1., Numbered, one, 2., Numbered, two
expect(ContentUtils.countWords(content)).toBe(15);
});
it('should handle content with wikilinks', () => {
const content = 'See [[Other Note]] for more details.';
expect(ContentUtils.countWords(content)).toBe(6); // Links are counted as words
});
it('should handle complex mixed content', () => {
const content = `---
title: Complex Note
tags: [test]
---
# Introduction
This is a test note with [[links]] and \`code\`.
%% This comment should not be counted %%
\`\`\`python
def hello():
print("world")
\`\`\`
## Conclusion
Final thoughts here.`;
// Excluding frontmatter and comment, counts:
// #, Introduction, This, is, a, test, note, with, [[links]], and, `code`.,
// ```python, def, hello():, print("world"), ```, ##, Conclusion, Final, thoughts, here.
expect(ContentUtils.countWords(content)).toBe(21);
});
});
});