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:
14
CHANGELOG.md
14
CHANGELOG.md
@@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [1.1.0] - 2025-10-30
|
||||
|
||||
### Added
|
||||
- **Word Count**: `create_note`, `update_note`, and `update_sections` now automatically return word count for the note content
|
||||
- Excludes YAML frontmatter and Obsidian comments (`%% ... %%`) from word count
|
||||
@@ -18,10 +22,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
- Returns detailed broken link information including line number and context snippet
|
||||
- Provides human-readable summary (e.g., "15 links: 12 valid, 2 broken notes, 1 broken heading")
|
||||
- Can be disabled via `validateLinks: false` parameter for performance-critical operations
|
||||
- **Word Count for Read Operations**: Extended word count support to read operations
|
||||
- `read_note` now automatically includes `wordCount` when returning content (with `withContent` or `parseFrontmatter` options)
|
||||
- `stat` supports optional `includeWordCount` parameter to compute word count (with performance warning)
|
||||
- `list` supports optional `includeWordCount` parameter to compute word count for all files (with performance warning)
|
||||
- All read operations use the same word counting rules as write operations (excludes frontmatter and Obsidian comments)
|
||||
- Best-effort error handling: unreadable files are skipped in batch operations without failing the entire request
|
||||
|
||||
### Changed
|
||||
- `create_note`, `update_note`, and `update_sections` response format now includes `wordCount` and optional `linkValidation` fields
|
||||
- `updateNote` now returns structured JSON response instead of simple success message (includes success, path, versionId, modified, wordCount, linkValidation)
|
||||
- `read_note` response now includes `wordCount` field when returning content
|
||||
- `stat` response includes optional `wordCount` field in metadata when `includeWordCount: true`
|
||||
- `list` response includes optional `wordCount` field for each file when `includeWordCount: true`
|
||||
- Type definitions updated: `ParsedNote` and `FileMetadata` interfaces now include optional `wordCount?: number` field
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export class ToolRegistry {
|
||||
return [
|
||||
{
|
||||
name: "read_note",
|
||||
description: "Read the content of a file from the Obsidian vault with optional frontmatter parsing. Use this to read the contents of a specific note or file. Path must be vault-relative (no leading slash) and include the file extension. Use list() first if you're unsure of the exact path. This only works on files, not folders. By default returns raw content. Set parseFrontmatter to true to get structured data with separated frontmatter and content.",
|
||||
description: "Read the content of a file from the Obsidian vault with optional frontmatter parsing. Returns word count (excluding frontmatter and Obsidian comments) when content is included in the response. Use this to read the contents of a specific note or file. Path must be vault-relative (no leading slash) and include the file extension. Use list() first if you're unsure of the exact path. This only works on files, not folders. By default returns raw content with word count. Set parseFrontmatter to true to get structured data with separated frontmatter, content, and word count.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -289,7 +289,7 @@ export class ToolRegistry {
|
||||
},
|
||||
{
|
||||
name: "list",
|
||||
description: "List files and/or directories with advanced filtering, recursion, and pagination. Returns structured JSON with file/directory metadata and optional frontmatter summaries. Supports glob patterns for includes/excludes, recursive traversal, type filtering, and cursor-based pagination. Use this to explore vault structure with fine-grained control.",
|
||||
description: "List files and/or directories with advanced filtering, recursion, and pagination. Returns structured JSON with file/directory metadata and optional frontmatter summaries. Optional: includeWordCount (boolean) - If true, read each file's content and compute word count (excluding frontmatter and Obsidian comments). WARNING: This can be very slow for large directories or recursive listings, as it reads every file. Files that cannot be read are skipped (best effort). Only computed for files, not directories. Supports glob patterns for includes/excludes, recursive traversal, type filtering, and cursor-based pagination. Use this to explore vault structure with fine-grained control.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
@@ -327,19 +327,27 @@ export class ToolRegistry {
|
||||
withFrontmatterSummary: {
|
||||
type: "boolean",
|
||||
description: "If true, include parsed frontmatter (title, tags, aliases) for markdown files without reading full content. Default: false."
|
||||
},
|
||||
includeWordCount: {
|
||||
type: "boolean",
|
||||
description: "If true, read each file's content and compute word count. WARNING: Can be very slow for large directories or recursive listings. Only applies to files. Default: false"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "stat",
|
||||
description: "Get detailed metadata for a file or folder at a specific path. Returns existence status, kind (file or directory), and full metadata including size, dates, etc. Use this to check if a path exists and get its properties. More detailed than exists() but slightly slower. Returns structured JSON with path, exists boolean, kind, and metadata object.",
|
||||
description: "Get detailed metadata for a file or folder at a specific path. Returns existence status, kind (file or directory), and full metadata including size, dates, etc. Optional: includeWordCount (boolean) - If true, read file content and compute word count (excluding frontmatter and Obsidian comments). WARNING: This requires reading the entire file and is significantly slower than metadata-only stat. Only works for files, not directories. Use this to check if a path exists and get its properties. More detailed than exists() but slightly slower. Returns structured JSON with path, exists boolean, kind, and metadata object.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Vault-relative path to check (e.g., 'folder/note.md' or 'projects'). Can be a file or folder. Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
|
||||
},
|
||||
includeWordCount: {
|
||||
type: "boolean",
|
||||
description: "If true, read file content and compute word count. WARNING: Significantly slower than metadata-only stat. Only applies to files. Default: false"
|
||||
}
|
||||
},
|
||||
required: ["path"]
|
||||
@@ -561,11 +569,12 @@ export class ToolRegistry {
|
||||
only: args.only,
|
||||
limit: args.limit,
|
||||
cursor: args.cursor,
|
||||
withFrontmatterSummary: args.withFrontmatterSummary
|
||||
withFrontmatterSummary: args.withFrontmatterSummary,
|
||||
includeWordCount: args.includeWordCount
|
||||
});
|
||||
break;
|
||||
case "stat":
|
||||
result = await this.vaultTools.stat(args.path);
|
||||
result = await this.vaultTools.stat(args.path, args.includeWordCount);
|
||||
break;
|
||||
case "exists":
|
||||
result = await this.vaultTools.exists(args.path);
|
||||
|
||||
@@ -82,6 +82,17 @@ export class NoteTools {
|
||||
|
||||
// If no special options, return simple content
|
||||
if (!parseFrontmatter) {
|
||||
// Compute word count when returning content
|
||||
if (withContent) {
|
||||
const wordCount = ContentUtils.countWords(content);
|
||||
const result = {
|
||||
content,
|
||||
wordCount
|
||||
};
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: content }]
|
||||
};
|
||||
@@ -110,6 +121,11 @@ export class NoteTools {
|
||||
result.contentWithoutFrontmatter = extracted.contentWithoutFrontmatter;
|
||||
}
|
||||
|
||||
// Add word count when content is included
|
||||
if (withContent) {
|
||||
result.wordCount = ContentUtils.countWords(content);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { GlobUtils } from '../utils/glob-utils';
|
||||
import { SearchUtils } from '../utils/search-utils';
|
||||
import { WaypointUtils } from '../utils/waypoint-utils';
|
||||
import { LinkUtils } from '../utils/link-utils';
|
||||
import { ContentUtils } from '../utils/content-utils';
|
||||
import { IVaultAdapter, IMetadataCacheAdapter } from '../adapters/interfaces';
|
||||
|
||||
export class VaultTools {
|
||||
@@ -145,6 +146,7 @@ export class VaultTools {
|
||||
limit?: number;
|
||||
cursor?: string;
|
||||
withFrontmatterSummary?: boolean;
|
||||
includeWordCount?: boolean;
|
||||
}): Promise<CallToolResult> {
|
||||
const {
|
||||
path,
|
||||
@@ -154,7 +156,8 @@ export class VaultTools {
|
||||
only = 'any',
|
||||
limit,
|
||||
cursor,
|
||||
withFrontmatterSummary = false
|
||||
withFrontmatterSummary = false,
|
||||
includeWordCount = false
|
||||
} = options;
|
||||
|
||||
let items: Array<FileMetadataWithFrontmatter | DirectoryMetadata> = [];
|
||||
@@ -201,7 +204,7 @@ export class VaultTools {
|
||||
}
|
||||
|
||||
// Collect items based on recursive flag
|
||||
await this.collectItems(targetFolder, items, recursive, includes, excludes, only, withFrontmatterSummary);
|
||||
await this.collectItems(targetFolder, items, recursive, includes, excludes, only, withFrontmatterSummary, includeWordCount);
|
||||
|
||||
// Sort: directories first, then files, alphabetically within each group
|
||||
items.sort((a, b) => {
|
||||
@@ -259,7 +262,8 @@ export class VaultTools {
|
||||
includes?: string[],
|
||||
excludes?: string[],
|
||||
only?: 'files' | 'directories' | 'any',
|
||||
withFrontmatterSummary?: boolean
|
||||
withFrontmatterSummary?: boolean,
|
||||
includeWordCount?: boolean
|
||||
): Promise<void> {
|
||||
for (const item of folder.children) {
|
||||
// Skip the vault root itself
|
||||
@@ -276,6 +280,18 @@ export class VaultTools {
|
||||
if (item instanceof TFile) {
|
||||
if (only !== 'directories') {
|
||||
const fileMetadata = await this.createFileMetadataWithFrontmatter(item, withFrontmatterSummary || false);
|
||||
|
||||
// Optionally include word count (best effort)
|
||||
if (includeWordCount) {
|
||||
try {
|
||||
const content = await this.vault.read(item);
|
||||
fileMetadata.wordCount = ContentUtils.countWords(content);
|
||||
} catch (error) {
|
||||
// Skip word count if file can't be read (binary file, etc.)
|
||||
// wordCount field simply omitted for this file
|
||||
}
|
||||
}
|
||||
|
||||
items.push(fileMetadata);
|
||||
}
|
||||
} else if (item instanceof TFolder) {
|
||||
@@ -285,7 +301,7 @@ export class VaultTools {
|
||||
|
||||
// Recursively collect from subfolders if needed
|
||||
if (recursive) {
|
||||
await this.collectItems(item, items, recursive, includes, excludes, only, withFrontmatterSummary);
|
||||
await this.collectItems(item, items, recursive, includes, excludes, only, withFrontmatterSummary, includeWordCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -386,7 +402,7 @@ export class VaultTools {
|
||||
}
|
||||
|
||||
// Phase 3: Discovery Endpoints
|
||||
async stat(path: string): Promise<CallToolResult> {
|
||||
async stat(path: string, includeWordCount: boolean = false): Promise<CallToolResult> {
|
||||
// Validate path
|
||||
if (!PathUtils.isValidVaultPath(path)) {
|
||||
return {
|
||||
@@ -417,11 +433,23 @@ export class VaultTools {
|
||||
|
||||
// Check if it's a file
|
||||
if (item instanceof TFile) {
|
||||
const metadata = this.createFileMetadata(item);
|
||||
|
||||
// Optionally include word count
|
||||
if (includeWordCount) {
|
||||
try {
|
||||
const content = await this.vault.read(item);
|
||||
metadata.wordCount = ContentUtils.countWords(content);
|
||||
} catch (error) {
|
||||
// Skip word count if file can't be read (binary file, etc.)
|
||||
}
|
||||
}
|
||||
|
||||
const result: StatResult = {
|
||||
path: normalizedPath,
|
||||
exists: true,
|
||||
kind: "file",
|
||||
metadata: this.createFileMetadata(item)
|
||||
metadata
|
||||
};
|
||||
return {
|
||||
content: [{
|
||||
|
||||
@@ -73,6 +73,7 @@ export interface FileMetadata {
|
||||
size: number;
|
||||
modified: number;
|
||||
created: number;
|
||||
wordCount?: number;
|
||||
}
|
||||
|
||||
export interface DirectoryMetadata {
|
||||
@@ -181,6 +182,7 @@ export interface ParsedNote {
|
||||
parsedFrontmatter?: Record<string, any>;
|
||||
content: string;
|
||||
contentWithoutFrontmatter?: string;
|
||||
wordCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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