Skip to main content
Glama
joelmnz

Article Manager MCP Server

by joelmnz
databaseArticles.ts16.5 kB
import { database } from './database.js'; import { createHash } from 'crypto'; import { handleDatabaseError, DatabaseServiceError, DatabaseErrorType, retryDatabaseOperation, logDatabaseError } from './databaseErrors.js'; import { databaseConstraintService } from './databaseConstraints.js'; // Database-specific interfaces that match the schema export interface DatabaseArticle { id: number; title: string; slug: string; content: string; folder: string; isPublic: boolean; createdAt: Date; updatedAt: Date; createdBy?: string; updatedBy?: string; } // Maintain compatibility with existing Article interface export interface Article { slug: string; title: string; content: string; folder: string; created: string; isPublic: boolean; } export interface ArticleMetadata { slug: string; title: string; folder: string; created: string; modified: string; isPublic: boolean; } /** * Database-backed article service that replaces file-based storage */ export class DatabaseArticleService { /** * Generate URL-friendly slug from title */ generateSlug(title: string): string { return title .toLowerCase() .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .trim(); } /** * Validate slug format and uniqueness using constraint service */ private async validateSlug(slug: string, excludeId?: number): Promise<void> { await databaseConstraintService.validateArticleData({ slug, excludeId }); } /** * Validate folder path format using constraint service */ private async validateFolder(folder: string): Promise<void> { await databaseConstraintService.validateArticleData({ folder }); } /** * Normalize folder path (convert '/' to empty string for root) */ private normalizeFolder(folder?: string): string { if (!folder || folder === '/') { return ''; } return folder; } /** * Convert database row to Article interface */ private dbRowToArticle(row: any): Article { return { slug: row.slug, title: row.title, content: row.content, folder: row.folder, created: row.created_at.toISOString(), isPublic: row.is_public }; } /** * Convert database row to ArticleMetadata interface */ private dbRowToMetadata(row: any): ArticleMetadata { return { slug: row.slug, title: row.title, folder: row.folder, created: row.created_at.toISOString(), modified: row.updated_at.toISOString(), isPublic: row.is_public }; } /** * List all articles with optional folder filtering (includes subfolders) */ async listArticles(folder?: string, limit?: number): Promise<ArticleMetadata[]> { try { let query = ` SELECT slug, title, folder, is_public, created_at, updated_at FROM articles `; const params: any[] = []; if (folder !== undefined) { const normalizedFolder = this.normalizeFolder(folder); await this.validateFolder(normalizedFolder); // Use LIKE pattern to include subfolders // e.g., 'projects' matches 'projects', 'projects/web-dev', 'projects/project-1', etc. if (normalizedFolder === '') { // Empty folder means root - show all articles query += ' WHERE folder = $1'; params.push(normalizedFolder); } else { // Include the folder itself and all subfolders query += ' WHERE (folder = $1 OR folder LIKE $2)'; params.push(normalizedFolder, `${normalizedFolder}/%`); } } query += ' ORDER BY updated_at DESC'; if (limit !== undefined) { params.push(limit); query += ` LIMIT $${params.length}`; } const result = await database.query(query, params); return result.rows.map(row => this.dbRowToMetadata(row)); } catch (error) { if (error instanceof DatabaseServiceError) { throw error; } const dbError = handleDatabaseError(error); logDatabaseError(dbError, 'List Articles'); throw dbError; } } /** * Search articles by title with optional folder filtering (includes subfolders) */ async searchArticles(query: string, folder?: string): Promise<ArticleMetadata[]> { let sql = ` SELECT slug, title, folder, is_public, created_at, updated_at FROM articles WHERE title ILIKE $1 `; const params: any[] = [`%${query}%`]; if (folder !== undefined) { const normalizedFolder = this.normalizeFolder(folder); // Use LIKE pattern to include subfolders // e.g., 'projects' matches 'projects', 'projects/web-dev', 'projects/project-1', etc. if (normalizedFolder === '') { // Empty folder means root - exact match only sql += ' AND folder = $2'; params.push(normalizedFolder); } else { // Include the folder itself and all subfolders sql += ' AND (folder = $2 OR folder LIKE $3)'; params.push(normalizedFolder, `${normalizedFolder}/%`); } } sql += ' ORDER BY updated_at DESC'; const result = await database.query(sql, params); return result.rows.map(row => this.dbRowToMetadata(row)); } /** * Read article by slug */ async readArticle(slug: string): Promise<Article | null> { try { if (!slug || slug.trim().length === 0) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Slug cannot be empty', 'Article identifier cannot be empty.' ); } const result = await database.query( 'SELECT * FROM articles WHERE slug = $1', [slug] ); if (result.rows.length === 0) { return null; } return this.dbRowToArticle(result.rows[0]); } catch (error) { if (error instanceof DatabaseServiceError) { throw error; } const dbError = handleDatabaseError(error); logDatabaseError(dbError, 'Read Article'); throw dbError; } } /** * Read article by database ID */ async readArticleById(id: number): Promise<Article | null> { const result = await database.query( 'SELECT * FROM articles WHERE id = $1', [id] ); if (result.rows.length === 0) { return null; } return this.dbRowToArticle(result.rows[0]); } /** * Get article ID by slug (internal helper) */ async getArticleId(slug: string): Promise<number | null> { const result = await database.query( 'SELECT id FROM articles WHERE slug = $1', [slug] ); if (result.rows.length === 0) { return null; } return result.rows[0].id; } /** * Create a new article */ async createArticle( title: string, content: string, folder?: string, message?: string ): Promise<Article> { try { const normalizedFolder = this.normalizeFolder(folder); const slug = this.generateSlug(title); // Comprehensive validation using constraint service await databaseConstraintService.validateArticleData({ title, slug, content, folder: normalizedFolder }); const now = new Date(); const result = await database.query( `INSERT INTO articles (title, slug, content, folder, is_public, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [title.trim(), slug, content.trim(), normalizedFolder, false, now, now] ); return this.dbRowToArticle(result.rows[0]); } catch (error) { if (error instanceof DatabaseServiceError) { throw error; } const dbError = handleDatabaseError(error); logDatabaseError(dbError, 'Create Article'); throw dbError; } } /** * Update an existing article */ async updateArticle( slug: string, title: string, content: string, folder?: string, message?: string ): Promise<Article> { // Validate inputs if (!title || title.trim().length === 0) { throw new Error('Title cannot be empty'); } if (!content || content.trim().length === 0) { throw new Error('Content cannot be empty'); } const normalizedFolder = this.normalizeFolder(folder); this.validateFolder(normalizedFolder); // Get existing article with all fields in a single query const existingResult = await database.query( 'SELECT * FROM articles WHERE slug = $1', [slug] ); if (existingResult.rows.length === 0) { throw new Error(`Article with slug '${slug}' not found`); } const existingRow = existingResult.rows[0]; const articleId = existingRow.id; // Generate new slug from title const newSlug = this.generateSlug(title); // Validate new slug if it's different if (newSlug !== slug) { await this.validateSlug(newSlug, articleId); } // Check if content or title changed to decide whether to update updated_at const hasContentChanged = title !== existingRow.title || content !== existingRow.content; const updatedAt = hasContentChanged ? new Date() : existingRow.updated_at; const result = await database.query( `UPDATE articles SET title = $1, slug = $2, content = $3, folder = $4, updated_at = $5 WHERE id = $6 RETURNING *`, [title, newSlug, content, normalizedFolder, updatedAt, articleId] ); if (result.rows.length === 0) { throw new Error(`Failed to update article with slug '${slug}'`); } return this.dbRowToArticle(result.rows[0]); } /** * Delete an article */ async deleteArticle(slug: string): Promise<void> { try { if (!slug || slug.trim().length === 0) { throw new DatabaseServiceError( DatabaseErrorType.VALIDATION_ERROR, 'Slug cannot be empty', 'Article identifier cannot be empty.' ); } // Get article ID for constraint validation const articleId = await this.getArticleId(slug); if (!articleId) { throw new DatabaseServiceError( DatabaseErrorType.NOT_FOUND, `Article with slug '${slug}' not found`, 'The article you are trying to delete does not exist.' ); } // Validate deletion constraints (check referential integrity) await databaseConstraintService.validateArticleDeletion(articleId); const result = await database.query( 'DELETE FROM articles WHERE slug = $1', [slug] ); if (result.rowCount === 0) { throw new DatabaseServiceError( DatabaseErrorType.NOT_FOUND, `Article with slug '${slug}' not found`, 'The article you are trying to delete does not exist.' ); } } catch (error) { if (error instanceof DatabaseServiceError) { throw error; } const dbError = handleDatabaseError(error); logDatabaseError(dbError, 'Delete Article'); throw dbError; } } /** * Move article to a different folder */ async moveArticle(slug: string, newFolder: string): Promise<Article> { const normalizedFolder = this.normalizeFolder(newFolder); this.validateFolder(normalizedFolder); const now = new Date(); const result = await database.query( `UPDATE articles SET folder = $1, updated_at = $2 WHERE slug = $3 RETURNING *`, [normalizedFolder, now, slug] ); if (result.rows.length === 0) { throw new Error(`Article with slug '${slug}' not found`); } return this.dbRowToArticle(result.rows[0]); } /** * Set article public status */ async setArticlePublic(slug: string, isPublic: boolean): Promise<void> { const now = new Date(); const result = await database.query( `UPDATE articles SET is_public = $1, updated_at = $2 WHERE slug = $3`, [isPublic, now, slug] ); if (result.rowCount === 0) { throw new Error(`Article with slug '${slug}' not found`); } } /** * Get article by slug (for public access) - only returns if public */ async getPublicArticle(slug: string): Promise<Article | null> { const result = await database.query( 'SELECT * FROM articles WHERE slug = $1 AND is_public = true', [slug] ); if (result.rows.length === 0) { return null; } return this.dbRowToArticle(result.rows[0]); } /** * List articles in a specific folder (including subfolders) */ async listArticlesInFolder(folderPath: string, includeSubfolders: boolean = false): Promise<ArticleMetadata[]> { const normalizedFolder = this.normalizeFolder(folderPath); let query: string; let params: any[]; if (includeSubfolders) { // Include articles in subfolders using LIKE pattern query = ` SELECT slug, title, folder, is_public, created_at, updated_at FROM articles WHERE folder LIKE $1 ORDER BY updated_at DESC `; params = [normalizedFolder === '' ? '%' : `${normalizedFolder}%`]; } else { // Exact folder match query = ` SELECT slug, title, folder, is_public, created_at, updated_at FROM articles WHERE folder = $1 ORDER BY updated_at DESC `; params = [normalizedFolder]; } const result = await database.query(query, params); return result.rows.map(row => this.dbRowToMetadata(row)); } /** * Get folder hierarchy (list all unique folder paths) */ async getFolderHierarchy(): Promise<string[]> { const result = await database.query( `SELECT DISTINCT folder FROM articles WHERE folder != '' ORDER BY folder` ); return result.rows.map(row => row.folder); } /** * Rename a folder (updates all articles with the old folder name to the new folder name) */ async renameFolder(oldFolderName: string, newFolderName: string): Promise<{ updatedCount: number }> { try { // Normalize folder names const normalizedOldFolder = this.normalizeFolder(oldFolderName); const normalizedNewFolder = this.normalizeFolder(newFolderName); // Validate new folder name format await this.validateFolder(normalizedNewFolder); // Check if old folder exists const folders = await this.getFolderHierarchy(); if (!folders.includes(normalizedOldFolder)) { throw new DatabaseServiceError( DatabaseErrorType.NOT_FOUND, `Folder '${oldFolderName}' not found`, 'The folder you are trying to rename does not exist.' ); } // Update all articles with the old folder name const result = await database.query( `UPDATE articles SET folder = $1, updated_at = $2 WHERE folder = $3`, [normalizedNewFolder, new Date(), normalizedOldFolder] ); return { updatedCount: result.rowCount || 0 }; } catch (error) { if (error instanceof DatabaseServiceError) { throw error; } const dbError = handleDatabaseError(error); logDatabaseError(dbError, 'Rename Folder'); throw dbError; } } /** * Delete a folder (sets folder to empty string for all articles with that folder) */ async deleteFolder(folderName: string): Promise<{ updatedCount: number }> { try { // Normalize folder name const normalizedFolder = this.normalizeFolder(folderName); // Check if folder exists const folders = await this.getFolderHierarchy(); if (!folders.includes(normalizedFolder)) { throw new DatabaseServiceError( DatabaseErrorType.NOT_FOUND, `Folder '${folderName}' not found`, 'The folder you are trying to delete does not exist.' ); } // Update all articles with the folder to have empty folder const result = await database.query( `UPDATE articles SET folder = '', updated_at = $1 WHERE folder = $2`, [new Date(), normalizedFolder] ); return { updatedCount: result.rowCount || 0 }; } catch (error) { if (error instanceof DatabaseServiceError) { throw error; } const dbError = handleDatabaseError(error); logDatabaseError(dbError, 'Delete Folder'); throw dbError; } } } // Export singleton instance export const databaseArticleService = new DatabaseArticleService();

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/joelmnz/mcp-markdown-manager'

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