Skip to main content
Glama
notes.ts48.4 kB
/** * Note Manager * * Handles CRUD operations for individual notes, including creation, * reading, updating, deletion, and metadata management. */ import path from 'path'; import fs from 'fs/promises'; import { Workspace } from './workspace.js'; import { NoteTypeManager } from './note-types.js'; import { HybridSearchManager } from '../database/search-manager.js'; import { MetadataValidator } from './metadata-schema.js'; import type { ValidationResult } from './metadata-schema.js'; import { parseFrontmatter, parseNoteContent } from '../utils/yaml-parser.js'; import { generateContentHash, validateContentHash, ContentHashMismatchError, MissingContentHashError } from '../utils/content-hash.js'; import type { NoteLink, NoteMetadata, FlintNoteError, DeletionValidation, BackupInfo, WikiLink, BatchCreateNoteInput, BatchUpdateNoteInput, BatchCreateResult, BatchUpdateResult } from '../types/index.js'; import { WikilinkParser } from './wikilink-parser.js'; import { LinkExtractor } from './link-extractor.js'; interface ParsedNote { metadata: NoteMetadata; content: string; } export interface NoteInfo { id: string; type: string; title: string; filename: string; path: string; created: string; } export interface Note { [key: string]: unknown; id: string; type: string; filename: string; path: string; title: string; content: string; content_hash: string; metadata: NoteMetadata; created: string; modified: string; updated: string; size: number; } export interface UpdateResult { id: string; updated: boolean; timestamp: string; } export interface DeleteNoteResult { id: string; deleted: boolean; timestamp: string; backup_path?: string; warnings?: string[]; } export interface MoveNoteResult { success: boolean; old_id: string; new_id: string; old_type: string; new_type: string; filename: string; title: string; timestamp: string; links_updated?: number; notes_with_updated_links?: number; } export interface NoteListItem { id: string; type: string; filename: string; title: string; created: string; modified: string; size: number; tags: string[]; path: string; } interface ParsedIdentifier { typeName: string; filename: string; notePath: string; } export class NoteManager { #workspace: Workspace; #noteTypeManager: NoteTypeManager; #hybridSearchManager?: HybridSearchManager; constructor(workspace: Workspace, hybridSearchManager?: HybridSearchManager) { this.#workspace = workspace; this.#noteTypeManager = new NoteTypeManager(workspace); this.#hybridSearchManager = hybridSearchManager; } /** * Create a new note of the specified type */ async createNote( typeName: string, title: string, content: string, metadata: Record<string, unknown> = {} ): Promise<NoteInfo> { try { // Validate inputs if (!title || title.trim().length === 0) { throw new Error('Note title is required and cannot be empty'); } // Trim the title for consistent handling const trimmedTitle = title.trim(); // Validate and ensure note type exists if (!this.#workspace.isValidNoteTypeName(typeName)) { throw new Error(`Invalid note type name: ${typeName}`); } const typePath = await this.#workspace.ensureNoteType(typeName); // Generate filename from title and check availability const baseFilename = this.generateFilename(trimmedTitle); await this.checkFilenameAvailability(typePath, baseFilename); const filename = baseFilename; const notePath = path.join(typePath, filename); // Prepare metadata with title for validation const metadataWithTitle = { title: trimmedTitle, ...metadata }; // Validate metadata against schema const validationResult = await this.validateMetadata(typeName, metadataWithTitle); if (!validationResult.valid) { throw new Error( `Metadata validation failed: ${validationResult.errors.map(e => e.message).join(', ')}` ); } // Prepare note content with metadata const noteContent = await this.formatNoteContent( trimmedTitle, content, typeName, metadata ); // Write the note file await fs.writeFile(notePath, noteContent, 'utf-8'); // Update search index await this.updateSearchIndex(notePath, noteContent); return { id: this.generateNoteId(typeName, filename), type: typeName, title: trimmedTitle, filename, path: notePath, created: new Date().toISOString() }; } catch (error) { if (error instanceof Error) { // Preserve custom error properties if they exist const flintError = error as FlintNoteError; if (flintError.code === 'NOTE_ALREADY_EXISTS') { // Re-throw the original error for duplicate notes to preserve error details throw error; } // For other errors, wrap with context throw new Error(`Failed to create note '${title}': ${error.message}`); } throw new Error(`Failed to create note '${title}': Unknown error`); } } /** * Generate a filesystem-safe filename from a title */ generateFilename(title: string): string { // Remove or replace problematic characters let filename = title .toLowerCase() .replace(/[^\w\s-]/g, '') // Remove special characters except word chars, spaces, hyphens .replace(/\s+/g, '-') // Replace spaces with hyphens .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens // Ensure filename isn't empty if (!filename) { filename = 'untitled'; } // Ensure it doesn't exceed filesystem limits (considering full path length) if (filename.length > 100) { filename = filename.substring(0, 100); } return `${filename}.md`; } /** * Check if a filename is available, throwing an error if it already exists */ async checkFilenameAvailability(typePath: string, filename: string): Promise<void> { const filePath = path.join(typePath, filename); try { await fs.access(filePath); // File exists, throw an error const error = new Error( `A note with the filename '${filename}' already exists in the '${path.basename(typePath)}' note type` ) as FlintNoteError; error.code = 'NOTE_ALREADY_EXISTS'; error.details = { filename, typePath, filePath }; throw error; } catch (error) { // File doesn't exist, filename is available if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { return; // Filename is available } else { throw error; // Re-throw if it's not a "file not found" error } } } /** * Generate a unique note ID */ generateNoteId(typeName: string, filename: string): string { // Remove .md extension from filename for the ID const baseFilename = filename.replace(/\.md$/, ''); return `${typeName}/${baseFilename}`; } /** * Format note content with metadata frontmatter */ async formatNoteContent( title: string, content: string, typeName: string, metadata: Record<string, unknown> = {} ): Promise<string> { const timestamp = new Date().toISOString(); const filename = this.generateFilename(title); const baseFilename = path.basename(filename, '.md'); let formattedContent = '---\n'; formattedContent += `title: "${title}"\n`; formattedContent += `filename: "${baseFilename}"\n`; formattedContent += `type: ${typeName}\n`; formattedContent += `created: ${timestamp}\n`; formattedContent += `updated: ${timestamp}\n`; // Add custom metadata fields for (const [key, value] of Object.entries(metadata)) { if ( key !== 'title' && key !== 'filename' && key !== 'type' && key !== 'created' && key !== 'updated' ) { if (Array.isArray(value)) { const escapedArray = value.map(v => { if (typeof v === 'string') { const escapedValue = v.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); return `"${escapedValue}"`; } return v; }); formattedContent += `${key}: [${escapedArray.join(', ')}]\n`; } else if (typeof value === 'string') { // Escape quotes and backslashes in string values const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); formattedContent += `${key}: "${escapedValue}"\n`; } else { formattedContent += `${key}: ${value}\n`; } } } // Add default tags if not specified if (!metadata.tags) { formattedContent += 'tags: []\n'; } formattedContent += '---\n\n'; formattedContent += content; return formattedContent; } /** * Get a specific note by identifier */ async getNote(identifier: string): Promise<Note | null> { try { const { typeName, filename, notePath } = this.parseNoteIdentifier(identifier); // Check if note exists try { await fs.access(notePath); } catch { return null; } // Read note content const content = await fs.readFile(notePath, 'utf-8'); const stats = await fs.stat(notePath); // Parse frontmatter and content const parsed = this.parseNoteContent(content); // Generate content hash for optimistic locking const contentHash = generateContentHash(parsed.content); return { id: identifier, type: typeName, filename, path: notePath, title: parsed.metadata.title || this.extractTitleFromFilename(filename), content: parsed.content, content_hash: contentHash, metadata: parsed.metadata, created: parsed.metadata.created || stats.birthtime.toISOString(), modified: parsed.metadata.updated || stats.mtime.toISOString(), updated: parsed.metadata.updated || stats.mtime.toISOString(), size: stats.size }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to get note '${identifier}': ${errorMessage}`); } } /** * Get multiple notes by their identifiers */ async getNotes( identifiers: string[] ): Promise<Array<{ success: boolean; note?: Note; error?: string }>> { const results = await Promise.allSettled( identifiers.map(async identifier => { try { const note = await this.getNote(identifier); if (!note) { throw new Error(`Note not found: ${identifier}`); } return { success: true, note }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; return { success: false, error: errorMessage }; } }) ); return results.map((result, index) => { if (result.status === 'fulfilled') { return result.value; } else { return { success: false, error: `Failed to retrieve note ${identifiers[index]}: ${result.reason}` }; } }); } /** * Get a note by file path */ async getNoteByPath(filePath: string): Promise<Note | null> { try { // Validate path is in workspace if (!this.#workspace.isPathInWorkspace(filePath)) { throw new Error('Path is outside workspace'); } // Check if note exists try { await fs.access(filePath); } catch { return null; } // Extract type and filename from path const relativePath = path.relative(this.#workspace.rootPath, filePath); const pathParts = relativePath.split(path.sep); const typeName = pathParts[0]; const filename = pathParts.slice(1).join(path.sep); const identifier = `${typeName}/${filename}`; // Read note content const content = await fs.readFile(filePath, 'utf-8'); const stats = await fs.stat(filePath); // Parse frontmatter and content const parsed = this.parseNoteContent(content); // Generate content hash for optimistic locking const contentHash = generateContentHash(parsed.content); return { id: identifier, type: typeName, filename, path: filePath, title: parsed.metadata.title || this.extractTitleFromFilename(filename), content: parsed.content, content_hash: contentHash, metadata: parsed.metadata, created: parsed.metadata.created || stats.birthtime.toISOString(), modified: parsed.metadata.updated || stats.mtime.toISOString(), updated: parsed.metadata.updated || stats.mtime.toISOString(), size: stats.size }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to get note at path '${filePath}': ${errorMessage}`); } } /** * Parse note identifier to extract type, filename, and path */ parseNoteIdentifier(identifier: string): ParsedIdentifier { let typeName: string; let filename: string; if (identifier.includes('/')) { // Format: "type/filename" const parts = identifier.split('/'); typeName = parts[0]; filename = parts.slice(1).join('/'); } else { // Just filename, assume default type const config = this.#workspace.getConfig(); typeName = config?.default_note_type || 'general'; filename = identifier; } // Ensure filename has .md extension (backward compatibility: accept IDs with or without .md) if (!filename.endsWith('.md')) { filename += '.md'; } const notePath = this.#workspace.getNotePath(typeName, filename); return { typeName, filename, notePath }; } /** * Parse note content to separate frontmatter and body */ parseNoteContent(content: string): ParsedNote { return parseNoteContent(content, true); } /** * Parse YAML frontmatter */ parseFrontmatter(frontmatter: string): NoteMetadata { return parseFrontmatter(frontmatter, true); } /** * Extract title from filename */ extractTitleFromFilename(filename: string): string { return filename .replace(/\.md$/, '') .replace(/-/g, ' ') .replace(/\b\w/g, letter => letter.toUpperCase()); } /** * Update an existing note */ async updateNote( identifier: string, newContent: string, contentHash: string ): Promise<UpdateResult> { try { if (!contentHash) { throw new MissingContentHashError('note update'); } const { typeName: _typeName, filename: _filename, notePath } = this.parseNoteIdentifier(identifier); // Check if note exists try { await fs.access(notePath); } catch { throw new Error(`Note '${identifier}' does not exist`); } // Read current content to preserve metadata const currentContent = await fs.readFile(notePath, 'utf-8'); const parsed = this.parseNoteContent(currentContent); // Validate content hash to prevent conflicts validateContentHash(parsed.content, contentHash); // Update the content while preserving metadata const updatedMetadata = { ...parsed.metadata, updated: new Date().toISOString() }; const updatedContent = this.formatUpdatedNoteContent(updatedMetadata, newContent); // Write updated content await fs.writeFile(notePath, updatedContent, 'utf-8'); // Update search index await this.updateSearchIndex(notePath, updatedContent); return { id: identifier, updated: true, timestamp: updatedMetadata.updated }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to update note '${identifier}': ${errorMessage}`); } } /** * Format updated note content with preserved metadata */ formatUpdatedNoteContent(metadata: NoteMetadata, newContent: string): string { let formattedContent = '---\n'; for (const [key, value] of Object.entries(metadata)) { if (key === 'links' && value && typeof value === 'object') { // Special handling for new bidirectional links structure const links = value as { outbound?: NoteLink[]; inbound?: NoteLink[] }; if ( (links.outbound && links.outbound.length > 0) || (links.inbound && links.inbound.length > 0) ) { formattedContent += 'links:\n'; if (links.outbound && links.outbound.length > 0) { formattedContent += ' outbound:\n'; links.outbound.forEach((link: NoteLink) => { formattedContent += ` - target: "${link.target}"\n`; formattedContent += ` relationship: "${link.relationship}"\n`; formattedContent += ` created: "${link.created}"\n`; if (link.context) { formattedContent += ` context: "${link.context}"\n`; } if (link.display) { formattedContent += ` display: "${link.display}"\n`; } if (link.type) { formattedContent += ` type: "${link.type}"\n`; } }); } if (links.inbound && links.inbound.length > 0) { formattedContent += ' inbound:\n'; links.inbound.forEach((link: NoteLink) => { formattedContent += ` - target: "${link.target}"\n`; formattedContent += ` relationship: "${link.relationship}"\n`; formattedContent += ` created: "${link.created}"\n`; if (link.context) { formattedContent += ` context: "${link.context}"\n`; } if (link.display) { formattedContent += ` display: "${link.display}"\n`; } if (link.type) { formattedContent += ` type: "${link.type}"\n`; } }); } } } else if (Array.isArray(value)) { // Handle other arrays (like tags) if (value.length > 0) { const escapedArray = value.map(v => { if (typeof v === 'string') { const escapedValue = v.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); return `"${escapedValue}"`; } return `"${v}"`; }); formattedContent += `${key}: [${escapedArray.join(', ')}]\n`; } else { formattedContent += `${key}: []\n`; } } else if (typeof value === 'string') { // Always quote strings to handle special characters properly // Escape quotes and backslashes in string values const escapedValue = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); formattedContent += `${key}: "${escapedValue}"\n`; } else { formattedContent += `${key}: ${value}\n`; } } formattedContent += '---\n\n'; formattedContent += newContent; return formattedContent; } /** * Update a note with custom metadata, avoiding duplicate frontmatter */ async updateNoteWithMetadata( identifier: string, content: string, metadata: NoteMetadata, contentHash: string, bypassProtection: boolean = false ): Promise<UpdateResult> { try { if (!contentHash) { throw new MissingContentHashError('note metadata update'); } const { typeName: _typeName, filename: _filename, notePath } = this.parseNoteIdentifier(identifier); // Check if note exists try { await fs.access(notePath); } catch { throw new Error(`Note '${identifier}' does not exist`); } // Read current content to preserve existing metadata const currentContent = await fs.readFile(notePath, 'utf-8'); const parsed = this.parseNoteContent(currentContent); // Validate content hash to prevent conflicts validateContentHash(parsed.content, contentHash); // Check for protected fields unless bypassing protection if (!bypassProtection) { this.#validateNoProtectedFields(metadata); } // Merge metadata with existing metadata const updatedMetadata = { ...parsed.metadata, ...metadata, updated: new Date().toISOString() }; // Format content with metadata const formattedContent = this.formatUpdatedNoteContent(updatedMetadata, content); // Write updated content await fs.writeFile(notePath, formattedContent, 'utf-8'); // Update search index await this.updateSearchIndex(notePath, formattedContent); return { id: identifier, updated: true, timestamp: updatedMetadata.updated }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to update note '${identifier}': ${errorMessage}`); } } /** * Delete a note */ async deleteNote( identifier: string, confirm: boolean = false ): Promise<DeleteNoteResult> { try { const config = this.#workspace.getConfig(); // Validate deletion const validation = await this.validateNoteDeletion(identifier); if (!validation.can_delete) { throw new Error(`Cannot delete note: ${validation.errors.join(', ')}`); } // Check confirmation requirement if (config?.deletion?.require_confirmation && !confirm) { throw new Error(`Deletion requires confirmation. Set confirm=true to proceed.`); } const { typeName: _typeName, filename: _filename, notePath } = this.parseNoteIdentifier(identifier); let backupPath: string | undefined; // Create backup if enabled if (config?.deletion?.create_backups) { const backup = await this.createNoteBackup(notePath); backupPath = backup.path; } // Remove from search index first await this.removeFromSearchIndex(notePath); // Delete the file await fs.unlink(notePath); return { id: identifier, deleted: true, timestamp: new Date().toISOString(), backup_path: backupPath, warnings: validation.warnings }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to delete note '${identifier}': ${errorMessage}`); } } /** * Validate if a note can be deleted */ async validateNoteDeletion(identifier: string): Promise<DeletionValidation> { const validation: DeletionValidation = { can_delete: true, warnings: [], errors: [] }; try { const { typeName: _typeName, filename: _filename, notePath } = this.parseNoteIdentifier(identifier); // Check if note exists try { await fs.access(notePath); } catch { validation.can_delete = false; validation.errors.push(`Note '${identifier}' does not exist`); return validation; } // Check for incoming links from other notes const incomingLinks = await this.findIncomingLinks(identifier); if (incomingLinks.length > 0) { validation.warnings.push( `Note has ${incomingLinks.length} incoming links that will become orphaned` ); validation.incoming_links = incomingLinks; } return validation; } catch (error) { validation.can_delete = false; validation.errors.push( `Validation failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); return validation; } } /** * Find incoming links to a note */ async findIncomingLinks(identifier: string): Promise<string[]> { try { const incomingLinks: string[] = []; const notes = await this.listNotes(); for (const note of notes) { try { const noteContent = await fs.readFile(note.path, 'utf-8'); const { wikilinks } = WikilinkParser.parseWikilinks(noteContent); const hasLinkToTarget = wikilinks.some((link: WikiLink) => { const linkIdentifier = `${link.type || note.type}/${link.filename || link.target}`; return linkIdentifier === identifier || link.target === identifier; }); if (hasLinkToTarget) { incomingLinks.push(`${note.type}/${note.filename}`); } } catch { // Skip notes that can't be read continue; } } return incomingLinks; } catch { return []; } } /** * Create a backup of a note before deletion */ async createNoteBackup(notePath: string): Promise<BackupInfo> { try { const config = this.#workspace.getConfig(); const backupDir = path.resolve( this.#workspace.rootPath, config?.deletion?.backup_path || '.flint-note/backups' ); // Ensure backup directory exists await fs.mkdir(backupDir, { recursive: true }); const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); const filename = path.basename(notePath); const backupFilename = `${timestamp}_${filename}`; const backupPath = path.join(backupDir, backupFilename); // Copy the note file await fs.copyFile(notePath, backupPath); // Get file stats for size const stats = await fs.stat(backupPath); return { path: backupPath, timestamp: new Date().toISOString(), notes: [notePath], size: stats.size }; } catch (error) { throw new Error( `Failed to create backup: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Bulk delete notes matching criteria */ async bulkDeleteNotes( criteria: { type?: string; tags?: string[]; pattern?: string }, confirm: boolean = false ): Promise<DeleteNoteResult[]> { try { const config = this.#workspace.getConfig(); // Find notes matching criteria const matchingNotes = await this.findNotesMatchingCriteria(criteria); if (matchingNotes.length === 0) { return []; } // Check bulk delete limit if (matchingNotes.length > (config?.deletion?.max_bulk_delete || 10)) { throw new Error( `Bulk delete limit exceeded: attempting to delete ${matchingNotes.length} notes, ` + `maximum allowed is ${config?.deletion?.max_bulk_delete || 10}` ); } // Check confirmation requirement if (config?.deletion?.require_confirmation && !confirm) { throw new Error( `Bulk deletion of ${matchingNotes.length} notes requires confirmation. Set confirm=true to proceed.` ); } // Delete each note const results: DeleteNoteResult[] = []; for (const noteIdentifier of matchingNotes) { try { const result = await this.deleteNote(noteIdentifier, true); // Already confirmed at bulk level results.push(result); } catch (error) { // Continue with other deletions, but record the error results.push({ id: noteIdentifier, deleted: false, timestamp: new Date().toISOString(), warnings: [ `Failed to delete: ${error instanceof Error ? error.message : 'Unknown error'}` ] }); } } return results; } catch (error) { throw new Error( `Bulk delete failed: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * Find notes matching deletion criteria */ async findNotesMatchingCriteria(criteria: { type?: string; tags?: string[]; pattern?: string; }): Promise<string[]> { try { const notes = await this.listNotes(); const matching: string[] = []; for (const note of notes) { let matches = true; // Check type filter if (criteria.type && note.type !== criteria.type) { matches = false; } // Check tags filter if (criteria.tags && criteria.tags.length > 0) { const noteTags = note.tags || []; const hasAllTags = criteria.tags.every(tag => noteTags.includes(tag)); if (!hasAllTags) { matches = false; } } // Check pattern filter if (criteria.pattern && matches) { try { const noteContent = await fs.readFile(note.path, 'utf-8'); const regex = new RegExp(criteria.pattern, 'i'); if (!regex.test(noteContent) && !regex.test(note.title)) { matches = false; } } catch { matches = false; } } if (matches) { matching.push(`${note.type}/${note.filename}`); } } return matching; } catch (error) { throw new Error( `Failed to find matching notes: ${error instanceof Error ? error.message : 'Unknown error'}` ); } } /** * List notes in a specific type */ async listNotes(typeName?: string, limit?: number): Promise<NoteListItem[]> { try { const notes: NoteListItem[] = []; let noteTypes: Array<{ name: string; path: string }> = []; if (typeName) { // List notes from specific type const typePath = this.#workspace.getNoteTypePath(typeName); try { await fs.access(typePath); noteTypes = [{ name: typeName, path: typePath }]; } catch { throw new Error(`Note type '${typeName}' does not exist`); } } else { // List notes from all types const workspaceRoot = this.#workspace.rootPath; const entries = await fs.readdir(workspaceRoot, { withFileTypes: true }); for (const entry of entries) { if ( entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'node_modules' ) { noteTypes.push({ name: entry.name, path: path.join(workspaceRoot, entry.name) }); } } } // Collect notes from each type for (const noteType of noteTypes) { try { const typeEntries = await fs.readdir(noteType.path); const noteFiles = typeEntries.filter( file => file.endsWith('.md') && !file.startsWith('.') && file !== '_description.md' ); for (const filename of noteFiles) { const notePath = path.join(noteType.path, filename); const stats = await fs.stat(notePath); // Read just the frontmatter for efficiency const content = await fs.readFile(notePath, 'utf-8'); const parsed = this.parseNoteContent(content); notes.push({ id: this.generateNoteId(noteType.name, filename), type: noteType.name, filename, title: parsed.metadata.title || this.extractTitleFromFilename(filename), created: stats.birthtime.toISOString(), modified: stats.mtime.toISOString(), size: stats.size, tags: parsed.metadata.tags || [], path: notePath }); } } catch { // Continue with other types if one fails continue; } } // Sort by modification date (newest first) notes.sort( (a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime() ); // Apply limit if specified if (limit && limit > 0) { return notes.slice(0, limit); } return notes; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error(`Failed to list notes: ${errorMessage}`); } } /** * Update search index for a note */ async updateSearchIndex(notePath: string, content: string): Promise<void> { try { // Update hybrid search index if available if (this.#hybridSearchManager) { const parsed = parseNoteContent(content); const filename = path.basename(notePath); const noteId = this.generateNoteId(parsed.metadata.type || 'default', filename); await this.#hybridSearchManager.upsertNote( noteId, parsed.metadata.title || filename.replace('.md', ''), parsed.content, parsed.metadata.type || 'default', filename, notePath, parsed.metadata ); // Extract and store links in the database await this.extractAndStoreLinks(noteId, parsed.content); } } catch (error) { // Don't fail note operations if search index update fails console.error( 'Failed to update search index:', error instanceof Error ? error.message : 'Unknown error' ); } } /** * Extract and store links for a note */ private async extractAndStoreLinks(noteId: string, content: string): Promise<void> { try { if (this.#hybridSearchManager) { const db = await this.#hybridSearchManager.getDatabaseConnection(); const extractionResult = LinkExtractor.extractLinks(content); await LinkExtractor.storeLinks(noteId, extractionResult, db); } } catch (error) { // Don't fail note operations if link extraction fails console.error( 'Failed to extract and store links:', error instanceof Error ? error.message : 'Unknown error' ); } } /** * Remove note from search index */ async removeFromSearchIndex(notePath: string): Promise<void> { try { // Remove from hybrid search index if available if (this.#hybridSearchManager) { const filename = path.basename(notePath); const content = await fs.readFile(notePath, 'utf-8'); const parsed = parseNoteContent(content); const noteId = this.generateNoteId(parsed.metadata.type || 'default', filename); // Clear links for this note const db = await this.#hybridSearchManager.getDatabaseConnection(); await LinkExtractor.clearLinksForNote(noteId, db); await this.#hybridSearchManager.removeNote(noteId); } } catch (error) { // Don't fail note operations if search index update fails console.error( 'Failed to remove from search index:', error instanceof Error ? error.message : 'Unknown error' ); } } /** * Search notes using the hybrid search manager */ async searchNotes(options: { query?: string; type_filter?: string; limit?: number; }): Promise<import('../database/search-manager.js').SearchResult[]> { if (!this.#hybridSearchManager) { throw new Error( 'Search functionality not available - hybrid search manager not initialized' ); } return await this.#hybridSearchManager.searchNotes( options.query, options.type_filter || null, options.limit || 50 ); } /** * Validate metadata against note type schema */ async validateMetadata( typeName: string, metadata: Record<string, unknown> ): Promise<ValidationResult> { try { const schema = await this.#noteTypeManager.getMetadataSchema(typeName); return MetadataValidator.validate(metadata, schema); } catch (error) { // If schema retrieval fails, allow the operation but log warning console.warn( `Failed to get metadata schema for type '${typeName}':`, error instanceof Error ? error.message : 'Unknown error' ); return { valid: true, errors: [], warnings: [] }; } } /** * Create multiple notes in a batch operation */ async batchCreateNotes(notes: BatchCreateNoteInput[]): Promise<BatchCreateResult> { const results: BatchCreateResult['results'] = []; let successful = 0; let failed = 0; for (const noteInput of notes) { try { const result = await this.createNote( noteInput.type, noteInput.title, noteInput.content, noteInput.metadata || {} ); results.push({ input: noteInput, success: true, result }); successful++; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; results.push({ input: noteInput, success: false, error: errorMessage }); failed++; } } return { total: notes.length, successful, failed, results }; } /** * Update multiple notes in a batch operation */ async batchUpdateNotes(updates: BatchUpdateNoteInput[]): Promise<BatchUpdateResult> { const results: BatchUpdateResult['results'] = []; let successful = 0; let failed = 0; // First validate that all updates include content hashes for (const updateInput of updates) { if (!updateInput.content_hash) { throw new MissingContentHashError('batch note update'); } } for (const updateInput of updates) { try { let result: UpdateResult; if (updateInput.content !== undefined && updateInput.metadata !== undefined) { // Both content and metadata update result = await this.updateNoteWithMetadata( updateInput.identifier, updateInput.content, updateInput.metadata as NoteMetadata, updateInput.content_hash, false // Don't bypass protection for batch operations ); } else if (updateInput.content !== undefined) { // Content-only update result = await this.updateNote( updateInput.identifier, updateInput.content, updateInput.content_hash ); } else if (updateInput.metadata !== undefined) { // Metadata-only update - read current content and update with new metadata const currentNote = await this.getNote(updateInput.identifier); if (!currentNote) { throw new Error(`Note '${updateInput.identifier}' not found`); } result = await this.updateNoteWithMetadata( updateInput.identifier, currentNote.content, updateInput.metadata as NoteMetadata, updateInput.content_hash, false // Don't bypass protection for batch operations ); } else { throw new Error('Either content or metadata must be provided for update'); } results.push({ input: updateInput, success: true, result }); successful++; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; // Include hash mismatch details in error response if (error instanceof ContentHashMismatchError) { results.push({ input: updateInput, success: false, error: errorMessage, hash_mismatch: { current_hash: error.current_hash, provided_hash: error.provided_hash } }); } else { results.push({ input: updateInput, success: false, error: errorMessage }); } failed++; } } return { total: updates.length, successful, failed, results }; } /** * Validate that metadata does not contain protected fields * * This method prevents modification of critical note identification fields * through regular metadata updates. Protected fields include: * - 'title': The display name of the note (use rename_note tool instead) * - 'filename': The file system name (use rename_note tool instead) * * This protection ensures that: * 1. Note renaming goes through the proper rename_note tool which handles wikilink updates * 2. File system consistency is maintained * 3. Note IDs and references remain stable * 4. Users receive clear guidance on the correct tool to use * * @param metadata - The metadata object to validate * @throws Error with descriptive message directing users to rename_note tool if protected fields are present */ #validateNoProtectedFields(metadata: NoteMetadata): void { const protectedFields = new Set(['type', 'title', 'filename', 'created', 'updated']); const foundProtectedFields: string[] = []; for (const [key, value] of Object.entries(metadata)) { if (protectedFields.has(key) && value !== undefined && value !== null) { foundProtectedFields.push(key); } } if (foundProtectedFields.length > 0) { const fieldList = foundProtectedFields.join(', '); const typeMessage = foundProtectedFields.includes('type') ? `Use the 'move_note' tool to safely move notes between note types with proper file relocation and link updates. ` : ''; const titleMessage = foundProtectedFields.some(field => ['title', 'filename'].includes(field) ) ? `Use the 'rename_note' tool to safely update note titles and preserve links. ` : ''; const timestampMessage = foundProtectedFields.some(field => ['created', 'updated'].includes(field) ) ? `The 'created' and 'updated' fields are handled automatically and cannot be modified manually.` : ''; throw new Error( `Cannot modify protected field(s): ${fieldList}. ` + typeMessage + titleMessage + timestampMessage ); } } /** * Rename a note by updating its title field */ async renameNote( identifier: string, newTitle: string, contentHash: string ): Promise<{ success: boolean; notesUpdated?: number; linksUpdated?: number }> { // Get the current note const currentNote = await this.getNote(identifier); if (!currentNote) { throw new Error(`Note '${identifier}' not found`); } // Update the title in metadata while preserving all other metadata const updatedMetadata = { ...currentNote.metadata, title: newTitle }; // Use the existing updateNoteWithMetadata method with protection bypass for rename await this.updateNoteWithMetadata( identifier, currentNote.content, // Keep content unchanged updatedMetadata, contentHash, true // Bypass protection for legitimate rename operations ); let brokenLinksUpdated = 0; let wikilinksResult = { notesUpdated: 0, linksUpdated: 0 }; // Only proceed with link updates if search manager is available if (this.#hybridSearchManager) { const db = await this.#hybridSearchManager.getDatabaseConnection(); const noteId = this.generateNoteId(currentNote.type, currentNote.filename); // Update broken links that might now be resolved due to the new title brokenLinksUpdated = await LinkExtractor.updateBrokenLinks(noteId, newTitle, db); // Always update wikilinks in other notes wikilinksResult = await LinkExtractor.updateWikilinksForRenamedNote( noteId, currentNote.title, newTitle, db ); } return { success: true, notesUpdated: wikilinksResult.notesUpdated, linksUpdated: wikilinksResult.linksUpdated + brokenLinksUpdated }; } /** * Move a note from one note type to another */ async moveNote( identifier: string, newType: string, contentHash: string ): Promise<MoveNoteResult> { try { if (!contentHash) { throw new MissingContentHashError('note move operation'); } // Validate the new note type name format if (!this.#workspace.isValidNoteTypeName(newType)) { throw new Error(`Invalid note type name: ${newType}`); } // Get the current note const currentNote = await this.getNote(identifier); if (!currentNote) { throw new Error(`Note '${identifier}' not found`); } // Validate content hash to prevent conflicts validateContentHash(currentNote.content, contentHash); const { typeName: oldType, filename, notePath: currentPath } = this.parseNoteIdentifier(identifier); // Don't move if already in target type if (oldType === newType) { throw new Error(`Note is already in note type '${newType}'`); } // Check if target note type exists - don't create it automatically const targetTypePath = this.#workspace.getNoteTypePath(newType); try { await fs.access(targetTypePath); } catch { throw new Error( `Note type '${newType}' does not exist. Create the note type first before moving notes to it.` ); } const targetPath = path.join(targetTypePath, filename); // Check for conflicts in target directory try { await fs.access(targetPath); // If we reach here, the file exists - throw conflict error throw new Error( `A note with filename '${filename}' already exists in note type '${newType}'. ` + 'Move operation would overwrite existing note.' ); } catch (error) { // If it's a filesystem error (ENOENT), we can proceed if (error instanceof Error && 'code' in error && error.code === 'ENOENT') { // File doesn't exist, we can proceed } else { // Re-throw any other error (including our conflict error) throw error; } } // Remove from search index at old location await this.removeFromSearchIndex(currentPath); // Update the metadata with new type and timestamp const updatedMetadata = { ...currentNote.metadata, type: newType, updated: new Date().toISOString() }; // Format the content with updated metadata const updatedContent = this.formatUpdatedNoteContent( updatedMetadata, currentNote.content ); // Move the file to the new location await fs.rename(currentPath, targetPath); // Write the updated content with new type await fs.writeFile(targetPath, updatedContent, 'utf-8'); // Update search index at new location await this.updateSearchIndex(targetPath, updatedContent); // Generate new identifier const newId = this.generateNoteId(newType, filename); let linksUpdated = 0; let notesWithUpdatedLinks = 0; // Update links if search manager is available if (this.#hybridSearchManager) { const db = await this.#hybridSearchManager.getDatabaseConnection(); // Update all links that reference the old identifier const result = await LinkExtractor.updateWikilinksForMovedNote( identifier, newId, currentNote.title, db ); linksUpdated = result.linksUpdated; notesWithUpdatedLinks = result.notesUpdated; } const timestamp = new Date().toISOString(); return { success: true, old_id: identifier, new_id: newId, old_type: oldType, new_type: newType, filename, title: currentNote.title, timestamp, links_updated: linksUpdated, notes_with_updated_links: notesWithUpdatedLinks }; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; throw new Error( `Failed to move note '${identifier}' to type '${newType}': ${errorMessage}` ); } } }

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