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:
@@ -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}` }],
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
391
src/utils/link-utils.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user