refactor: migrate VaultTools link methods to use adapters

Update validateWikilinks, resolveWikilink, and getBacklinks methods
to use IVaultAdapter and IMetadataCacheAdapter instead of direct App access.

- Implemented inline link suggestion finding using vault adapter
- Implemented backlinks retrieval using metadata cache adapter
- Added helper methods: findLinkSuggestions, extractSnippet, escapeRegex
- App parameter still required for waypoint methods (not in scope for this task)
This commit is contained in:
2025-10-19 23:45:13 -04:00
parent cfb3a50eac
commit 886730bf95
3 changed files with 2646 additions and 34 deletions

File diff suppressed because it is too large Load Diff

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "obsidian-mcp-server", "name": "obsidian-mcp-server",
"version": "1.0.0", "version": "3.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "obsidian-mcp-server", "name": "obsidian-mcp-server",
"version": "1.0.0", "version": "3.0.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cors": "^2.8.5", "cors": "^2.8.5",

View File

@@ -12,7 +12,7 @@ export class VaultTools {
constructor( constructor(
private vault: IVaultAdapter, private vault: IVaultAdapter,
private metadata: IMetadataCacheAdapter, private metadata: IMetadataCacheAdapter,
private app: App // Keep temporarily for methods not yet migrated private app: App // Still needed for waypoint methods (searchWaypoints, getFolderWaypoint, isFolderNote)
) {} ) {}
async getVaultInfo(): Promise<CallToolResult> { async getVaultInfo(): Promise<CallToolResult> {
@@ -837,10 +837,10 @@ export class VaultTools {
try { try {
// Normalize and validate path // Normalize and validate path
const normalizedPath = PathUtils.normalizePath(path); const normalizedPath = PathUtils.normalizePath(path);
// Resolve file // Get file using adapter
const file = PathUtils.resolveFile(this.app, normalizedPath); const file = this.vault.getAbstractFileByPath(normalizedPath);
if (!file) { if (!file || !(file instanceof TFile)) {
return { return {
content: [{ content: [{
type: "text", type: "text",
@@ -850,11 +850,34 @@ export class VaultTools {
}; };
} }
// Validate wikilinks // Read file content
const { resolvedLinks, unresolvedLinks } = await LinkUtils.validateWikilinks( const content = await this.vault.read(file);
this.app,
normalizedPath // Parse wikilinks
); const wikilinks = LinkUtils.parseWikilinks(content);
const resolvedLinks: any[] = [];
const unresolvedLinks: any[] = [];
for (const link of wikilinks) {
const resolvedFile = this.metadata.getFirstLinkpathDest(link.target, normalizedPath);
if (resolvedFile) {
resolvedLinks.push({
text: link.raw,
target: resolvedFile.path,
alias: link.alias
});
} else {
// Find suggestions (need to implement locally)
const suggestions = this.findLinkSuggestions(link.target);
unresolvedLinks.push({
text: link.raw,
line: link.line,
suggestions
});
}
}
const result: ValidateWikilinksResult = { const result: ValidateWikilinksResult = {
path: normalizedPath, path: normalizedPath,
@@ -880,6 +903,56 @@ export class VaultTools {
} }
} }
/**
* Find potential matches for an unresolved link
*/
private findLinkSuggestions(linkText: string, maxSuggestions: number = 5): string[] {
const allFiles = this.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);
}
/** /**
* Resolve a single wikilink from a source note * Resolve a single wikilink from a source note
* Returns the target path if resolvable, or suggestions if not * Returns the target path if resolvable, or suggestions if not
@@ -888,10 +961,10 @@ export class VaultTools {
try { try {
// Normalize and validate source path // Normalize and validate source path
const normalizedPath = PathUtils.normalizePath(sourcePath); const normalizedPath = PathUtils.normalizePath(sourcePath);
// Resolve source file // Get source file using adapter
const file = PathUtils.resolveFile(this.app, normalizedPath); const file = this.vault.getAbstractFileByPath(normalizedPath);
if (!file) { if (!file || !(file instanceof TFile)) {
return { return {
content: [{ content: [{
type: "text", type: "text",
@@ -901,8 +974,8 @@ export class VaultTools {
}; };
} }
// Try to resolve the link // Try to resolve the link using metadata cache adapter
const resolvedFile = LinkUtils.resolveLink(this.app, normalizedPath, linkText); const resolvedFile = this.metadata.getFirstLinkpathDest(linkText, normalizedPath);
const result: ResolveWikilinkResult = { const result: ResolveWikilinkResult = {
sourcePath: normalizedPath, sourcePath: normalizedPath,
@@ -913,7 +986,7 @@ export class VaultTools {
// If not resolved, provide suggestions // If not resolved, provide suggestions
if (!resolvedFile) { if (!resolvedFile) {
result.suggestions = LinkUtils.findSuggestions(this.app, linkText); result.suggestions = this.findLinkSuggestions(linkText);
} }
return { return {
@@ -945,10 +1018,10 @@ export class VaultTools {
try { try {
// Normalize and validate path // Normalize and validate path
const normalizedPath = PathUtils.normalizePath(path); const normalizedPath = PathUtils.normalizePath(path);
// Resolve file // Get target file using adapter
const file = PathUtils.resolveFile(this.app, normalizedPath); const targetFile = this.vault.getAbstractFileByPath(normalizedPath);
if (!file) { if (!targetFile || !(targetFile instanceof TFile)) {
return { return {
content: [{ content: [{
type: "text", type: "text",
@@ -958,18 +1031,99 @@ export class VaultTools {
}; };
} }
// Get backlinks // Get target file's basename for matching
const backlinks = await LinkUtils.getBacklinks( const targetBasename = targetFile.basename;
this.app,
normalizedPath,
includeUnlinked
);
// If snippets not requested, remove them // Get all backlinks from MetadataCache using resolvedLinks
if (!includeSnippets) { const resolvedLinks = this.metadata.resolvedLinks;
for (const backlink of backlinks) { const backlinks: any[] = [];
for (const occurrence of backlink.occurrences) {
occurrence.snippet = ''; // 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[normalizedPath]) {
continue;
}
const sourceFile = this.vault.getAbstractFileByPath(sourcePath);
if (!(sourceFile instanceof TFile)) {
continue;
}
// Read the source file to find link occurrences
const content = await this.vault.read(sourceFile);
const lines = content.split('\n');
const occurrences: any[] = [];
// Parse wikilinks in the source file to find references to target
const wikilinks = LinkUtils.parseWikilinks(content);
for (const link of wikilinks) {
// Resolve this link to see if it points to our target
const resolvedFile = this.metadata.getFirstLinkpathDest(link.target, sourcePath);
if (resolvedFile && resolvedFile.path === normalizedPath) {
const snippet = includeSnippets ? 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 = this.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 === normalizedPath) {
continue;
}
const content = await this.vault.read(file);
const lines = content.split('\n');
const occurrences: any[] = [];
// 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 = includeSnippets ? 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
});
} }
} }
} }
@@ -996,4 +1150,27 @@ export class VaultTools {
}; };
} }
} }
/**
* Extract a snippet of text around a specific line
*/
private 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
*/
private escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
} }