Skip to main content
Glama
WritingStorage.ts13.9 kB
/** * WritingStorage - Database operations for Writer's Aid MCP * Handles CRUD operations for markdown files, chunks, links, and metadata */ import type Database from "better-sqlite3"; import type { SQLiteManager } from "./SQLiteManager.js"; import { QueryCache, type QueryCacheConfig, type CacheStats } from "../cache/QueryCache.js"; import type { MarkdownFile, MarkdownChunk, MarkdownHeading, MarkdownLink, MarkdownMetadata, MarkdownFileRow, MarkdownChunkRow, } from "../markdown/types.js"; /** * WritingStorage class for managing manuscript data */ export class WritingStorage { private db: Database.Database; private cache: QueryCache | null = null; constructor(sqliteManager: SQLiteManager) { this.db = sqliteManager.getDatabase(); } // ============================================================================ // Cache Management // ============================================================================ /** * Enable query caching for better performance */ enableCache(config: QueryCacheConfig): void { this.cache = new QueryCache(config); } /** * Disable query caching */ disableCache(): void { this.cache = null; } /** * Check if caching is enabled */ isCacheEnabled(): boolean { return this.cache !== null; } /** * Clear all cached queries */ clearCache(): void { this.cache?.clear(); } /** * Get cache statistics */ getCacheStats(): CacheStats | null { return this.cache ? this.cache.getStats() : null; } // ============================================================================ // File Operations // ============================================================================ /** * Store markdown files in the database */ async storeFiles(files: MarkdownFile[]): Promise<void> { const stmt = this.db.prepare(` INSERT OR REPLACE INTO markdown_files (id, file_path, title, content, content_hash, word_count, created_at, last_modified, indexed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); this.db.transaction(() => { for (const file of files) { stmt.run( file.id, file.file_path, file.title, file.content, file.content_hash, file.word_count, file.created_at, file.last_modified, file.indexed_at ); // Invalidate cache for this file if (this.cache) { this.cache.delete(`file:${file.file_path}`); this.cache.delete(`file:${file.id}`); } } })(); console.log(`✓ Stored ${files.length} markdown files`); } /** * Get a file by path */ getFile(filePath: string): MarkdownFile | null { const cacheKey = `file:${filePath}`; // Check cache first if (this.cache) { const cached = this.cache.get<MarkdownFile | null>(cacheKey); if (cached !== undefined) { return cached; } } const row = this.db .prepare("SELECT * FROM markdown_files WHERE file_path = ?") .get(filePath) as MarkdownFileRow | undefined; if (!row) { this.cache?.set(cacheKey, null); return null; } const result: MarkdownFile = { ...row, title: row.title || null, }; this.cache?.set(cacheKey, result); return result; } /** * Get file by ID */ getFileById(id: string): MarkdownFile | null { const cacheKey = `file:${id}`; if (this.cache) { const cached = this.cache.get<MarkdownFile | null>(cacheKey); if (cached !== undefined) { return cached; } } const row = this.db .prepare("SELECT * FROM markdown_files WHERE id = ?") .get(id) as MarkdownFileRow | undefined; if (!row) { this.cache?.set(cacheKey, null); return null; } const result: MarkdownFile = { ...row, title: row.title || null, }; this.cache?.set(cacheKey, result); return result; } /** * Get all files */ getAllFiles(): MarkdownFile[] { const rows = this.db .prepare("SELECT * FROM markdown_files ORDER BY file_path") .all() as MarkdownFileRow[]; return rows.map((row) => ({ ...row, title: row.title || null, })); } // ============================================================================ // Chunk Operations // ============================================================================ /** * Store markdown chunks */ async storeChunks(chunks: MarkdownChunk[]): Promise<void> { const stmt = this.db.prepare(` INSERT OR REPLACE INTO markdown_chunks (id, file_id, chunk_index, heading, content, start_offset, end_offset, token_count, word_count) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); this.db.transaction(() => { for (const chunk of chunks) { stmt.run( chunk.id, chunk.file_id, chunk.chunk_index, chunk.heading, chunk.content, chunk.start_offset, chunk.end_offset, chunk.token_count, chunk.word_count ); } })(); console.log(`✓ Stored ${chunks.length} chunks`); } /** * Get chunks for a file */ getChunksForFile(fileId: string): MarkdownChunk[] { const cacheKey = `chunks:${fileId}`; if (this.cache) { const cached = this.cache.get<MarkdownChunk[]>(cacheKey); if (cached !== undefined) { return cached; } } const rows = this.db .prepare("SELECT * FROM markdown_chunks WHERE file_id = ? ORDER BY chunk_index") .all(fileId) as MarkdownChunkRow[]; const result: MarkdownChunk[] = rows.map((row) => ({ ...row, heading: row.heading || null, })); this.cache?.set(cacheKey, result); return result; } // ============================================================================ // Heading Operations // ============================================================================ /** * Store headings */ async storeHeadings(headings: MarkdownHeading[]): Promise<void> { const stmt = this.db.prepare(` INSERT OR REPLACE INTO markdown_headings (id, file_id, level, text, slug, line_number, parent_id) VALUES (?, ?, ?, ?, ?, ?, ?) `); this.db.transaction(() => { for (const heading of headings) { stmt.run( heading.id, heading.file_id, heading.level, heading.text, heading.slug, heading.line_number, heading.parent_id ); } })(); console.log(`✓ Stored ${headings.length} headings`); } /** * Get headings for a file */ getHeadingsForFile(fileId: string): MarkdownHeading[] { const rows = this.db .prepare("SELECT * FROM markdown_headings WHERE file_id = ? ORDER BY line_number") .all(fileId) as MarkdownHeading[]; return rows; } // ============================================================================ // Link Operations // ============================================================================ /** * Store links */ async storeLinks(links: MarkdownLink[]): Promise<void> { const stmt = this.db.prepare(` INSERT OR REPLACE INTO markdown_links (id, source_file_id, target_file_path, link_text, link_type, source_line, is_broken) VALUES (?, ?, ?, ?, ?, ?, ?) `); this.db.transaction(() => { for (const link of links) { stmt.run( link.id, link.source_file_id, link.target_file_path, link.link_text, link.link_type, link.source_line, link.is_broken ? 1 : 0 ); } })(); console.log(`✓ Stored ${links.length} links`); } /** * Get links from a file */ getLinksFromFile(fileId: string): MarkdownLink[] { const rows = this.db .prepare("SELECT * FROM markdown_links WHERE source_file_id = ?") .all(fileId) as Array<Record<string, unknown>>; return rows.map((row) => ({ id: row.id as string, source_file_id: row.source_file_id as string, target_file_path: row.target_file_path as string, link_text: (row.link_text as string) || null, link_type: row.link_type as 'wiki' | 'markdown' | 'external' | 'anchor', source_line: (row.source_line as number) || null, is_broken: Boolean(row.is_broken), })); } /** * Get broken links */ getBrokenLinks(): MarkdownLink[] { const rows = this.db .prepare("SELECT * FROM markdown_links WHERE is_broken = 1") .all() as Array<Record<string, unknown>>; return rows.map((row) => ({ id: row.id as string, source_file_id: row.source_file_id as string, target_file_path: row.target_file_path as string, link_text: (row.link_text as string) || null, link_type: row.link_type as 'wiki' | 'markdown' | 'external' | 'anchor', source_line: (row.source_line as number) || null, is_broken: true, })); } // ============================================================================ // Metadata Operations // ============================================================================ /** * Store metadata */ async storeMetadata(metadata: MarkdownMetadata[]): Promise<void> { const stmt = this.db.prepare(` INSERT OR REPLACE INTO markdown_metadata (id, file_id, key, value) VALUES (?, ?, ?, ?) `); this.db.transaction(() => { for (const meta of metadata) { stmt.run(meta.id, meta.file_id, meta.key, meta.value); } })(); console.log(`✓ Stored ${metadata.length} metadata entries`); } /** * Get metadata for a file */ getMetadataForFile(fileId: string): MarkdownMetadata[] { const rows = this.db .prepare("SELECT * FROM markdown_metadata WHERE file_id = ?") .all(fileId) as MarkdownMetadata[]; return rows; } // ============================================================================ // Statistics // ============================================================================ /** * Get database statistics */ getStats(): { files: number; chunks: number; headings: number; links: number; todos: number; } { const cacheKey = "stats:all"; if (this.cache) { const cached = this.cache.get<ReturnType<typeof this.getStats>>(cacheKey); if (cached !== undefined) { return cached; } } const stats = { files: (this.db.prepare("SELECT COUNT(*) as count FROM markdown_files").get() as { count: number }).count, chunks: (this.db.prepare("SELECT COUNT(*) as count FROM markdown_chunks").get() as { count: number }).count, headings: (this.db.prepare("SELECT COUNT(*) as count FROM markdown_headings").get() as { count: number }).count, links: (this.db.prepare("SELECT COUNT(*) as count FROM markdown_links").get() as { count: number }).count, todos: (this.db.prepare("SELECT COUNT(*) as count FROM markdown_todos").get() as { count: number }).count, }; this.cache?.set(cacheKey, stats); return stats; } // ============================================================================ // Convenience Helper Methods // ============================================================================ /** * Add a single file (convenience method) */ async addFile(data: { filePath: string; content: string; title: string }): Promise<void> { const file: MarkdownFile = { id: data.filePath, file_path: data.filePath, title: data.title, content: data.content, content_hash: this.hashContent(data.content), word_count: data.content.split(/\s+/).length, created_at: Date.now(), last_modified: Date.now(), indexed_at: Date.now(), }; await this.storeFiles([file]); } /** * Add a single chunk (convenience method) */ async addChunk( filePath: string, data: { heading: string; content: string; chunkIndex: number; tokenCount: number } ): Promise<void> { const chunk: MarkdownChunk = { id: `${filePath}:${data.chunkIndex}`, file_id: filePath, chunk_index: data.chunkIndex, heading: data.heading, content: data.content, start_offset: 0, end_offset: data.content.length, token_count: data.tokenCount, word_count: data.content.split(/\s+/).length, }; await this.storeChunks([chunk]); } /** * Add a single link (convenience method) */ async addLink(data: { sourceFile: string; targetFile: string; linkText: string; linkType: string; }): Promise<void> { const link: MarkdownLink = { id: `${data.sourceFile}:${data.targetFile}`, source_file_id: data.sourceFile, target_file_path: data.targetFile, link_text: data.linkText, link_type: data.linkType as "wiki" | "markdown" | "external" | "anchor", source_line: 0, is_broken: false, }; await this.storeLinks([link]); } /** * Get headings for a file */ getHeadings(filePath: string): MarkdownHeading[] { const rows = this.db .prepare("SELECT * FROM markdown_headings WHERE file_id = ? ORDER BY line_number") .all(filePath) as MarkdownHeading[]; return rows; } /** * Close database connection */ close(): void { this.db.close(); } /** * Hash content for change detection */ private hashContent(content: string): string { // Simple hash function let hash = 0; for (let i = 0; i < content.length; i++) { const char = content.charCodeAt(i); hash = (hash << 5) - hash + char; hash |= 0; // Convert to 32bit integer } return hash.toString(36); } }

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/xiaolai/claude-writers-aid-mcp'

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