diff --git a/Cargo.lock b/Cargo.lock index ef51ce9..5a5ff29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,17 +2,149 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34efbcccd345379ca2868b2b2c9d3782e9cc58ba87bc7d79d5b53d9c9ae6f25d" + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "cc" +version = "1.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42bc4aea80032b7bf409b0bc7ccad88853858911b7713a8062fdc0623867bedc" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getopts" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1" +dependencies = [ + "unicode-width", +] + [[package]] name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] [[package]] name = "indexmap" @@ -24,6 +156,65 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "proc-macro2" version = "1.0.101" @@ -33,6 +224,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quote" version = "1.0.40" @@ -42,6 +252,26 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rusqlite" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "serde" version = "1.0.219" @@ -71,6 +301,18 @@ dependencies = [ "serde", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + [[package]] name = "syn" version = "2.0.106" @@ -86,6 +328,9 @@ dependencies = [ name = "tavern" version = "0.0.0" dependencies = [ + "chrono", + "pulldown-cmark", + "rusqlite", "serde", "toml", ] @@ -129,12 +374,147 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + [[package]] name = "unicode-ident" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" +[[package]] +name = "unicode-width" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link", +] + [[package]] name = "winnow" version = "0.7.12" diff --git a/Cargo.toml b/Cargo.toml index a7dd2fd..85d2158 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,5 +11,16 @@ license = "Apache-2.0" [dependencies] +chrono = { version = "0.4.41", features = ["serde"] } +pulldown-cmark = "0.13.0" +rusqlite = "0.37.0" serde = { version = "1.0.219", features = ["derive"] } toml = "0.9.5" + +# When switching to PostgreSQL use feature: "sqlx-postgres" +#sea-orm = { version = "1.1.14", features = [ "sqlx-sqlite", +# "runtime-tokio-native-tls", +# "macros" ] } + +[features] +publisher = [] diff --git a/examples/generate_database.rs b/examples/generate_database.rs new file mode 100644 index 0000000..2ad600a --- /dev/null +++ b/examples/generate_database.rs @@ -0,0 +1,107 @@ +use std::path::{Path, PathBuf}; + +use chrono::{NaiveDate, NaiveDateTime}; + +use tavern::{Adventurer, Database, Tale, Tavern}; + + + +fn generate_tavern() -> Tavern +{ + let author: Adventurer = + Adventurer { name: String::from("Jason Smith"), + handle: String::from("myrddin"), + profile: + String::from("https://cybermages.tech/about/myrddin"), + image: + String::from("https://cybermages.tech/about/myrddin/pic"), + blurb: String::from("I love code!") }; + + let tale: Tale = + Tale { title: String::from("Test post"), + slug: String::from("test_post"), + author: author.handle.clone(), + summary: String::from("The Moon is made of cheese!"), + tags: vec![String::from("Space"), String::from("Cheese")], + publish_date: NaiveDate::from_ymd_opt(2025, 12, 25).unwrap().and_hms_opt(13, 10, 41).unwrap(), + content: PathBuf::from("posts/test_post.md") }; + + + // Create a dummy posts directory and file for this example to work + if !Path::new("posts").exists() + { + std::fs::create_dir("posts").unwrap(); + } + std::fs::write("posts/the-rustacean.md", + "# Hello, Rust!\n\nThis is a **test** post.").unwrap(); + + + Tavern { title: String::from("Runes & Ramblings"), + description: String::from("Join software engineer Jason Smith on his Rust programming journey. Explore program design, tech stacks, and more on this blog from CybeMages, LLC."), + tales: vec![tale], + authors: vec![author] } +} + + +fn read_from_file

(config_file: P) -> Tavern + where P: AsRef +{ + // Read the previously written TOML file + let toml_data = + std::fs::read_to_string(&config_file).expect("Failed to read TOML file"); + + // Deserialize it + toml::from_str(&toml_data).expect("Failed to parse TOML") +} + + +fn create_database() -> Result<(), Box> +{ + // This part would be the entry point of your CI/CD script + // It would load your data and then save it to the database + + // Create a Tavern object + let tavern = read_from_file("Tavern.toml"); + //let tavern = generate_tavern(); + + // Open the database and save the Tavern content + let db = Database::open(Path::new("tavern.db"))?; + + db.insert_tavern(&tavern.title, &tavern.description)?; + println!("Saved site settings: Title='{}', Description='{}'", tavern.title, tavern.description); + + for author in &tavern.authors + { + db.insert_adventurer(author)?; + println!("Saved adventurer: {}", author.name); + } + + for tale in &tavern.tales + { + db.insert_tale(tale)?; + println!("Saved tale: {}", tale.title); + } + + Ok(()) +} + +pub fn main() +{ + match std::env::set_current_dir("/home/myrddin/cybermages/blog/") + { + Ok(_) => + { + println!("Successfully changed working directory."); + } + Err(e) => + { + eprintln!("Failed to change directory: {}", e); + } + } + + match create_database() + { + Ok(_) => {} + Err(e) => { eprintln!("Error: {}", e); } + } +} diff --git a/src/adventurer.rs b/src/adventurer.rs index 3f2058d..6136d66 100644 --- a/src/adventurer.rs +++ b/src/adventurer.rs @@ -12,13 +12,16 @@ pub struct Adventurer /// The full name of the adventurer. pub name: String, - /// A unique handle or username for the adventurer (e.g., used in URLs or mentions). + /// A unique handle or username for the adventurer (e.g., used in URLs or + /// mentions). pub handle: String, - /// A link to the adventurer's profile (e.g., personal website, GitHub, etc.). + /// A link to the adventurer's profile (e.g., personal website, GitHub, + /// etc.). pub profile: String, - /// A URL or path to an image representing the adventurer (e.g., avatar or portrait). + /// A URL or path to an image representing the adventurer (e.g., avatar or + /// portrait). pub image: String, /// A short descriptive text or tagline about the adventurer. @@ -27,6 +30,4 @@ pub struct Adventurer -impl Adventurer -{ -} +impl Adventurer {} diff --git a/src/converter.rs b/src/converter.rs new file mode 100644 index 0000000..1f3433b --- /dev/null +++ b/src/converter.rs @@ -0,0 +1,83 @@ +//use std::io::Write; + +use pulldown_cmark::{html, Parser}; + + +pub enum Converter {} + + + +impl Converter +{ + /* + /// Public function to handle file-to-file conversion + pub fn md_to_html(markdown_path: &std::path::Path, + html_path: &std::path::Path) + -> std::io::Result<()> + { + let markdown_content = std::fs::read_to_string(markdown_path)?; + let html_output = Self::markdown_to_html(&markdown_content); + let mut html_file = std::fs::File::create(html_path)?; + + html_file.write_all(html_output.as_bytes())?; + Ok(()) + } + */ + + /// Private function to handle the core conversion logic + pub fn markdown_to_html(markdown_content: &str) -> String + { + let parser = Parser::new(markdown_content); + let mut html_output = String::new(); + html::push_html(&mut html_output, parser); + html_output + } +} + +// tests/integration_test.rs +// This file would be in your project's `tests` directory. +// It tests the public functions of your main program. +// You will also need to add `use crate::*` to access the functions. + +#[cfg(test)] +mod tests +{ + use std::fs; + use std::io::Write; + use std::path::Path; + + use super::*; // Import the parent module's items + + #[test] + fn test_markdown_conversion_with_files() + { + let test_dir = Path::new("temp_test_dir"); + let temp_md_path = test_dir.join("test.md"); + let temp_html_path = test_dir.join("test.html"); + + // Ensure the test directory is clean + if test_dir.exists() + { + fs::remove_dir_all(&test_dir).unwrap(); + } + fs::create_dir(&test_dir).unwrap(); + + // Create a temporary Markdown file for the test + let markdown_content = "# Hello, World!\n\nThis is a **test**."; + let expected_html = + "

Hello, World!

\n

This is a test.

\n"; + + let mut temp_md_file = fs::File::create(&temp_md_path).unwrap(); + temp_md_file.write_all(markdown_content.as_bytes()).unwrap(); + + // Use the new Converter API + Converter::md_to_html(&temp_md_path, &temp_html_path).unwrap(); + let converted_html = fs::read_to_string(&temp_html_path).unwrap(); + + // Assert that the output matches the expected HTML + assert_eq!(converted_html.trim(), expected_html.trim()); + + // Clean up the temporary directory after the test + fs::remove_dir_all(&test_dir).unwrap(); + } +} diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..cb1f046 --- /dev/null +++ b/src/database.rs @@ -0,0 +1,309 @@ +// 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), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 11ef843..f78e40d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,15 +1,19 @@ -//! A blogging system that will allow you to write your blog in Markdown and then display it in HTML using Dioxus. +//! A blogging system that will allow you to write your blog in Markdown and +//! then display it in HTML using Dioxus. mod info; mod adventurer; +#[cfg(feature = "publisher")] +mod converter; +mod database; mod tale; mod tavern; -pub use crate::info::{get_name, get_version}; - pub use crate::adventurer::Adventurer; +pub use crate::database::Database; +pub use crate::info::{get_name, get_version}; pub use crate::tale::Tale; pub use crate::tavern::Tavern; diff --git a/src/tale.rs b/src/tale.rs index be770c3..13faf76 100644 --- a/src/tale.rs +++ b/src/tale.rs @@ -1,18 +1,27 @@ use serde::{Deserialize, Serialize}; +use chrono::NaiveDateTime; + /// A type alias representing the path to a Markdown file. /// This type is used to point to the location of the content of a `Tale`. +#[cfg(feature = "publisher")] pub type Markdown = std::path::PathBuf; -/// Represents a post or story in the application. +/// A type alias representing the HTML content of the tale. +#[cfg(not(feature = "publisher"))] +pub type Markdown = String; + + + +/// Metadata describing a tale. /// -/// A `Tale` contains metadata about the post such as its title, author, -/// and tags, along with a path to its Markdown content. +/// This includes details such as the title, author, summary, and +/// associated tags. #[derive(Deserialize, Serialize)] -pub struct Tale +pub struct FrontMatter { /// The title of the tale. pub title: String, @@ -26,15 +35,23 @@ pub struct Tale /// A short summary or description of the tale. pub summary: String, - /// A list of tags associated with the tale for categorization and searching. + /// A list of tags associated with the tale for categorization and + /// searching. pub tags: Vec, + /// The Date and Time that must elapse before this tale can be told. + pub publish_date: NaiveDateTime +} + + +/// Represents a post or story in the application. +#[derive(Deserialize, Serialize)] +pub struct Tale +{ + /// Metadata of the post. + #[serde(flatten)] + pub front_matter: FrontMatter, + /// The file path to the Markdown content of the tale. pub content: Markdown } - - - -impl Tale -{ -} diff --git a/src/tavern.rs b/src/tavern.rs index 4c6d6ec..0469c14 100644 --- a/src/tavern.rs +++ b/src/tavern.rs @@ -7,11 +7,18 @@ use crate::tale::Tale; /// Represents the entire blog or collection of content. /// -/// A `Tavern` contains a list of all tales (posts) and their respective authors. -/// It serves as the central structure for organizing and serializing the site's content. +/// A `Tavern` contains a list of all tales (posts) and their respective +/// authors. It serves as the central structure for organizing and serializing +/// the site's content. #[derive(Deserialize, Serialize)] pub struct Tavern { + /// + pub title: String, + + /// + pub description: String, + /// A list of all published tales (posts) in the tavern. pub tales: Vec, diff --git a/tests/serde.rs b/tests/serde.rs index 2edc6a5..e08006b 100644 --- a/tests/serde.rs +++ b/tests/serde.rs @@ -1,31 +1,36 @@ +use chrono::{NaiveDate, NaiveDateTime}; + use tavern::{Adventurer, Tale, Tavern}; fn generate_tavern() -> Tavern { - let author: Adventurer = Adventurer - { - name: String::from("Jason Smith"), - handle: String::from("myrddin"), - profile: String::from("https://cybermages.tech/about/myrddin"), - image: String::from("https://cybermages.tech/about/myrddin/pic"), - blurb: String::from("I love code!") - }; + let author: Adventurer = + Adventurer { name: String::from("Jason Smith"), + handle: String::from("myrddin"), + profile: + String::from("https://cybermages.tech/about/myrddin"), + image: + String::from("https://cybermages.tech/about/myrddin/pic"), + blurb: String::from("I love code!") }; - let tale: Tale = Tale - { - title: String::from("Test post"), - slug: String::from("test_post"), - author: author.handle.clone(), - summary: String::from("The Moon is made of cheese!"), - tags: vec![String::from("Space"), String::from("Cheese")], - content: std::path::PathBuf::from("posts/test_post.md") - }; + let tale: Tale = + Tale { title: String::from("Test post"), + slug: String::from("test_post"), + author: author.handle.clone(), + summary: String::from("The Moon is made of cheese!"), + tags: vec![String::from("Space"), String::from("Cheese")], + publish_date: NaiveDate::from_ymd_opt(2025, 12, 25).unwrap().and_hms_opt(13, 10, 41).unwrap(), + content: std::path::PathBuf::from("posts/test_post.md") }; - Tavern { tales: vec![tale], authors: vec![author]} + Tavern { title: String::from("Runes & Ramblings"), + description: String::from("Join software engineer Jason Smith on his Rust programming journey. Explore program design, tech stacks, and more on this blog from CybeMages, LLC."), + tales: vec![tale], + authors: vec![author] } } + fn cleanup_temp_file(path: &std::path::PathBuf) { if path.exists() @@ -40,14 +45,14 @@ fn to_file() { let tavern = generate_tavern(); - let toml_string = toml::to_string_pretty(&tavern) - .expect("Serialization to TOML should succeed"); + let toml_string = toml::to_string_pretty(&tavern).expect("Serialization \ + to TOML should \ + succeed"); // Save the TOML to a temporary file. let mut path = std::env::temp_dir(); path.push("tavern_test_out.toml"); - std::fs::write(&path, &toml_string) - .expect("Failed to write TOML to file"); + std::fs::write(&path, &toml_string).expect("Failed to write TOML to file"); cleanup_temp_file(&path); } @@ -57,22 +62,22 @@ fn from_file() { let tavern = generate_tavern(); - let toml_string = toml::to_string_pretty(&tavern) - .expect("Serialization to TOML should succeed"); + let toml_string = toml::to_string_pretty(&tavern).expect("Serialization \ + to TOML should \ + succeed"); // Save the TOML to a temporary file. let mut path = std::env::temp_dir(); path.push("tavern_test_in.toml"); - std::fs::write(&path, &toml_string) - .expect("Failed to write TOML to file"); + std::fs::write(&path, &toml_string).expect("Failed to write TOML to file"); // Read the previously written TOML file - let toml_data = std::fs::read_to_string(&path) - .expect("Failed to read TOML file"); + let toml_data = + std::fs::read_to_string(&path).expect("Failed to read TOML file"); // Deserialize it - let tavern: Tavern = toml::from_str(&toml_data) - .expect("Failed to parse TOML"); + let tavern: Tavern = + toml::from_str(&toml_data).expect("Failed to parse TOML"); // Assert some known values to make this a real test let tale = &tavern.tales[0];