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",
|
"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",
|
||||||
|
|||||||
@@ -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, '\\$&');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user