// The connection strings are: // PostgreSQL - "postgres://root:root@localhost:5432" // SQLite - "sqlite::memory:" // const DATABASE_URL: &str = "sqlite::memory:"; // const DB_NAME: &str = "cm_tavern_db"; use std::path::Path; use rusqlite::{params, Connection, Result}; #[cfg(not(feature = "publisher"))] use rusqlite::Statement; use crate::adventurer::Adventurer; #[cfg(feature = "publisher")] use crate::converter::Converter; #[cfg(not(feature = "publisher"))] use crate::tale::FrontMatter; use crate::tale::Tale; pub struct Database { connection: Connection } impl Database { /// Opens a connection to the SQLite database and creates the necessary /// tables. pub fn open(db_path: &Path) -> Result { let connection = Connection::open(db_path)?; #[cfg(feature = "publisher")] Self::create_tables(&connection)?; Ok(Database { connection }) } /// Creates the 'tales' and 'adventurers' tables if they don't exist. #[cfg(feature = "publisher")] fn create_tables(conn: &Connection) -> Result<()> { conn.execute( "CREATE TABLE IF NOT EXISTS adventurers ( handle TEXT PRIMARY KEY, name TEXT NOT NULL, profile TEXT, image TEXT, blurb TEXT )", [] )?; conn.execute( "CREATE TABLE IF NOT EXISTS tales ( slug TEXT PRIMARY KEY, title TEXT NOT NULL, author TEXT NOT NULL, summary TEXT, publish_date TEXT NOT NULL, content TEXT )", [] )?; conn.execute( "CREATE TABLE IF NOT EXISTS tags ( id INTEGER PRIMARY KEY, name TEXT NOT NULL UNIQUE )", [] )?; conn.execute( "CREATE TABLE IF NOT EXISTS tale_tags ( tale_slug TEXT, tag_id INTEGER, FOREIGN KEY(tale_slug) REFERENCES tales(slug), FOREIGN KEY(tag_id) REFERENCES tags(id), UNIQUE(tale_slug, tag_id) )", [] )?; conn.execute( "CREATE TABLE IF NOT EXISTS tavern ( key TEXT PRIMARY KEY, value TEXT NOT NULL )", [], )?; Ok(()) } /// Inserts a single tale into the database. #[cfg(feature = "publisher")] pub fn insert_tale(&self, tale: &Tale) -> Result<()> { // Read the markdown content from the file let markdown_content = std::fs::read_to_string(&tale.content) .map_err(|e| rusqlite::Error::ToSqlConversionFailure(e.into()))?; // Convert the markdown to HTML let html_content = Converter::markdown_to_html(&markdown_content); self.connection.execute( "INSERT OR REPLACE INTO tales ( slug, title, author, summary, publish_date, content ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", params![ tale.front_matter.slug, tale.front_matter.title, tale.front_matter.author, tale.front_matter.summary, tale.front_matter.publish_date.to_string(), html_content ] )?; // Insert tags and link them to the tale for tag_name in &tale.front_matter.tags { let tag_id = self.insert_and_get_tag_id(tag_name)?; self.connection.execute("INSERT OR IGNORE INTO tale_tags \ (tale_slug, tag_id) VALUES (?1, ?2)", params![tale.front_matter.slug, tag_id])?; } Ok(()) } /// Inserts a tag and returns its ID. If the tag already exists, it returns /// the existing ID. #[cfg(feature = "publisher")] fn insert_and_get_tag_id(&self, tag_name: &str) -> Result { self.connection .execute("INSERT OR IGNORE INTO tags (name) VALUES (?1)", params![tag_name])?; let id: i64 = self.connection .query_row("SELECT id FROM tags WHERE name = ?1", params![tag_name], |row| row.get(0))?; Ok(id) } /// Inserts a single adventurer into the database. #[cfg(feature = "publisher")] pub fn insert_adventurer(&self, adventurer: &Adventurer) -> Result<()> { self.connection.execute( "INSERT OR REPLACE INTO adventurers ( handle, name, profile, image, blurb ) VALUES (?1, ?2, ?3, ?4, ?5)", params![ adventurer.handle, adventurer.name, adventurer.profile, adventurer.image, adventurer.blurb ] )?; Ok(()) } /// Inserts the site-wide settings like title and description. #[cfg(feature = "publisher")] pub fn insert_tavern(&self, title: &str, description: &str) -> Result<()> { self.connection.execute_batch(&format!( "BEGIN; INSERT OR REPLACE INTO tavern (key, value) VALUES ('title', '{}'); INSERT OR REPLACE INTO tavern (key, value) VALUES ('description', '{}'); COMMIT;", title, description ))?; Ok(()) } /// Fetches a vector of lightweight FrontMatter objects from the database. #[cfg(not(feature = "publisher"))] pub fn get_tales_summary(&self, categories: &[String]) -> Result> { let mut query = String::from( "SELECT t.title, t.slug, t.summary, t.author, t.publish_date, GROUP_CONCAT(tg.name, ',') AS tags FROM tales AS t JOIN tale_tags AS tt ON t.slug = tt.tale_slug JOIN tags AS tg ON tt.tag_id = tg.id" ); // If categories are provided, filter the results if !categories.is_empty() { query.push_str(" WHERE tg.name IN ("); // Add a placeholder for each category let placeholders: Vec<_> = (0..categories.len()).map(|_| "?").collect(); query.push_str(&placeholders.join(", ")); query.push_str(")"); } // Group by tale to get all tags for each tale query.push_str(" GROUP BY t.slug"); query.push_str(" ORDER BY t.publish_date DESC"); let mut stmt: Statement = self.connection.prepare(&query)?; let tales_iter = stmt.query_map(rusqlite::params_from_iter(categories), |row| { let date_str: String = row.get(4)?; let tags_str: String = row.get(5)?; let tags: Vec = tags_str.split(',').map(|s| s.to_string()).collect(); Ok(FrontMatter { title: row.get(0)?, slug: row.get(1)?, summary: row.get(2)?, author: row.get(3)?, publish_date: chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S").map_err(|e| rusqlite::Error::ToSqlConversionFailure(e.into()))?, tags, }) })?; let summaries: Result, _> = tales_iter.collect(); summaries } /// Fetches a single tale from the database by its slug. /// This function is only available in the 'reader' build. #[cfg(not(feature = "publisher"))] pub fn get_tale_by_slug(&self, slug: &str) -> Result> { let query = String::from( "SELECT t.title, t.slug, t.summary, t.author, t.publish_date, t.content, GROUP_CONCAT(tg.name, ',') AS tags FROM tales AS t LEFT JOIN tale_tags AS tt ON t.slug = tt.tale_slug LEFT JOIN tags AS tg ON tt.tag_id = tg.id WHERE t.slug = ?1 GROUP BY t.slug" ); // Prepare the statement and execute a query for a single row. let result = self.connection.query_row( &query, params![slug], |row| { // Parse the tags string into a vector of strings. let tags_str: Option = row.get(6)?; let tags = tags_str.map(|s| s.split(',').map(|tag| tag.to_string()).collect()).unwrap_or_default(); // Parse the date string into a NaiveDateTime. let date_str: String = row.get(4)?; let publish_date = chrono::NaiveDateTime::parse_from_str(&date_str, "%Y-%m-%dT%H:%M:%S") .map_err(|e| rusqlite::Error::ToSqlConversionFailure(e.into()))?; // Construct the FrontMatter and the full Tale struct. Ok(Tale { front_matter: FrontMatter { title: row.get(0)?, slug: row.get(1)?, summary: row.get(2)?, author: row.get(3)?, publish_date, tags, }, content: row.get(5)?, }) } ); // Handle the result: if no rows were returned, return `None`. match result { Ok(tale) => Ok(Some(tale)), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e), } } /// Fetches a single adventurer from the database by their handle. /// This function is only available in the 'reader' build. #[cfg(not(feature = "publisher"))] pub fn get_adventurer(&self, handle: &str) -> Result> { let result = self.connection.query_row( "SELECT handle, name, profile, image, blurb FROM adventurers WHERE handle = ?1", params![handle], |row| { Ok(Adventurer { handle: row.get(0)?, name: row.get(1)?, profile: row.get(2)?, image: row.get(3)?, blurb: row.get(4)?, }) } ); match result { Ok(adventurer) => Ok(Some(adventurer)), Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), Err(e) => Err(e), } } }