use std::env; use rusqlite::Connection; use time::OffsetDateTime; use crate::error::Result; use crate::error::TrsError; use crate::parser::RssArticle; use crate::parser::RssChannel; const SCHEMA_CHANNELS: &'static str = "CREATE TABLE IF NOT EXISTS Channels ( \ id INTEGER PRIMARY KEY, \ name TEXT NOT NULL, \ link TEXT NOT NULL UNIQUE, \ description TEXT, \ last_update INTEGER\ )"; const SCHEMA_ARTICLES: &'static str = "CREATE TABLE IF NOT EXISTS Articles ( \ id INTEGER PRIMARY KEY, \ channel_id INTEGER NOT NULL, \ title TEXT NOT NULL, \ description TEXT, \ link TEXT NOT NULL UNIQUE, \ pub_date INTEGER, \ last_update INTEGER , \ unread BOOLEAN DEFAULT TRUE, \ FOREIGN KEY(channel_id) REFERENCES Channels(id) ON DELETE CASCADE \ )"; const ADD_CHANNEL: &'static str = "INSERT INTO Channels (name, link, description, last_update) \ VALUES (?1, ?2, ?3, ?4)\ ON CONFLICT(link) DO UPDATE SET name=?1, description=?3, last_update=?4"; const REMOVE_CHANNEL: &'static str = "DELETE FROM Channels WHERE id = ?1"; const LIST_CHANNELS: &'static str = "SELECT id, name, link, description, last_update FROM Channels LIMIT ?1"; const GET_CHANNEL: &'static str = "SELECT id, name, link, description, last_update FROM Channels WHERE link = ?1"; const ADD_ARTICLE: &'static str = "INSERT INTO Articles (channel_id, title, description, link, pub_date, last_update, unread) \ VALUES (?1, ?2, ?3, ?4, ?5, ?6, true) \ ON CONFLICT(link) DO UPDATE SET last_update=?6"; const GET_ARTICLES_BY_CHANNEL: &'static str = "SELECT id, channel_id, title, description, link, pub_date, last_update, unread FROM Articles WHERE channel_id = ?1"; const GET_ARTICLE: &'static str = "SELECT id, channel_id, title, description, link, pub_date, last_update, unread FROM Articles WHERE link = ?1"; const LIST_ARTICLES: &'static str = "SELECT id, channel_id, title, description, link, pub_date, last_update, unread FROM Articles"; const MARK_ARTICLE_READ: &'static str = "UPDATE Articles SET unread = FALSE WHERE id = ?1"; const MARK_ARTICLE_UNREAD: &'static str = "UPDATE Articles SET unread = TRUE WHERE id = ?1"; pub struct Db { connection: Connection, } pub struct RssChannelD { pub id: i64, pub title: String, pub link: String, pub description: String, pub last_update: OffsetDateTime, pub articles: Vec, } pub struct RssArticleD { pub id: i64, pub channel_id: i64, pub title: String, pub description: String, pub link: String, pub pub_date: Option, pub last_update: Option, pub unread: bool, } macro_rules! schema_sql { ($conn:expr, $sql:expr) => { $conn .execute($sql, ()) .map_err(|e| TrsError::SqlError(e, "Failed to create Channels table".to_string()))?; }; } impl Db { pub fn create(instance_name: &str) -> Result { let home_dir = env::home_dir(); let db_dir = home_dir .map(|dir| dir.join(".config").join("trs")) .ok_or(TrsError::Error( "Unable to determine home directory".to_string(), ))?; match std::fs::create_dir_all(&db_dir) { Ok(_) => {} Err(e) => { return Err(TrsError::Error(format!( "Failed to create database directory: {}", e ))); } } let db_file = db_dir.join(format!("{}.db", instance_name)); let connection = Connection::open(db_file)?; schema_sql!(connection, SCHEMA_CHANNELS); schema_sql!(connection, SCHEMA_ARTICLES); Ok(Db { connection }) } pub fn get_channel(&self, link: &str) -> Result { let mut channel = self .connection .query_row(GET_CHANNEL, (link,), Db::map_rsschanneld) .map_err(|e| { TrsError::SqlError(e, format!("Failed to retrieve channel with link {}", link)) })?; channel.articles = self.list_articles_by_channel(channel.id)?; Ok(channel) } pub fn add_channel(&self, channel: &RssChannel) -> Result { self.connection .execute( ADD_CHANNEL, ( &channel.title, &channel.link, &channel.description, OffsetDateTime::now_utc().unix_timestamp(), ), ) .map_err(|e| TrsError::SqlError(e, "Failed to add channel".to_string()))?; let mut inserted_channel = self.get_channel(&channel.link).map_err(|e| { TrsError::Error(format!("Failed to retrieve channel after adding: {}", e)) })?; let mut articles = Vec::new(); for article in &channel.articles { let article = self.add_article(inserted_channel.id, article)?; articles.push(article); } inserted_channel.articles = articles; Ok(inserted_channel) } pub fn remove_channel(&self, id: u32) -> Result { self.connection .execute(REMOVE_CHANNEL, (id,)) .map(|rows| rows as usize) .map_err(|e| TrsError::SqlError(e, "Failed to remove channel".to_string())) } pub fn list_channels(&self, limit: u32) -> Result> { let mut channels = self .connection .prepare(LIST_CHANNELS) .map_err(|e| TrsError::SqlError(e, "Failed to prepare query".to_string()))? .query_map((limit as i64,), Db::map_rsschanneld) .map_err(|e| TrsError::SqlError(e, "Failed to list channels".to_string()))? .map(|r| r.unwrap()) .collect::>(); let mut articles = self.list_articles()?; for channel in &mut channels { for article in &mut articles { if article.channel_id == channel.id { let copied = std::mem::replace(article, RssArticleD::dummy()); channel.articles.push(copied); } } } Ok(channels) } pub fn mark_article_read(&self, id: i64) -> Result { self.connection .execute(MARK_ARTICLE_READ, (id,)) .map(|rows| rows as usize) .map_err(|e| TrsError::SqlError(e, "Failed to mark article as read".to_string())) } pub fn mark_article_unread(&self, id: i64) -> Result { self.connection .execute(MARK_ARTICLE_UNREAD, (id,)) .map(|rows| rows as usize) .map_err(|e| TrsError::SqlError(e, "Failed to mark article as unread".to_string())) } fn add_article(&self, channel_id: i64, article: &RssArticle) -> Result { self.connection .execute( ADD_ARTICLE, ( channel_id, &article.title, &article.description, &article.link, article.date.map(|d| d.unix_timestamp()), OffsetDateTime::now_utc().unix_timestamp(), ), ) .map_err(|e| TrsError::SqlError(e, "Failed to add article".to_string()))?; self.get_article(&article.link) .map_err(|e| TrsError::Error(format!("Failed to retrieve article after adding: {}", e))) } fn get_article(&self, link: &str) -> Result { self.connection .query_row(GET_ARTICLE, (link,), Db::map_rssarticled) .map_err(|e| { TrsError::SqlError(e, format!("Failed to retrieve article with link {}", link)) }) } fn list_articles_by_channel(&self, channel_id: i64) -> Result> { let articles = self .connection .prepare(GET_ARTICLES_BY_CHANNEL) .map_err(|e| TrsError::SqlError(e, "Failed to prepare query".to_string()))? .query_map((channel_id,), Db::map_rssarticled) .map_err(|e| TrsError::SqlError(e, "Failed to list articles".to_string()))? .map(|r| r.unwrap()) .collect::>(); Ok(articles) } fn list_articles(&self) -> Result> { let articles = self .connection .prepare(LIST_ARTICLES) .map_err(|e| TrsError::SqlError(e, "Failed to prepare query".to_string()))? .query_map([], Db::map_rssarticled) .map_err(|e| TrsError::SqlError(e, "Failed to list articles".to_string()))? .map(|r| r.unwrap()) .collect::>(); Ok(articles) } fn map_rsschanneld(row: &rusqlite::Row) -> std::result::Result { Ok(RssChannelD::new( row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?, Db::read_datetime(4, &row)?, Vec::new(), )) } fn map_rssarticled(row: &rusqlite::Row) -> std::result::Result { Ok(RssArticleD { id: row.get(0)?, channel_id: row.get(1)?, title: row.get(2)?, description: row.get(3)?, link: row.get(4)?, pub_date: Db::read_datetime(5, row).ok(), last_update: Db::read_datetime(6, row).ok(), unread: row.get(7)?, }) } fn read_datetime( idx: usize, row: &rusqlite::Row, ) -> std::result::Result { row.get::(idx).map(|ts| { OffsetDateTime::from_unix_timestamp(ts).map_err(|e| { rusqlite::Error::FromSqlConversionFailure( idx, rusqlite::types::Type::Integer, Box::new(e), ) }) })? } } impl RssChannelD { fn new( id: i64, title: String, link: String, description: String, last_update: OffsetDateTime, articles: Vec, ) -> Self { RssChannelD { id, title, link, description, last_update, articles, } } } impl RssArticleD { fn dummy() -> Self { RssArticleD { id: -1, channel_id: 0, title: String::new(), description: String::new(), link: String::new(), pub_date: None, last_update: None, unread: false, } } }