310 lines
10 KiB
Rust
310 lines
10 KiB
Rust
|
|
// 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<Self>
|
||
|
|
{
|
||
|
|
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<i64>
|
||
|
|
{
|
||
|
|
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<Vec<FrontMatter>> {
|
||
|
|
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<String> = 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<Vec<_>, _> = 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<Option<Tale>> {
|
||
|
|
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<String> = 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<Option<Adventurer>> {
|
||
|
|
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),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|