mod config; mod firefly; mod receipts; use rocket::form::Form; use rocket::fs::{FileServer, TempFile}; use rocket::http::Status; use rocket::response::Redirect; use rocket::tokio::io::{AsyncReadExt, BufReader}; use rocket::State; use rocket_db_pools::Database as RocketDatabase; use rocket_dyn_templates::{context, Template}; use serde::Serialize; use tracing::{debug, error}; use config::Config; use firefly::{ Firefly, TransactionRead, TransactionSplitUpdate, TransactionUpdate, }; struct Context { #[allow(dead_code)] config: Config, firefly: Firefly, } #[derive(Debug, thiserror::Error)] enum InitError { #[error("Invalid config: {0}")] Config(std::io::Error), } impl Context { pub fn init(config: Config) -> Result { let token = std::fs::read_to_string(&config.firefly.token) .map_err(InitError::Config)?; let firefly = Firefly::new(&config.firefly.url, &token); Ok(Self { config, firefly }) } } #[derive(RocketDatabase)] #[database("receipts")] struct Database(rocket_db_pools::sqlx::PgPool); #[derive(Serialize)] pub struct Transaction { pub id: String, pub amount: f64, pub description: String, pub date: String, } #[derive(rocket::FromForm)] struct TransactionPostData<'r> { amount: f64, notes: String, photo: Vec>, } impl TryFrom for Transaction { type Error = &'static str; fn try_from(t: TransactionRead) -> Result { let first_split = match t.attributes.transactions.first() { Some(t) => t, None => { error!("Invalid transaction {}: no splits", t.id); return Err("Transaction has no splits"); }, }; let date = first_split.date; let amount = t.amount(); let description = if let Some(title) = &t.attributes.group_title { title.into() } else { first_split.description.clone() }; Ok(Self { id: t.id, amount, description, date: date.format("%A, %_d %B %Y").to_string(), }) } } #[rocket::get("/")] async fn index() -> Redirect { Redirect::to(rocket::uri!(transaction_list())) } #[rocket::get("/transactions")] async fn transaction_list(ctx: &State) -> (Status, Template) { let result = ctx .firefly .search_transactions(&ctx.config.firefly.search_query) .await; match result { Ok(r) => { let transactions: Vec<_> = r .data .into_iter() .filter_map(|t| match Transaction::try_from(t) { Ok(t) => Some(t), Err(e) => { error!("Error parsing transaction details: {}", e); None }, }) .collect(); ( Status::Ok, Template::render( "transaction-list", context! { transactions: transactions, }, ), ) }, Err(e) => { error!("Error fetching transaction list: {}", e); ( Status::InternalServerError, Template::render( "error", context! { error: "Failed to fetch transaction list from Firefly III", }, ), ) }, } } #[rocket::get("/transactions/")] async fn get_transaction(id: &str, ctx: &State) -> Option