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:
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user