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

@@ -385,6 +385,60 @@ export class ToolRegistry {
},
required: ["path"]
}
},
{
name: "validate_wikilinks",
description: "Validate all wikilinks in a note and report unresolved links. Parses all [[wikilinks]] in the file, resolves them using Obsidian's link resolution rules, and provides suggestions for broken links. Returns structured JSON with total link count, arrays of resolved links (with targets) and unresolved links (with suggestions). Use this to identify and fix broken links in your notes.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Vault-relative path to the note to validate (e.g., 'projects/project.md'). Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
}
},
required: ["path"]
}
},
{
name: "resolve_wikilink",
description: "Resolve a single wikilink from a source note to its target path. Uses Obsidian's link resolution rules including shortest path matching, relative paths, and aliases. Returns structured JSON with resolution status, target path if found, or suggestions if not found. Supports links with headings ([[note#heading]]) and aliases ([[note|alias]]). Use this to programmatically resolve links before following them.",
inputSchema: {
type: "object",
properties: {
sourcePath: {
type: "string",
description: "Vault-relative path to the source note containing the link (e.g., 'projects/project.md'). Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
},
linkText: {
type: "string",
description: "The wikilink text to resolve (without brackets). Examples: 'target note', 'folder/note', 'note#heading', 'note|alias'. Can include heading references and aliases."
}
},
required: ["sourcePath", "linkText"]
}
},
{
name: "backlinks",
description: "Get all backlinks to a note. Returns all notes that link to the target note, with optional unlinked mentions (text references without wikilinks). Uses Obsidian's MetadataCache for accurate backlink detection. Returns structured JSON with array of backlinks, each containing source path, type (linked/unlinked), and occurrences with line numbers and context snippets. Use this to explore note connections and build knowledge graphs.",
inputSchema: {
type: "object",
properties: {
path: {
type: "string",
description: "Vault-relative path to the target note (e.g., 'concepts/important-concept.md'). Paths are case-sensitive on macOS/Linux. Do not use leading or trailing slashes."
},
includeUnlinked: {
type: "boolean",
description: "If true, include unlinked mentions (text references without [[brackets]]). If false (default), only include wikilinks. Default: false. Warning: enabling this can be slow for large vaults."
},
includeSnippets: {
type: "boolean",
description: "If true (default), include context snippets for each backlink occurrence. If false, omit snippets to reduce response size. Default: true"
}
},
required: ["path"]
}
}
];
}
@@ -474,6 +528,16 @@ export class ToolRegistry {
return await this.vaultTools.getFolderWaypoint(args.path);
case "is_folder_note":
return await this.vaultTools.isFolderNote(args.path);
case "validate_wikilinks":
return await this.vaultTools.validateWikilinks(args.path);
case "resolve_wikilink":
return await this.vaultTools.resolveWikilink(args.sourcePath, args.linkText);
case "backlinks":
return await this.vaultTools.getBacklinks(
args.path,
args.includeUnlinked ?? false,
args.includeSnippets ?? true
);
default:
return {
content: [{ type: "text", text: `Unknown tool: ${name}` }],

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
};
}
}
}

View File

@@ -284,3 +284,70 @@ export interface DeleteNoteResult {
dryRun: boolean;
soft: boolean;
}
// Phase 9: Linking & Backlinks Types
/**
* Resolved wikilink information
*/
export interface ResolvedLink {
text: string;
target: string;
alias?: string;
}
/**
* Unresolved wikilink information
*/
export interface UnresolvedLink {
text: string;
line: number;
suggestions: string[];
}
/**
* Result from validate_wikilinks operation
*/
export interface ValidateWikilinksResult {
path: string;
totalLinks: number;
resolvedLinks: ResolvedLink[];
unresolvedLinks: UnresolvedLink[];
}
/**
* Result from resolve_wikilink operation
*/
export interface ResolveWikilinkResult {
sourcePath: string;
linkText: string;
resolved: boolean;
targetPath?: string;
suggestions?: string[];
}
/**
* Backlink occurrence in a file
*/
export interface BacklinkOccurrence {
line: number;
snippet: string;
}
/**
* Backlink from a source file
*/
export interface BacklinkInfo {
sourcePath: string;
type: 'linked' | 'unlinked';
occurrences: BacklinkOccurrence[];
}
/**
* Result from backlinks operation
*/
export interface BacklinksResult {
path: string;
backlinks: BacklinkInfo[];
totalBacklinks: number;
}

391
src/utils/link-utils.ts Normal file
View File

@@ -0,0 +1,391 @@
import { App, TFile, MetadataCache } from 'obsidian';
/**
* Parsed wikilink structure
*/
export interface WikiLink {
/** Full link text including brackets: [[link]] or [[link|alias]] */
raw: string;
/** Link target (the part before |) */
target: string;
/** Display alias (the part after |), if present */
alias?: string;
/** Line number where the link appears (1-indexed) */
line: number;
/** Column where the link starts (0-indexed) */
column: number;
}
/**
* Resolved link information
*/
export interface ResolvedLink {
/** Original link text */
text: string;
/** Resolved target file path */
target: string;
/** Display alias, if present */
alias?: string;
}
/**
* Unresolved link information
*/
export interface UnresolvedLink {
/** Original link text */
text: string;
/** Line number where the link appears */
line: number;
/** Suggested potential matches */
suggestions: string[];
}
/**
* Backlink occurrence in a file
*/
export interface BacklinkOccurrence {
/** Line number where the backlink appears */
line: number;
/** Context snippet around the backlink */
snippet: string;
}
/**
* Backlink from a source file
*/
export interface Backlink {
/** Source file path that contains the link */
sourcePath: string;
/** Type of backlink: linked (wikilink) or unlinked (text mention) */
type: 'linked' | 'unlinked';
/** List of occurrences in the source file */
occurrences: BacklinkOccurrence[];
}
/**
* Utilities for working with wikilinks and backlinks
*/
export class LinkUtils {
/**
* Regex pattern for matching wikilinks: [[target]] or [[target|alias]]
* Matches:
* - [[simple link]]
* - [[link with spaces]]
* - [[link|with alias]]
* - [[folder/nested link]]
* - [[link#heading]]
* - [[link#heading|alias]]
*/
private static readonly WIKILINK_REGEX = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
/**
* Parse all wikilinks from content
* @param content File content to parse
* @returns Array of parsed wikilinks with positions
*/
static parseWikilinks(content: string): WikiLink[] {
const links: WikiLink[] = [];
const lines = content.split('\n');
for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) {
const line = lines[lineIndex];
const regex = new RegExp(this.WIKILINK_REGEX);
let match: RegExpExecArray | null;
while ((match = regex.exec(line)) !== null) {
const raw = match[0];
const target = match[1].trim();
const alias = match[2]?.trim();
links.push({
raw,
target,
alias,
line: lineIndex + 1, // 1-indexed
column: match.index
});
}
}
return links;
}
/**
* Resolve a wikilink to its target file
* Uses Obsidian's MetadataCache for accurate resolution
*
* @param app Obsidian App instance
* @param sourcePath Path of the file containing the link
* @param linkText Link text (without brackets)
* @returns Resolved file or null if not found
*/
static resolveLink(app: App, sourcePath: string, linkText: string): TFile | null {
// Get the source file
const sourceFile = app.vault.getAbstractFileByPath(sourcePath);
if (!(sourceFile instanceof TFile)) {
return null;
}
// Use Obsidian's MetadataCache to resolve the link
// This handles all of Obsidian's link resolution rules:
// - Shortest path matching
// - Relative paths
// - Aliases
// - Headings and blocks
const resolvedFile = app.metadataCache.getFirstLinkpathDest(linkText, sourcePath);
return resolvedFile;
}
/**
* Find potential matches for an unresolved link
* Uses fuzzy matching on file names
*
* @param app Obsidian App instance
* @param linkText Link text to find matches for
* @param maxSuggestions Maximum number of suggestions to return
* @returns Array of suggested file paths
*/
static findSuggestions(app: App, linkText: string, maxSuggestions: number = 5): string[] {
const allFiles = app.vault.getMarkdownFiles();
const suggestions: Array<{ path: string; score: number }> = [];
// Remove heading/block references for matching
const cleanLinkText = linkText.split('#')[0].split('^')[0].toLowerCase();
for (const file of allFiles) {
const fileName = file.basename.toLowerCase();
const filePath = file.path.toLowerCase();
// Calculate similarity score
let score = 0;
// Exact basename match (highest priority)
if (fileName === cleanLinkText) {
score = 1000;
}
// Basename contains link text
else if (fileName.includes(cleanLinkText)) {
score = 500 + (cleanLinkText.length / fileName.length) * 100;
}
// Path contains link text
else if (filePath.includes(cleanLinkText)) {
score = 250 + (cleanLinkText.length / filePath.length) * 100;
}
// Levenshtein-like: count matching characters
else {
let matchCount = 0;
for (const char of cleanLinkText) {
if (fileName.includes(char)) {
matchCount++;
}
}
score = (matchCount / cleanLinkText.length) * 100;
}
if (score > 0) {
suggestions.push({ path: file.path, score });
}
}
// Sort by score (descending) and return top N
suggestions.sort((a, b) => b.score - a.score);
return suggestions.slice(0, maxSuggestions).map(s => s.path);
}
/**
* Get all backlinks to a file
* Uses Obsidian's MetadataCache for accurate backlink detection
*
* @param app Obsidian App instance
* @param targetPath Path of the file to find backlinks for
* @param includeUnlinked Whether to include unlinked mentions
* @returns Array of backlinks
*/
static async getBacklinks(
app: App,
targetPath: string,
includeUnlinked: boolean = false
): Promise<Backlink[]> {
const backlinks: Backlink[] = [];
const targetFile = app.vault.getAbstractFileByPath(targetPath);
if (!(targetFile instanceof TFile)) {
return backlinks;
}
// Get the target file's basename for matching
const targetBasename = targetFile.basename;
// Get all backlinks from MetadataCache using resolvedLinks
// resolvedLinks is a map of: sourcePath -> { targetPath: linkCount }
const resolvedLinks = app.metadataCache.resolvedLinks;
// Find all files that link to our target
for (const [sourcePath, links] of Object.entries(resolvedLinks)) {
// Check if this source file links to our target
if (!links[targetPath]) {
continue;
}
const sourceFile = app.vault.getAbstractFileByPath(sourcePath);
if (!(sourceFile instanceof TFile)) {
continue;
}
// Read the source file to find link occurrences
const content = await app.vault.read(sourceFile);
const lines = content.split('\n');
const occurrences: BacklinkOccurrence[] = [];
// Parse wikilinks in the source file to find references to target
const wikilinks = this.parseWikilinks(content);
for (const link of wikilinks) {
// Resolve this link to see if it points to our target
const resolvedFile = this.resolveLink(app, sourcePath, link.target);
if (resolvedFile && resolvedFile.path === targetPath) {
const snippet = this.extractSnippet(lines, link.line - 1, 100);
occurrences.push({
line: link.line,
snippet
});
}
}
if (occurrences.length > 0) {
backlinks.push({
sourcePath,
type: 'linked',
occurrences
});
}
}
// Process unlinked mentions if requested
if (includeUnlinked) {
const allFiles = app.vault.getMarkdownFiles();
// Build a set of files that already have linked backlinks
const linkedSourcePaths = new Set(backlinks.map(b => b.sourcePath));
for (const file of allFiles) {
// Skip if already in linked backlinks
if (linkedSourcePaths.has(file.path)) {
continue;
}
// Skip the target file itself
if (file.path === targetPath) {
continue;
}
const content = await app.vault.read(file);
const lines = content.split('\n');
const occurrences: BacklinkOccurrence[] = [];
// Search for unlinked mentions of the target basename
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Use word boundary regex to find whole word matches
const regex = new RegExp(`\\b${this.escapeRegex(targetBasename)}\\b`, 'gi');
if (regex.test(line)) {
const snippet = this.extractSnippet(lines, i, 100);
occurrences.push({
line: i + 1, // 1-indexed
snippet
});
}
}
if (occurrences.length > 0) {
backlinks.push({
sourcePath: file.path,
type: 'unlinked',
occurrences
});
}
}
}
return backlinks;
}
/**
* Extract a snippet of text around a specific line
* @param lines Array of lines
* @param lineIndex Line index (0-indexed)
* @param maxLength Maximum snippet length
* @returns Snippet text
*/
private static extractSnippet(lines: string[], lineIndex: number, maxLength: number): string {
const line = lines[lineIndex] || '';
// If line is short enough, return it as-is
if (line.length <= maxLength) {
return line;
}
// Truncate and add ellipsis
const half = Math.floor(maxLength / 2);
return line.substring(0, half) + '...' + line.substring(line.length - half);
}
/**
* Escape special regex characters
* @param str String to escape
* @returns Escaped string
*/
private static escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Validate all wikilinks in a file
* @param app Obsidian App instance
* @param filePath Path of the file to validate
* @returns Object with resolved and unresolved links
*/
static async validateWikilinks(
app: App,
filePath: string
): Promise<{
resolvedLinks: ResolvedLink[];
unresolvedLinks: UnresolvedLink[];
}> {
const file = app.vault.getAbstractFileByPath(filePath);
if (!(file instanceof TFile)) {
return { resolvedLinks: [], unresolvedLinks: [] };
}
const content = await app.vault.read(file);
const wikilinks = this.parseWikilinks(content);
const resolvedLinks: ResolvedLink[] = [];
const unresolvedLinks: UnresolvedLink[] = [];
for (const link of wikilinks) {
const resolvedFile = this.resolveLink(app, filePath, link.target);
if (resolvedFile) {
resolvedLinks.push({
text: link.raw,
target: resolvedFile.path,
alias: link.alias
});
} else {
const suggestions = this.findSuggestions(app, link.target);
unresolvedLinks.push({
text: link.raw,
line: link.line,
suggestions
});
}
}
return { resolvedLinks, unresolvedLinks };
}
}