feat: Phase 9 - Linking & Backlinks

Implement three new tools for wikilink validation, resolution, and backlink queries:

New Tools:
- validate_wikilinks: Validate all wikilinks in a note with suggestions for broken links
- resolve_wikilink: Resolve a single wikilink to its target path
- backlinks: Get all backlinks to a note with optional unlinked mentions

New Files:
- src/utils/link-utils.ts: Complete wikilink parsing, resolution, and backlink utilities

Modified Files:
- src/tools/vault-tools.ts: Added 3 new methods for link operations
- src/tools/index.ts: Added 3 tool definitions and handlers
- src/types/mcp-types.ts: Added Phase 9 type definitions
- ROADMAP.md: Marked Phase 9 as complete
- CHANGELOG.md: Added v8.0.0 release notes

Key Features:
- Regex-based wikilink parsing with position tracking
- Uses MetadataCache.getFirstLinkpathDest() for accurate resolution
- Fuzzy matching suggestion engine for broken links
- Efficient backlink detection using MetadataCache.resolvedLinks
- Optional unlinked mentions with word-boundary matching
- Context snippet extraction for each occurrence
This commit is contained in:
2025-10-17 00:52:51 -04:00
parent 99e2ade3ca
commit 6017f879f4
6 changed files with 877 additions and 26 deletions

View File

@@ -1,10 +1,11 @@
import { App, TFile, TFolder } from 'obsidian';
import { CallToolResult, FileMetadata, DirectoryMetadata, VaultInfo, SearchResult, SearchMatch, StatResult, ExistsResult, ListResult, FileMetadataWithFrontmatter, FrontmatterSummary, WaypointSearchResult, FolderWaypointResult, FolderNoteResult } from '../types/mcp-types';
import { CallToolResult, FileMetadata, DirectoryMetadata, VaultInfo, SearchResult, SearchMatch, StatResult, ExistsResult, ListResult, FileMetadataWithFrontmatter, FrontmatterSummary, WaypointSearchResult, FolderWaypointResult, FolderNoteResult, ValidateWikilinksResult, ResolveWikilinkResult, BacklinksResult } from '../types/mcp-types';
import { PathUtils } from '../utils/path-utils';
import { ErrorMessages } from '../utils/error-messages';
import { GlobUtils } from '../utils/glob-utils';
import { SearchUtils } from '../utils/search-utils';
import { WaypointUtils } from '../utils/waypoint-utils';
import { LinkUtils } from '../utils/link-utils';
export class VaultTools {
constructor(private app: App) {}
@@ -689,4 +690,172 @@ export class VaultTools {
};
}
}
/**
* Validate all wikilinks in a note
* Reports resolved and unresolved links with suggestions
*/
async validateWikilinks(path: string): Promise<CallToolResult> {
try {
// Normalize and validate path
const normalizedPath = PathUtils.normalizePath(path);
// Resolve file
const file = PathUtils.resolveFile(this.app, normalizedPath);
if (!file) {
return {
content: [{
type: "text",
text: ErrorMessages.fileNotFound(normalizedPath)
}],
isError: true
};
}
// Validate wikilinks
const { resolvedLinks, unresolvedLinks } = await LinkUtils.validateWikilinks(
this.app,
normalizedPath
);
const result: ValidateWikilinksResult = {
path: normalizedPath,
totalLinks: resolvedLinks.length + unresolvedLinks.length,
resolvedLinks,
unresolvedLinks
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
} catch (error) {
return {
content: [{
type: "text",
text: `Validate wikilinks error: ${(error as Error).message}`
}],
isError: true
};
}
}
/**
* Resolve a single wikilink from a source note
* Returns the target path if resolvable, or suggestions if not
*/
async resolveWikilink(sourcePath: string, linkText: string): Promise<CallToolResult> {
try {
// Normalize and validate source path
const normalizedPath = PathUtils.normalizePath(sourcePath);
// Resolve source file
const file = PathUtils.resolveFile(this.app, normalizedPath);
if (!file) {
return {
content: [{
type: "text",
text: ErrorMessages.fileNotFound(normalizedPath)
}],
isError: true
};
}
// Try to resolve the link
const resolvedFile = LinkUtils.resolveLink(this.app, normalizedPath, linkText);
const result: ResolveWikilinkResult = {
sourcePath: normalizedPath,
linkText,
resolved: resolvedFile !== null,
targetPath: resolvedFile?.path
};
// If not resolved, provide suggestions
if (!resolvedFile) {
result.suggestions = LinkUtils.findSuggestions(this.app, linkText);
}
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
} catch (error) {
return {
content: [{
type: "text",
text: `Resolve wikilink error: ${(error as Error).message}`
}],
isError: true
};
}
}
/**
* Get all backlinks to a note
* Optionally includes unlinked mentions
*/
async getBacklinks(
path: string,
includeUnlinked: boolean = false,
includeSnippets: boolean = true
): Promise<CallToolResult> {
try {
// Normalize and validate path
const normalizedPath = PathUtils.normalizePath(path);
// Resolve file
const file = PathUtils.resolveFile(this.app, normalizedPath);
if (!file) {
return {
content: [{
type: "text",
text: ErrorMessages.fileNotFound(normalizedPath)
}],
isError: true
};
}
// Get backlinks
const backlinks = await LinkUtils.getBacklinks(
this.app,
normalizedPath,
includeUnlinked
);
// If snippets not requested, remove them
if (!includeSnippets) {
for (const backlink of backlinks) {
for (const occurrence of backlink.occurrences) {
occurrence.snippet = '';
}
}
}
const result: BacklinksResult = {
path: normalizedPath,
backlinks,
totalBacklinks: backlinks.length
};
return {
content: [{
type: "text",
text: JSON.stringify(result, null, 2)
}]
};
} catch (error) {
return {
content: [{
type: "text",
text: `Get backlinks error: ${(error as Error).message}`
}],
isError: true
};
}
}
}