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:
19
CHANGELOG.md
19
CHANGELOG.md
@@ -6,6 +6,25 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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
|
||||
- Includes all other content (code blocks, inline code, headings, lists, etc.)
|
||||
- **Link Validation**: `create_note`, `update_note`, and `update_sections` now automatically validate all wikilinks and embeds
|
||||
- Validates basic wikilinks (`[[Note]]`), heading links (`[[Note#Heading]]`), and embeds (`![[file.ext]]`)
|
||||
- Categorizes links as: valid, broken notes (note doesn't exist), or broken headings (note exists but heading missing)
|
||||
- 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
|
||||
|
||||
### 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)
|
||||
|
||||
---
|
||||
|
||||
## [1.0.1] - 2025-10-28
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -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) }]
|
||||
};
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
42
src/utils/content-utils.ts
Normal file
42
src/utils/content-utils.ts
Normal 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, '');
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
146
tests/utils/content-utils.test.ts
Normal file
146
tests/utils/content-utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user