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:
2435
docs/plans/2025-10-19-100-percent-test-coverage-implementation.md
Normal file
2435
docs/plans/2025-10-19-100-percent-test-coverage-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "obsidian-mcp-server",
|
||||
"version": "1.0.0",
|
||||
"version": "3.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "obsidian-mcp-server",
|
||||
"version": "1.0.0",
|
||||
"version": "3.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
|
||||
@@ -12,7 +12,7 @@ export class VaultTools {
|
||||
constructor(
|
||||
private vault: IVaultAdapter,
|
||||
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> {
|
||||
@@ -837,10 +837,10 @@ export class VaultTools {
|
||||
try {
|
||||
// Normalize and validate path
|
||||
const normalizedPath = PathUtils.normalizePath(path);
|
||||
|
||||
// Resolve file
|
||||
const file = PathUtils.resolveFile(this.app, normalizedPath);
|
||||
if (!file) {
|
||||
|
||||
// Get file using adapter
|
||||
const file = this.vault.getAbstractFileByPath(normalizedPath);
|
||||
if (!file || !(file instanceof TFile)) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
@@ -850,11 +850,34 @@ export class VaultTools {
|
||||
};
|
||||
}
|
||||
|
||||
// Validate wikilinks
|
||||
const { resolvedLinks, unresolvedLinks } = await LinkUtils.validateWikilinks(
|
||||
this.app,
|
||||
normalizedPath
|
||||
);
|
||||
// Read file content
|
||||
const content = await this.vault.read(file);
|
||||
|
||||
// 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 = {
|
||||
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
|
||||
* Returns the target path if resolvable, or suggestions if not
|
||||
@@ -888,10 +961,10 @@ export class VaultTools {
|
||||
try {
|
||||
// Normalize and validate source path
|
||||
const normalizedPath = PathUtils.normalizePath(sourcePath);
|
||||
|
||||
// Resolve source file
|
||||
const file = PathUtils.resolveFile(this.app, normalizedPath);
|
||||
if (!file) {
|
||||
|
||||
// Get source file using adapter
|
||||
const file = this.vault.getAbstractFileByPath(normalizedPath);
|
||||
if (!file || !(file instanceof TFile)) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
@@ -901,8 +974,8 @@ export class VaultTools {
|
||||
};
|
||||
}
|
||||
|
||||
// Try to resolve the link
|
||||
const resolvedFile = LinkUtils.resolveLink(this.app, normalizedPath, linkText);
|
||||
// Try to resolve the link using metadata cache adapter
|
||||
const resolvedFile = this.metadata.getFirstLinkpathDest(linkText, normalizedPath);
|
||||
|
||||
const result: ResolveWikilinkResult = {
|
||||
sourcePath: normalizedPath,
|
||||
@@ -913,7 +986,7 @@ export class VaultTools {
|
||||
|
||||
// If not resolved, provide suggestions
|
||||
if (!resolvedFile) {
|
||||
result.suggestions = LinkUtils.findSuggestions(this.app, linkText);
|
||||
result.suggestions = this.findLinkSuggestions(linkText);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -945,10 +1018,10 @@ export class VaultTools {
|
||||
try {
|
||||
// Normalize and validate path
|
||||
const normalizedPath = PathUtils.normalizePath(path);
|
||||
|
||||
// Resolve file
|
||||
const file = PathUtils.resolveFile(this.app, normalizedPath);
|
||||
if (!file) {
|
||||
|
||||
// Get target file using adapter
|
||||
const targetFile = this.vault.getAbstractFileByPath(normalizedPath);
|
||||
if (!targetFile || !(targetFile instanceof TFile)) {
|
||||
return {
|
||||
content: [{
|
||||
type: "text",
|
||||
@@ -958,18 +1031,99 @@ export class VaultTools {
|
||||
};
|
||||
}
|
||||
|
||||
// Get backlinks
|
||||
const backlinks = await LinkUtils.getBacklinks(
|
||||
this.app,
|
||||
normalizedPath,
|
||||
includeUnlinked
|
||||
);
|
||||
// Get target file's basename for matching
|
||||
const targetBasename = targetFile.basename;
|
||||
|
||||
// If snippets not requested, remove them
|
||||
if (!includeSnippets) {
|
||||
for (const backlink of backlinks) {
|
||||
for (const occurrence of backlink.occurrences) {
|
||||
occurrence.snippet = '';
|
||||
// Get all backlinks from MetadataCache using resolvedLinks
|
||||
const resolvedLinks = this.metadata.resolvedLinks;
|
||||
const backlinks: any[] = [];
|
||||
|
||||
// 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, '\\$&');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user