Skip to main content
Glama

auto_backlink_vault

Automatically create wikilinks by detecting note names in content to establish backlinks across your Obsidian vault.

Instructions

Automatically add backlinks throughout the entire vault by detecting note names in content and converting them to wikilinks

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
dryRunNoPreview changes without applying them (default: true)
excludePatternsNoArray of glob patterns to exclude from processing (e.g., ["template/*", "archive/*"])
minLengthNoMinimum note name length to consider for linking (default: 3)
caseSensitiveNoWhether matching should be case sensitive (default: false)
wholeWordsNoWhether to match only whole words (default: true)
batchSizeNoNumber of notes to process in each batch (default: 50)

Implementation Reference

  • src/index.ts:1309-1351 (registration)
    Registration of the 'auto_backlink_vault' tool in the listTools response, defining its name, description, and input schema.
    {
      name: 'auto_backlink_vault',
      description: 'Automatically add backlinks throughout the entire vault by detecting note names in content and converting them to wikilinks',
      inputSchema: {
        type: 'object',
        properties: {
          dryRun: {
            type: 'boolean',
            description: 'Preview changes without applying them (default: true)',
            default: true
          },
          excludePatterns: {
            type: 'array',
            description: 'Array of glob patterns to exclude from processing (e.g., ["template/*", "archive/*"])',
            items: {
              type: 'string'
            },
            default: []
          },
          minLength: {
            type: 'number',
            description: 'Minimum note name length to consider for linking (default: 3)',
            default: 3
          },
          caseSensitive: {
            type: 'boolean',
            description: 'Whether matching should be case sensitive (default: false)',
            default: false
          },
          wholeWords: {
            type: 'boolean',
            description: 'Whether to match only whole words (default: true)',
            default: true
          },
          batchSize: {
            type: 'number',
            description: 'Number of notes to process in each batch (default: 50)',
            default: 50
          }
        },
        required: [],
      },
    },
  • Main handler function for executing the auto_backlink_vault tool. Validates input parameters, calls processVaultBacklinks for core logic, and formats the results.
    private async handleAutoBacklinkVault(args: any) {
      // Auto backlink vault processing
      
      // Set default values
      const options = {
        dryRun: args?.dryRun !== undefined ? args.dryRun : true,
        excludePatterns: args?.excludePatterns || [],
        minLength: args?.minLength || 3,
        caseSensitive: args?.caseSensitive || false,
        wholeWords: args?.wholeWords !== undefined ? args.wholeWords : true,
        batchSize: args?.batchSize || 50,
      };
      
      // Process with validated options
      
      // Validate options
      if (!Array.isArray(options.excludePatterns)) {
        throw new Error('excludePatterns must be an array');
      }
      
      if (typeof options.minLength !== 'number' || options.minLength < 1 || options.minLength > 100) {
        throw new Error('minLength must be a positive number between 1 and 100');
      }
      
      if (typeof options.batchSize !== 'number' || options.batchSize < 1 || options.batchSize > 500) {
        throw new Error('batchSize must be a positive number between 1 and 500');
      }
      
      // Safety checks
      if (!options.dryRun) {
        console.warn('[WARNING] Auto backlink vault will modify files. Make sure you have backups!');
      }
      
      // Validate exclude patterns
      for (const pattern of options.excludePatterns) {
        if (typeof pattern !== 'string') {
          throw new Error('All exclude patterns must be strings');
        }
        try {
          new RegExp(pattern.replace(/\*/g, '.*'));
        } catch (error) {
          throw new Error(`Invalid exclude pattern "${pattern}": ${error}`);
        }
      }
      
      // Process the vault
      const results = await processVaultBacklinks(
        () => this.listVaultFiles(),
        (path: string) => this.readNote(path),
        options
      );
      
      // Format the results
      let output = `Auto Backlink Vault Results:\n`;
      output += `================================\n`;
      output += `Total notes: ${results.totalNotes}\n`;
      output += `Processed notes: ${results.processedNotes}\n`;
      output += `Modified notes: ${results.modifiedNotes}\n`;
      output += `Total links added: ${results.totalLinksAdded}\n`;
      
      if (results.errors.length > 0) {
        output += `\nErrors (${results.errors.length}):\n`;
        results.errors.forEach((error, index) => {
          output += `${index + 1}. ${error}\n`;
        });
      }
      
      if (options.dryRun && results.changes.length > 0) {
        output += `\nPreview of changes (first 10):\n`;
        const previewChanges = results.changes.slice(0, 10);
        previewChanges.forEach((change, index) => {
          output += `${index + 1}. ${change.path}: "${change.oldText}" → "${change.newText}"\n`;
        });
        
        if (results.changes.length > 10) {
          output += `... and ${results.changes.length - 10} more changes\n`;
        }
        
        output += `\nNote: This was a dry run. No changes were actually made.\n`;
        output += `To apply these changes, run the tool again with dryRun: false\n`;
      } else if (!options.dryRun && results.modifiedNotes > 0) {
        output += `\nChanges have been applied successfully!\n`;
      }
      
      return {
        content: [
          {
            type: 'text',
            text: output,
          },
        ],
      };
    }
  • Core helper function that orchestrates vault-wide backlink processing: builds note index, processes notes in batches, identifies link opportunities, and applies wikilink replacements.
    async function processVaultBacklinks(
      listVaultFiles: () => Promise<string[]>,
      readNote: (path: string) => Promise<string>,
      options: {
        dryRun: boolean;
        excludePatterns: string[];
        minLength: number;
        caseSensitive: boolean;
        wholeWords: boolean;
        batchSize: number;
      }
    ): Promise<{
      totalNotes: number;
      processedNotes: number;
      modifiedNotes: number;
      totalLinksAdded: number;
      errors: string[];
      changes: { path: string; oldText: string; newText: string }[];
    }> {
      const results = {
        totalNotes: 0,
        processedNotes: 0,
        modifiedNotes: 0,
        totalLinksAdded: 0,
        errors: [] as string[],
        changes: [] as { path: string; oldText: string; newText: string }[]
      };
      
      try {
        // Start vault processing
        
        // Get all notes in the vault
        const allFiles = await listVaultFiles();
        // Found files in vault
        
        const noteIndex = buildNoteIndex(allFiles);
        // Built note index
        
        // Filter out excluded patterns
        const filteredNotes = noteIndex.filter(note => {
          return !options.excludePatterns.some(pattern => {
            const regex = new RegExp(pattern.replace(/\*/g, '.*'));
            return regex.test(note.path);
          });
        });
        
        results.totalNotes = filteredNotes.length;
        // Filtered notes for processing
        
        // Process notes in batches
        for (let i = 0; i < filteredNotes.length; i += options.batchSize) {
          // Processing batch
          // Batch processing range
          
          const batch = filteredNotes.slice(i, i + options.batchSize);
          
          // Process each note in the batch
          for (const note of batch) {
            try {
              // Processing note
              const content = await readNote(note.path);
              // Read note content
              
              // Create backlink matches for this note
              const matches = createBacklinkMatches(content, noteIndex, {
                minLength: options.minLength,
                caseSensitive: options.caseSensitive,
                wholeWords: options.wholeWords
              });
              // Found potential matches
              
              // Filter out self-references
              const validMatches = matches.filter(match => match.notePath !== note.path);
              // Filtered self-references
              
              if (validMatches.length > 0) {
                // Valid matches found
                results.modifiedNotes++;
                results.totalLinksAdded += validMatches.length;
                
                // Convert matches to edit operations
                const edits = validMatches.map(match => ({
                  oldText: match.oldText,
                  newText: match.newText
                }));
                
                // Record changes for reporting
                validMatches.forEach(match => {
                  results.changes.push({
                    path: note.path,
                    oldText: match.oldText,
                    newText: match.newText
                  });
                });
                
                // Apply changes if not dry run
                if (!options.dryRun) {
                  await applyNoteEdits(note.path, edits);
                }
              }
              
              results.processedNotes++;
              
            } catch (error) {
              const errorMessage = `Error processing note ${note.path}: ${error instanceof Error ? error.message : String(error)}`;
              results.errors.push(errorMessage);
              console.error(errorMessage);
            }
          }
          
          // Small delay between batches to avoid overwhelming the system
          if (i + options.batchSize < filteredNotes.length) {
            await new Promise(resolve => setTimeout(resolve, 10));
          }
        }
        
      } catch (error) {
        const errorMessage = `Error in vault processing: ${error instanceof Error ? error.message : String(error)}`;
        results.errors.push(errorMessage);
        console.error(errorMessage);
      }
      
      return results;
    }
  • Key helper that scans note content for potential backlink opportunities, matching note names while skipping code, existing links, and common words.
    function createBacklinkMatches(content: string, noteIndex: NoteInfo[], options: {
      minLength: number;
      caseSensitive: boolean;
      wholeWords: boolean;
    }): { oldText: string; newText: string; notePath: string }[] {
      const matches: { oldText: string; newText: string; notePath: string }[] = [];
      
      if (!content || content.trim().length === 0) {
        return matches;
      }
      
      let processedContent = content;
      
      // Skip content that's already in wikilinks, markdown links, or code blocks
      const skipPatterns = [
        /```[\s\S]*?```/g,           // Code blocks
        /`[^`]*`/g,                  // Inline code
        /\[\[[^\]]*\]\]/g,           // Existing wikilinks
        /\[[^\]]*\]\([^)]*\)/g,      // Markdown links
        /https?:\/\/[^\s]*/g,        // URLs
        /!\[\[[^\]]*\]\]/g,          // Embedded wikilinks (images, etc)
      ];
      
      // Create a map of regions to skip
      const skipRegions: { start: number; end: number }[] = [];
      
      for (const pattern of skipPatterns) {
        let match;
        while ((match = pattern.exec(content)) !== null) {
          skipRegions.push({ start: match.index, end: match.index + match[0].length });
        }
      }
      
      // Sort skip regions by start position
      skipRegions.sort((a, b) => a.start - b.start);
      
      // Function to check if a position is in a skip region
      function isInSkipRegion(start: number, end: number): boolean {
        return skipRegions.some(region => 
          (start >= region.start && start < region.end) ||
          (end > region.start && end <= region.end) ||
          (start <= region.start && end >= region.end)
        );
      }
      
      // Process each note in the index
      for (const note of noteIndex) {
        if (note.nameWithoutExt.length < options.minLength) {
          continue;
        }
        
        // Skip very common words to avoid over-linking
        const commonWords = ['the', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'up', 'about', 'into', 'over', 'after'];
        if (commonWords.includes(note.nameWithoutExt.toLowerCase())) {
          continue;
        }
        
        const flags = options.caseSensitive ? 'g' : 'gi';
        const boundary = options.wholeWords ? '\\b' : '';
        const escapedName = escapeRegExp(note.nameWithoutExt);
        
        try {
          const regex = new RegExp(`${boundary}${escapedName}${boundary}`, flags);
          
          let match;
          while ((match = regex.exec(processedContent)) !== null) {
            const matchStart = match.index;
            const matchEnd = match.index + match[0].length;
            
            // Skip if this match is in a skip region
            if (isInSkipRegion(matchStart, matchEnd)) {
              continue;
            }
            
            // Check if this text is already a wikilink or part of one
            const beforeMatch = processedContent.substring(Math.max(0, matchStart - 2), matchStart);
            const afterMatch = processedContent.substring(matchEnd, Math.min(processedContent.length, matchEnd + 2));
            
            if (beforeMatch.includes('[[') || afterMatch.includes(']]')) {
              continue;
            }
            
            // Additional check for potential false positives
            // Skip if the match is within a word (for non-word-boundary matches)
            if (!options.wholeWords) {
              const charBefore = matchStart > 0 ? processedContent[matchStart - 1] : ' ';
              const charAfter = matchEnd < processedContent.length ? processedContent[matchEnd] : ' ';
              
              if (/\w/.test(charBefore) || /\w/.test(charAfter)) {
                continue;
              }
            }
            
            matches.push({
              oldText: match[0],
              newText: `[[${note.nameWithoutExt}]]`,
              notePath: note.path
            });
            
            // Replace the matched text in processedContent to avoid overlapping matches
            processedContent = processedContent.substring(0, matchStart) + 
                              `[[${note.nameWithoutExt}]]` + 
                              processedContent.substring(matchEnd);
            
            // Reset regex lastIndex due to content change
            regex.lastIndex = matchStart + `[[${note.nameWithoutExt}]]`.length;
          }
        } catch (error) {
          console.warn(`Error processing regex for note "${note.nameWithoutExt}": ${error}`);
          continue;
        }
      }
      
      return matches;
    }
  • Helper to create a sorted index of vault notes for efficient backlink matching (longer names first).
    function buildNoteIndex(filePaths: string[]): NoteInfo[] {
      const noteIndex: NoteInfo[] = [];
      
      for (const filePath of filePaths) {
        const name = path.basename(filePath);
        const nameWithoutExt = path.basename(filePath, path.extname(filePath));
        
        // Only include markdown files for now
        if (path.extname(filePath) === '.md') {
          noteIndex.push({
            path: filePath,
            name: name,
            nameWithoutExt: nameWithoutExt
          });
        }
      }
      
      // Sort by name length (descending) to match longer names first
      return noteIndex.sort((a, b) => b.nameWithoutExt.length - a.nameWithoutExt.length);
    }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/newtype-01/obsidian-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server