import { Database } from 'bun:sqlite'; import path from 'path'; import { calculateReadTime } from '../frontend/utils'; /** * Singleton database connection class for managing blog posts and tags */ class DatabaseConnection { private static instance: DatabaseConnection; private db: Database; /** * Private constructor to initialize database connection and schema */ private constructor() { // Initialize the database if it doesn't exist const dbPath = path.join(process.cwd(), 'blog.sqlite'); this.db = new Database(dbPath); // Initialize database schema this.initializeDatabase(); } /** * Gets the singleton instance of the DatabaseConnection * * @returns The DatabaseConnection singleton instance */ public static getInstance(): DatabaseConnection { if (!DatabaseConnection.instance) { DatabaseConnection.instance = new DatabaseConnection(); } return DatabaseConnection.instance; } /** * Gets the database connection * * @returns The SQLite database instance */ public getDatabase(): Database { return this.db; } /** * Initializes the database schema with posts, tags, and post_tags tables */ private initializeDatabase() { // Create the posts table if it doesn't exist this.db.run(` CREATE TABLE IF NOT EXISTS posts ( id INTEGER PRIMARY KEY AUTOINCREMENT, path TEXT UNIQUE NOT NULL, title TEXT, date TEXT, reading_time TEXT, summary TEXT, content TEXT ) `); // Create the tags table if it doesn't exist this.db.run(` CREATE TABLE IF NOT EXISTS tags ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE NOT NULL, url_normalized TEXT GENERATED ALWAYS AS (lower(replace(name, ' ', '-'))) STORED ) `); // Create the post_tags junction table for many-to-many relationship this.db.run(` CREATE TABLE IF NOT EXISTS post_tags ( post_id INTEGER, tag_id INTEGER, PRIMARY KEY (post_id, tag_id), FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE, FOREIGN KEY (tag_id) REFERENCES tags (id) ON DELETE CASCADE ) `); } /** * Adds a post to the database with its metadata and content * * @param filePath - The path to the post file * @param data - An object containing the post metadata (title, date, tags, etc.) * @param content - The full content of the post */ public addPost(filePath: string, data: { [key: string]: any }, content: string) { if (!data) return; // Use a transaction to ensure data consistency const transaction = this.db.transaction(() => { try { // Extract common fields const { title, date, readingTime, tags, excerpt } = data; // Convert all values to strings or null explicitly (except tags) // Ensure date is stored in ISO format for consistent sorting const values = [ filePath ? String(filePath) : null, title ? String(title) : null, date ? (date instanceof Date ? date.toISOString().split('T')[0] : String(date)) : null, readingTime ? String(readingTime) : calculateReadTime(content), excerpt ? String(excerpt) : null, content ? String(content) : null ]; // Query to insert or replace metadata (without tags) const insertPost = this.db.query(` INSERT OR REPLACE INTO posts (path, title, date, reading_time, summary, content) VALUES (?, ?, ?, ?, ?, ?) `); insertPost.run(...values); // Get the post ID const getPostId = this.db.query('SELECT id FROM posts WHERE path = ?'); const postResult = getPostId.get(filePath) as { id: number } | undefined; if (!postResult) { throw new Error(`Failed to retrieve post ID for ${filePath}`); } // Delete existing tag associations for this post const deleteExistingTags = this.db.query('DELETE FROM post_tags WHERE post_id = ?'); deleteExistingTags.run(postResult.id); // If tags exist, process them if (tags && Array.isArray(tags)) { // Insert into junction table const insertPostTag = this.db.query('INSERT OR IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)'); // Create or get tag and associate with post const createOrGetTag = this.db.query(` INSERT OR IGNORE INTO tags (name) VALUES (?) RETURNING id `); const getTag = this.db.query('SELECT id FROM tags WHERE name = ?'); for (const tag of tags) { let tagResult = createOrGetTag.get(String(tag)) as { id: number } | undefined; // If no result from insert, the tag already exists, so get it if (!tagResult) { tagResult = getTag.get(String(tag)) as { id: number } | undefined; } if (!tagResult) { throw new Error(`Failed to retrieve tag ID for ${tag}`); } insertPostTag.run(postResult.id, tagResult.id); } } } catch (error) { console.error(`Failed to store ${filePath}:`, error); throw error; // Re-throw to make the transaction fail } }); // Execute the transaction try { transaction(); } catch (error) { console.error(`Transaction failed for ${filePath}:`, error); } } /** * Gets the total number of posts in the database * * @param tags - Optional array of tag names to filter posts by * @returns The number of posts, optionally filtered by tags */ public getNumOfPosts(tags: string[] = []) { if (tags.length === 0) { const queryCount = this.db.query('SELECT COUNT(*) AS count FROM posts WHERE path NOT LIKE \'%.md\''); const numPosts = queryCount.get() as { count: number }; return numPosts.count; } else { // Use url_normalized field for tag filtering const placeholders = tags.map(() => '?').join(','); // Get count of posts that have ALL the specified tags using url_normalized const countQuery = this.db.query(` SELECT COUNT(*) AS count FROM ( SELECT p.id, COUNT(DISTINCT t.id) as tag_count FROM posts p JOIN post_tags pt ON p.id = pt.post_id JOIN tags t ON pt.tag_id = t.id WHERE t.url_normalized IN (${placeholders}) AND p.path NOT LIKE '%.md' GROUP BY p.id HAVING COUNT(DISTINCT t.id) = ? ) `); const result = countQuery.get(...tags, tags.length) as { count: number }; return result.count; } } /** * Retrieves a paginated list of posts * * @param limit - Maximum number of posts to return (default: 10) * @param offset - Number of posts to skip for pagination (default: 0) * @param tags - Optional array of tag names to filter posts by * @returns Array of posts with metadata and cleaned paths */ public getPosts(limit: number = 10, offset: number = 0, tags: string[] = []): Array<{ id: number, path: string, title: string, date: string, readingTime: string, summary: string, content: string, tags: string[], }> { let posts; if (tags.length === 0) { // No tags specified, use the original query const query = this.db.query(` SELECT * FROM posts WHERE path NOT LIKE '%.md' ORDER BY date DESC LIMIT ? OFFSET ? `); posts = query.all(limit, offset) as any[]; } else { // Filter by tags using url_normalized field // and ensure posts have ALL the specified tags const placeholders = tags.map(() => '?').join(','); const tagFilter = this.db.query(` SELECT p.* FROM posts p JOIN post_tags pt ON p.id = pt.post_id JOIN tags t ON pt.tag_id = t.id WHERE t.url_normalized IN (${placeholders}) AND p.path NOT LIKE '%.md' GROUP BY p.id HAVING COUNT(DISTINCT t.id) = ? ORDER BY p.date DESC LIMIT ? OFFSET ? `); // Use the tags directly since we're now comparing with url_normalized posts = tagFilter.all(...tags, tags.length, limit, offset) as any[]; } // Helper function to get tags for a post const getPostTags = (postId: number): string[] => { const query = this.db.query(` SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ? `); const results = query.all(postId) as { name: string }[]; return results.map(row => row.name); }; // Add tags to each post and clean up paths return posts.map(post => { const basePost = { ...post, readingTime: post.reading_time} return { ...basePost, tags: getPostTags(post.id), path: post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, '') }; }); } /** * Retrieves a single post by its file path * * @param path - The file path of the post to retrieve * @returns The post object with metadata and tags, or null if not found */ public getPost(path: string) { const query = this.db.query(` SELECT * FROM posts WHERE path = ? `); const post = query.get(path) as any; if (!post) return null; // Helper function to get tags for a post const getPostTags = (postId: number): string[] => { const query = this.db.query(` SELECT t.name FROM tags t JOIN post_tags pt ON t.id = pt.tag_id WHERE pt.post_id = ? `); const results = query.all(postId) as { name: string }[]; return results.map(row => row.name); }; // Get tags for this post post.tags = getPostTags(post.id); return post; } /** * Finds the chronologically adjacent posts (previous and next) for navigation * * @param currentPostPath - The file path of the current post * @returns Object with previousPost and nextPost properties, each containing title and path, or null if no adjacent post exists */ public getAdjacentPosts(currentPostPath: string) { const allPostsQuery = this.db.query(` SELECT path, title, date FROM posts WHERE path NOT LIKE '%.md' ORDER BY date DESC `); const allPosts = allPostsQuery.all() as any[]; // Find the current post index const currentIndex = allPosts.findIndex(post => post.path === currentPostPath); // If not found, return empty navigation if (currentIndex === -1) { return { previousPost: null, nextPost: null }; } // Get previous post (newer post, which comes before current in reverse chronological order) let previousPost = null; if (currentIndex > 0) { const prevPost = allPosts[currentIndex - 1]; // Clean up the path to match the URL structure const cleanPath = prevPost.path.replace(/^.*\/content\//, '/').replace(/\.md$/, ''); previousPost = { title: prevPost.title, path: cleanPath }; } // Get next post (older post, which comes after current in reverse chronological order) let nextPost = null; if (currentIndex < allPosts.length - 1) { const post = allPosts[currentIndex + 1]; // Clean up the path to match the URL structure const cleanPath = post.path.replace(/^.*\/content\//, '/').replace(/\.md$/, ''); nextPost = { title: post.title, path: cleanPath }; } return { previousPost, nextPost }; } /** * Retrieves all unique tags from the database with their respective post counts. * * This method implements caching to avoid repeated database queries. After the first * call, subsequent calls will return the cached results. Be aware that on db build, this * likely won't be accurate because of the natural race condition and the server should be restarted. * * @returns {Array<{ name: string; post_count: number; urlNormalized: string }>} * An array of tag objects containing: * - name: The display name of the tag * - post_count: Number of posts associated with this tag * - urlNormalized: The URL-safe version of the tag name */ public getAllTags(): { name: string; post_count: number; urlNormalized: string }[] { if (!this.getAllTagsCache) { this.getAllTagsCache = this.initGetAllTags(); } return this.getAllTagsCache; } private getAllTagsCache: { name: string; post_count: number; urlNormalized: string }[] | null = null private initGetAllTags() { const query = db.query(` SELECT t.name, t.url_normalized, COUNT(pt.post_id) as post_count FROM tags t JOIN post_tags pt ON t.id = pt.tag_id JOIN posts p ON pt.post_id = p.id GROUP BY t.id, t.name, t.url_normalized ORDER BY post_count DESC, t.name ASC `); const results = query.all() as { name: string; url_normalized: string; post_count: number }[]; return results.map(row => ({ name: row.name, urlNormalized: row.url_normalized, post_count: row.post_count })); } } /** * The SQLite database connection class * Use this by default for using helper query functions */ export const dbConnection = DatabaseConnection.getInstance(); /** * The SQLite database connection instance * Use this exported instance for direct database operations */ export const db = dbConnection.getDatabase();