419 lines
13 KiB
TypeScript
419 lines
13 KiB
TypeScript
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();
|