feat: add word count support for read operations

Extended word count functionality to read operations (read_note, stat, list) to complement existing write operation support.

Changes:
- read_note: Now automatically includes wordCount when returning content (with withContent or parseFrontmatter options)
- stat: Added optional includeWordCount parameter with performance warning
- list: Added optional includeWordCount parameter with performance warning
- All operations use same word counting rules (excludes frontmatter and Obsidian comments)
- Best-effort error handling for batch operations

Technical details:
- Updated ParsedNote and FileMetadata type definitions to include optional wordCount field
- Added comprehensive test coverage (18 new tests)
- Updated tool descriptions with usage notes and performance warnings
- Updated CHANGELOG.md to document new features in version 1.1.0
This commit is contained in:
2025-10-30 10:46:16 -04:00
parent c2002b0cdb
commit f8c7b6d53f
7 changed files with 380 additions and 12 deletions

View File

@@ -18,6 +18,18 @@ jest.mock('../src/utils/path-utils', () => ({
}
}));
// Mock LinkUtils for link validation tests
jest.mock('../src/utils/link-utils', () => ({
LinkUtils: {
validateLinks: jest.fn().mockResolvedValue({
valid: [],
brokenNotes: [],
brokenHeadings: [],
summary: 'No links found'
})
}
}));
// Import the mocked PathUtils
import { PathUtils } from '../src/utils/path-utils';
@@ -50,7 +62,10 @@ describe('NoteTools', () => {
const result = await noteTools.readNote('test.md');
expect(result.isError).toBeUndefined();
expect(result.content[0].text).toBe(content);
// Now returns JSON with content and wordCount
const parsed = JSON.parse(result.content[0].text);
expect(parsed.content).toBe(content);
expect(parsed.wordCount).toBe(7); // Test Note This is test content
expect(mockVault.read).toHaveBeenCalledWith(mockFile);
});
@@ -101,6 +116,93 @@ describe('NoteTools', () => {
// frontmatter field is the raw YAML string
expect(parsed.frontmatter).toBeDefined();
});
it('should include word count when withContent is true', async () => {
const mockFile = createMockTFile('test.md');
const content = '# Test Note\n\nThis is a test note with some words.';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await noteTools.readNote('test.md', { withContent: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.content).toBe(content);
expect(parsed.wordCount).toBe(11); // Test Note This is a test note with some words
});
it('should include word count when parseFrontmatter is true', async () => {
const mockFile = createMockTFile('test.md');
const content = '---\ntitle: Test\n---\n\nThis is content.';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await noteTools.readNote('test.md', { parseFrontmatter: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.wordCount).toBe(3); // "This is content."
});
it('should exclude frontmatter from word count', async () => {
const mockFile = createMockTFile('test.md');
const content = '---\ntitle: Test Note\ntags: [test, example]\n---\n\nActual content words.';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await noteTools.readNote('test.md', { parseFrontmatter: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.wordCount).toBe(3); // "Actual content words."
});
it('should exclude Obsidian comments from word count', async () => {
const mockFile = createMockTFile('test.md');
const content = 'Visible text %% Hidden comment %% more visible.';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await noteTools.readNote('test.md', { withContent: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.wordCount).toBe(4); // "Visible text more visible"
});
it('should return 0 word count for empty file', async () => {
const mockFile = createMockTFile('empty.md');
const content = '';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await noteTools.readNote('empty.md', { withContent: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.wordCount).toBe(0);
});
it('should return JSON format even with default options', async () => {
const mockFile = createMockTFile('test.md');
const content = '# Test Note\n\nContent here.';
(PathUtils.resolveFile as jest.Mock).mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await noteTools.readNote('test.md');
expect(result.isError).toBeUndefined();
// Now returns JSON even with default options
const parsed = JSON.parse(result.content[0].text);
expect(parsed.content).toBe(content);
expect(parsed.wordCount).toBe(5); // Test Note Content here
});
});
describe('createNote', () => {

View File

@@ -195,6 +195,97 @@ describe('VaultTools', () => {
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Invalid path');
});
it('should include word count when includeWordCount is true', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 500
});
const content = '# Test Note\n\nThis is a test note with some words.';
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await vaultTools.stat('test.md', true);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.exists).toBe(true);
expect(parsed.kind).toBe('file');
expect(parsed.metadata.wordCount).toBe(11); // Test Note This is a test note with some words
expect(mockVault.read).toHaveBeenCalledWith(mockFile);
});
it('should not include word count when includeWordCount is false', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 500
});
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
mockVault.read = jest.fn();
const result = await vaultTools.stat('test.md', false);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.metadata.wordCount).toBeUndefined();
expect(mockVault.read).not.toHaveBeenCalled();
});
it('should exclude frontmatter from word count in stat', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 500
});
const content = '---\ntitle: Test Note\ntags: [test]\n---\n\nActual content words.';
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await vaultTools.stat('test.md', true);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.metadata.wordCount).toBe(3); // "Actual content words."
});
it('should handle read errors when computing word count', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 500
});
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
mockVault.read = jest.fn().mockRejectedValue(new Error('Cannot read file'));
const result = await vaultTools.stat('test.md', true);
// Should still succeed but without word count
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.exists).toBe(true);
expect(parsed.metadata.wordCount).toBeUndefined();
});
it('should not include word count for directories', async () => {
const mockFolder = createMockTFolder('folder1', [
createMockTFile('folder1/file1.md')
]);
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFolder);
const result = await vaultTools.stat('folder1', true);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.kind).toBe('directory');
expect(parsed.metadata.wordCount).toBeUndefined();
});
});
describe('exists', () => {
@@ -486,6 +577,112 @@ describe('VaultTools', () => {
expect(parsed.items.length).toBe(1);
expect(parsed.items[0].frontmatterSummary).toBeUndefined();
});
it('should include word count when includeWordCount is true', async () => {
const mockFile1 = createMockTFile('file1.md');
const mockFile2 = createMockTFile('file2.md');
const mockRoot = createMockTFolder('', [mockFile1, mockFile2]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockVault.read = jest.fn()
.mockResolvedValueOnce('# File One\n\nThis has five words.')
.mockResolvedValueOnce('# File Two\n\nThis has more than five words here.');
const result = await vaultTools.list({ includeWordCount: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(2);
expect(parsed.items[0].wordCount).toBe(7); // File One This has five words
expect(parsed.items[1].wordCount).toBe(10); // File Two This has more than five words here
});
it('should not include word count when includeWordCount is false', async () => {
const mockFile = createMockTFile('file.md');
const mockRoot = createMockTFolder('', [mockFile]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockVault.read = jest.fn();
const result = await vaultTools.list({ includeWordCount: false });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(1);
expect(parsed.items[0].wordCount).toBeUndefined();
expect(mockVault.read).not.toHaveBeenCalled();
});
it('should exclude frontmatter from word count in list', async () => {
const mockFile = createMockTFile('file.md');
const mockRoot = createMockTFolder('', [mockFile]);
const content = '---\ntitle: Test\ntags: [test]\n---\n\nActual content.';
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockVault.read = jest.fn().mockResolvedValue(content);
const result = await vaultTools.list({ includeWordCount: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items[0].wordCount).toBe(2); // "Actual content"
});
it('should handle read errors gracefully when computing word count', async () => {
const mockFile1 = createMockTFile('file1.md');
const mockFile2 = createMockTFile('file2.md');
const mockRoot = createMockTFolder('', [mockFile1, mockFile2]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockVault.read = jest.fn()
.mockResolvedValueOnce('Content for file 1.')
.mockRejectedValueOnce(new Error('Cannot read file2'));
const result = await vaultTools.list({ includeWordCount: true });
// Should still succeed but skip word count for unreadable files
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(2);
expect(parsed.items[0].wordCount).toBe(4); // "Content for file 1"
expect(parsed.items[1].wordCount).toBeUndefined(); // Error, skip word count
});
it('should not include word count for directories', async () => {
const mockFile = createMockTFile('file.md');
const mockFolder = createMockTFolder('folder');
const mockRoot = createMockTFolder('', [mockFile, mockFolder]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockVault.read = jest.fn().mockResolvedValue('Some content.');
const result = await vaultTools.list({ includeWordCount: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(2);
const fileItem = parsed.items.find((item: any) => item.kind === 'file');
const folderItem = parsed.items.find((item: any) => item.kind === 'directory');
expect(fileItem.wordCount).toBe(2); // "Some content"
expect(folderItem.wordCount).toBeUndefined();
});
it('should filter files and include word count', async () => {
const mockFile = createMockTFile('file.md');
const mockFolder = createMockTFolder('folder');
const mockRoot = createMockTFolder('', [mockFile, mockFolder]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockVault.read = jest.fn().mockResolvedValue('File content here.');
const result = await vaultTools.list({ only: 'files', includeWordCount: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(1);
expect(parsed.items[0].kind).toBe('file');
expect(parsed.items[0].wordCount).toBe(3); // "File content here"
});
});
describe('getBacklinks', () => {