327 lines
10 KiB
Rust
327 lines
10 KiB
Rust
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<RssArticleD>,
|
|
}
|
|
|
|
pub struct RssArticleD {
|
|
pub id: i64,
|
|
pub channel_id: i64,
|
|
pub title: String,
|
|
pub description: String,
|
|
pub link: String,
|
|
pub pub_date: Option<OffsetDateTime>,
|
|
pub last_update: Option<OffsetDateTime>,
|
|
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<Self> {
|
|
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<RssChannelD> {
|
|
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<RssChannelD> {
|
|
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<usize> {
|
|
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<Vec<RssChannelD>> {
|
|
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::<Vec<RssChannelD>>();
|
|
|
|
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<usize> {
|
|
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<usize> {
|
|
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<RssArticleD> {
|
|
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<RssArticleD> {
|
|
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<Vec<RssArticleD>> {
|
|
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::<Vec<RssArticleD>>();
|
|
|
|
Ok(articles)
|
|
}
|
|
|
|
fn list_articles(&self) -> Result<Vec<RssArticleD>> {
|
|
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::<Vec<RssArticleD>>();
|
|
|
|
Ok(articles)
|
|
}
|
|
|
|
fn map_rsschanneld(row: &rusqlite::Row) -> std::result::Result<RssChannelD, rusqlite::Error> {
|
|
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<RssArticleD, rusqlite::Error> {
|
|
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<OffsetDateTime, rusqlite::Error> {
|
|
row.get::<usize, i64>(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<RssArticleD>,
|
|
) -> 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,
|
|
}
|
|
}
|
|
}
|