Skip to main content
Glama
links.ts17.5 kB
/** * Link Manager * * Handles creation and management of links between notes, including * bidirectional linking, relationship types, link validation, and wikilink integration. */ import path from 'path'; import { Workspace } from './workspace.js'; import { NoteManager } from './notes.js'; import { WikilinkParser } from './wikilink-parser.js'; import { NoteLinkingUtils } from '../utils/note-linking.js'; import type { NoteLink, LinkRelationship, LinkResult, NoteLookupResult, LinkSuggestion } from '../types/index.js'; import type { NoteLinkingManager } from '../utils/note-linking.js'; interface LinkNotesArgs { source: string; target: string; relationship?: LinkRelationship; bidirectional?: boolean; context?: string; } export class LinkManager implements NoteLinkingManager { #_workspace: Workspace; #noteManager: NoteManager; #linkingUtils: NoteLinkingUtils; constructor(workspace: Workspace, noteManager: NoteManager) { this.#_workspace = workspace; this.#noteManager = noteManager; this.#linkingUtils = new NoteLinkingUtils(this); } /** * Get the workspace instance */ get workspace(): Workspace { return this.#_workspace; } /** * Create a link between two notes */ async linkNotes(args: LinkNotesArgs): Promise<LinkResult> { const { source, target, relationship = 'references', bidirectional = true, context } = args; // Validate relationship type if (!this.isValidRelationship(relationship)) { throw new Error(`Invalid relationship type: ${relationship}`); } // Normalize identifiers and verify both notes exist const _sourceNote = await this.#noteManager.getNote(source); const _targetNote = await this.#noteManager.getNote(target); if (!_sourceNote) { throw new Error(`Source note does not exist: ${source}`); } if (!_targetNote) { throw new Error(`Target note does not exist: ${target}`); } // Check for duplicate links if (await this.linkExists(source, target, relationship)) { throw new Error( `Link already exists from ${source} to ${target} with relationship ${relationship}` ); } const timestamp = new Date().toISOString(); // Create the primary link await this.addLinkToNote(source, { target: target, relationship, created: timestamp, context }); let reverseLinkCreated = false; // Create reverse link if bidirectional if (bidirectional) { const reverseRelationship = this.getReverseRelationship(relationship); // Only create reverse link if it doesn't already exist if (!(await this.linkExists(target, source, reverseRelationship))) { await this.addLinkToNote(target, { target: source, relationship: reverseRelationship, created: timestamp, context: context ? `Reverse of: ${context}` : undefined }); reverseLinkCreated = true; } } // Add inline links to content if appropriate await this.addInlineLinks(source, target, relationship); return { success: true, link_created: { source: source, target: target, relationship, bidirectional, timestamp }, reverse_link_created: reverseLinkCreated }; } /** * Add a link to a note's frontmatter */ private async addLinkToNote(identifier: string, link: NoteLink): Promise<void> { const note = await this.#noteManager.getNote(identifier); if (!note) { throw new Error(`Note not found: ${identifier}`); } // Initialize links structure if it doesn't exist const links = note.metadata.links || { outbound: [], inbound: [] }; if (!links.outbound) links.outbound = []; if (!links.inbound) links.inbound = []; // Add the new link to outbound links.outbound.push(link); // Update the note metadata and let updateNote handle the content formatting const updatedMetadata = { ...note.metadata, links }; // Pass the plain content - updateNote will handle frontmatter formatting // Bypass protection since this is an internal system operation await this.#noteManager.updateNoteWithMetadata( identifier, note.content, updatedMetadata, note.content_hash, true ); } /** * Format note content with updated frontmatter including links * @deprecated Use NoteManager.formatUpdatedNoteContent instead */ private formatNoteWithLinks( content: string, _metadata: Record<string, unknown> ): string { // This method is deprecated - NoteManager handles frontmatter formatting return content; } /** * Add inline wikilinks to note content where appropriate */ private async addInlineLinks( sourceIdentifier: string, targetIdentifier: string, relationship: LinkRelationship ): Promise<void> { const sourceNote = await this.#noteManager.getNote(sourceIdentifier); const targetNote = await this.#noteManager.getNote(targetIdentifier); if (!sourceNote) { throw new Error(`Source note not found: ${sourceIdentifier}`); } if (!targetNote) { throw new Error(`Target note not found: ${targetIdentifier}`); } const targetTitle = targetNote.title; const targetFilename = path.basename(targetNote.filename, '.md'); const targetType = targetNote.type; const targetWikilink = `${targetType}/${targetFilename}`; // Check if content already contains a wikilink to the target if (WikilinkParser.containsLinkToTarget(sourceNote.content, targetWikilink)) { // Link already exists in content, don't add another return; } // For certain relationships, add context-appropriate inline links if ( relationship === 'references' || relationship === 'mentions' || relationship === 'related-to' ) { const wikilink = WikilinkParser.createWikilink( targetType, targetFilename, targetTitle ); // Add a simple reference at the end of the content const updatedContent = sourceNote.content.trim() + `\n\nSee also: ${wikilink}`; // Get the current note again to ensure we have the latest metadata const currentNote = await this.#noteManager.getNote(sourceIdentifier); if (!currentNote) { throw new Error(`Source note not found: ${sourceIdentifier}`); } // Update just the content, preserving existing metadata // Bypass protection since this is an internal system operation await this.#noteManager.updateNoteWithMetadata( sourceIdentifier, updatedContent, currentNote.metadata, currentNote.content_hash, true ); } } /** * Check if a link already exists between two notes */ private async linkExists( sourceIdentifier: string, targetIdentifier: string, relationship: LinkRelationship ): Promise<boolean> { try { const currentNote = await this.#noteManager.getNote(sourceIdentifier); if (!currentNote) { return false; } const links = currentNote.metadata?.links?.outbound || []; return links.some( link => link.target === targetIdentifier && link.relationship === relationship ); } catch { return false; } } /** * Check if relationship type is valid */ private isValidRelationship(relationship: string): relationship is LinkRelationship { const validRelationships: LinkRelationship[] = [ 'references', 'follows-up', 'contradicts', 'supports', 'mentions', 'depends-on', 'blocks', 'related-to' ]; return validRelationships.includes(relationship as LinkRelationship); } /** * Get the reverse relationship for bidirectional linking */ private getReverseRelationship(relationship: LinkRelationship): LinkRelationship { const reverseMap: Record<LinkRelationship, LinkRelationship> = { references: 'mentions', 'follows-up': 'mentions', contradicts: 'contradicts', supports: 'supports', mentions: 'mentions', 'depends-on': 'blocks', blocks: 'depends-on', 'related-to': 'related-to' }; return reverseMap[relationship] || 'related-to'; } /** * Get all links for a specific note */ async getLinksForNote( identifier: string ): Promise<{ outbound: NoteLink[]; inbound: NoteLink[] }> { const note = await this.#noteManager.getNote(identifier); if (!note) { return { outbound: [], inbound: [] }; } const links = note.metadata.links || { outbound: [], inbound: [] }; return { outbound: links.outbound || [], inbound: links.inbound || [] }; } /** * Remove a specific link between two notes */ async removeLink( source: string, target: string, relationship: LinkRelationship ): Promise<boolean> { const note = await this.#noteManager.getNote(source); if (!note) { return false; } const links = note.metadata.links || { outbound: [], inbound: [] }; const outboundLinks = links.outbound || []; const linkIndex = outboundLinks.findIndex( link => link.target === target && link.relationship === relationship ); if (linkIndex === -1) { return false; } // Remove the link outboundLinks.splice(linkIndex, 1); // Update the note with new metadata const updatedMetadata = { ...note.metadata, links: { ...links, outbound: outboundLinks } }; await this.#noteManager.updateNoteWithMetadata( source, note.content, updatedMetadata, note.content_hash, true ); return true; } /** * Parse and update frontmatter links from wikilinks in content */ async updateLinksFromContent(identifier: string): Promise<void> { const note = await this.#noteManager.getNote(identifier); if (!note) { throw new Error(`Note not found: ${identifier}`); } // Parse wikilinks from content const contentLinks = WikilinkParser.extractLinksForFrontmatter(note.content); // Convert to NoteLink format const outboundLinks: NoteLink[] = []; const timestamp = new Date().toISOString(); for (const link of contentLinks) { // Validate that target note exists const targetNote = await this.#noteManager.getNote(link.target); if (targetNote) { outboundLinks.push({ target: link.target, relationship: 'references', created: timestamp, display: link.display, type: link.type }); } } // Update metadata const existingLinks = note.metadata.links || { outbound: [], inbound: [] }; const updatedMetadata = { ...note.metadata, links: { ...existingLinks, outbound: outboundLinks } }; await this.#noteManager.updateNoteWithMetadata( identifier, note.content, updatedMetadata, note.content_hash, true ); } /** * Search for notes that could be linked */ async searchLinkableNotes( query: string, excludeType?: string ): Promise<NoteLookupResult[]> { // Use the search manager to find notes const searchResults = await this.#noteManager.searchNotes({ query, limit: 20 }); return searchResults .map(result => ({ filename: path.basename(result.filename, '.md'), title: result.title, type: result.type, path: result.path, exists: true })) .filter(note => !excludeType || note.type !== excludeType); } /** * Get link suggestions for a partial query */ async getLinkSuggestions( query: string, contextType?: string ): Promise<LinkSuggestion[]> { const notes = await this.searchLinkableNotes(query, contextType); return notes .map(note => ({ target: `${note.type}/${note.filename}`, display: note.title, type: note.type, filename: note.filename, title: note.title, relevance: this.calculateRelevance(query, note.title) })) .sort((a, b) => (b.relevance || 0) - (a.relevance || 0)); } /** * Calculate relevance score for search suggestions */ private calculateRelevance(query: string, title: string): number { const queryLower = query.toLowerCase(); const titleLower = title.toLowerCase(); // Exact match gets highest score if (titleLower === queryLower) return 1.0; // Starts with query gets high score if (titleLower.startsWith(queryLower)) return 0.8; // Contains query gets medium score if (titleLower.includes(queryLower)) return 0.6; // Fuzzy match gets lower score const words = queryLower.split(/\s+/); const matchingWords = words.filter(word => titleLower.includes(word)); return (matchingWords.length / words.length) * 0.4; } /** * Find potential automatic link opportunities in content */ async findAutoLinkOpportunities(identifier: string): Promise< Array<{ text: string; position: { start: number; end: number }; suggestions: LinkSuggestion[]; }> > { const note = await this.#noteManager.getNote(identifier); if (!note) { return []; } // Get all available notes const allNotes = await this.searchLinkableNotes('', note.type); // Find linkable text in content return WikilinkParser.findLinkableText(note.content, allNotes); } /** * Update inbound links for a target note */ async updateInboundLinks(targetIdentifier: string): Promise<void> { const targetNote = await this.#noteManager.getNote(targetIdentifier); if (!targetNote) { throw new Error(`Target note not found: ${targetIdentifier}`); } // Search for all notes that might link to this target const allNotes = await this.#noteManager.searchNotes({ query: '', limit: 1000 }); const inboundLinks: NoteLink[] = []; for (const note of allNotes) { if (note.id === targetIdentifier) continue; // Skip self const noteContent = await this.#noteManager.getNote(note.id); if (!noteContent) continue; // Check if this note contains wikilinks to the target const parseResult = WikilinkParser.parseWikilinks(noteContent.content); const targetType = targetNote.type; const targetFilename = path.basename(targetNote.filename, '.md'); const targetReference = `${targetType}/${targetFilename}`; for (const wikilink of parseResult.wikilinks) { if (WikilinkParser.normalizeTarget(wikilink.target) === targetReference) { inboundLinks.push({ target: note.id, relationship: 'references', created: note.created, display: note.title, type: note.type }); break; // Only count each note once } } } // Update target note's inbound links const existingLinks = targetNote.metadata.links || { outbound: [], inbound: [] }; const updatedMetadata = { ...targetNote.metadata, links: { ...existingLinks, inbound: inboundLinks } }; await this.#noteManager.updateNoteWithMetadata( targetIdentifier, targetNote.content, updatedMetadata, targetNote.content_hash, true ); } /** * Implementation of NoteLinkingManager interface for NoteLinkingUtils */ async searchNotes( query: string, type?: string, limit?: number ): Promise<NoteLookupResult[]> { const searchResults = await this.searchLinkableNotes(query, type); return searchResults.slice(0, limit || 20); } /** * Implementation of NoteLinkingManager interface for NoteLinkingUtils */ async getNote(identifier: string): Promise<{ id: string; title: string; type: string; filename: string; content: string; exists: boolean; } | null> { const note = await this.#noteManager.getNote(identifier); if (!note) { return null; } return { id: note.id, title: note.title, type: note.type, filename: path.basename(note.filename, '.md'), content: note.content, exists: true }; } /** * Validate wikilinks in content using enhanced utilities */ async validateWikilinks(content: string, contextType?: string) { return await this.#linkingUtils.validateWikilinks(content, contextType); } /** * Auto-link content with intelligent suggestions */ async autoLinkContent( content: string, contextType?: string, aggressiveness: 'conservative' | 'moderate' | 'aggressive' = 'moderate' ) { return await this.#linkingUtils.autoLinkContent(content, contextType, aggressiveness); } /** * Get smart link suggestions with context awareness */ async getSmartLinkSuggestions( partialQuery: string, contextType?: string, contextContent?: string, limit: number = 10 ) { return await this.#linkingUtils.getSmartLinkSuggestions( partialQuery, contextType, contextContent, limit ); } /** * Generate comprehensive link report for content */ async generateLinkReport(content: string, contextType?: string) { return await this.#linkingUtils.generateLinkReport(content, contextType); } }

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/disnet/flint-note'

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