diff --git a/Cargo.lock b/Cargo.lock index 717b6e1..1ee55ac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anyhow" version = "1.0.104" @@ -23,6 +32,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "axum" version = "0.8.9" @@ -130,6 +145,20 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "cmov" version = "0.5.4" @@ -142,6 +171,12 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.3.1" @@ -420,6 +455,30 @@ dependencies = [ "tower-service", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +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 = "itoa" version = "1.0.18" @@ -528,6 +587,15 @@ dependencies = [ "windows-sys", ] +[[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.4" @@ -641,6 +709,7 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "chrono", "dotenvy", "hmac", "secrecy", @@ -665,6 +734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ "bitflags", + "chrono", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -1155,12 +1225,65 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index 94c48a2..8014bcc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] anyhow = { version = "1.0.104", features = ["backtrace"] } axum = { version = "0.8.9", features = ["macros"] } +chrono = { version = "0.4.45", features = ["serde"] } dotenvy = "0.15.7" hmac = "0.13.0" secrecy = { version = "0.10.3", features = ["serde"] } @@ -15,7 +16,7 @@ sha2 = "0.11.0" tempfile = "3.27.0" thiserror = "2.0.21" tokio = { version = "1.53.2", features = ["full"] } -tokio-rusqlite = { version = "0.8.0", features = ["bundled", "uuid"] } +tokio-rusqlite = { version = "0.8.0", features = ["bundled", "uuid", "chrono"] } tower = { version = "0.5.3", features = ["tracing"] } tower-http = { version = "0.7.1", features = ["trace", "request-id"] } tracing = "0.1.44" diff --git a/src/api/mod.rs b/src/api/mod.rs index 70fe030..ee85cfd 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,4 +1,5 @@ pub mod users; pub mod syncs; +pub mod stats; diff --git a/src/api/stats.rs b/src/api/stats.rs new file mode 100644 index 0000000..02cbd07 --- /dev/null +++ b/src/api/stats.rs @@ -0,0 +1,27 @@ +use axum::{Json, Router, extract::State, routing}; +use chrono::NaiveDate; +use serde::Serialize; + +use crate::{app::AppState, app_error::AppError, auth_user::AuthUser}; + +pub fn stats_routes() -> Router { + Router::new() + .route("/contributions", routing::get(get_contributions)) +} + + +#[derive(Debug, Serialize)] +pub struct ResponseContribution { + date: NaiveDate, + document: String, + progress: f32, +} + +#[tracing::instrument(skip(st))] +pub async fn get_contributions(State(st) : State, AuthUser{username} : AuthUser) -> Result>, AppError> { + let cs = st.stats + .get_contributions(&username).await? + .into_iter().map(|c| ResponseContribution{date: c.date, document: c.document_id, progress: c.progress}) + .collect(); + Ok(Json(cs)) +} diff --git a/src/app.rs b/src/app.rs index c209c70..0f185d1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -2,12 +2,13 @@ use std::{path::Path, sync::Arc}; use axum::extract::FromRef; -use crate::{config::Config, reader::Reader, repository::{progress_sync::ProgressSync, users::Users}, writer::Writer}; +use crate::{config::Config, reader::Reader, repository::{progress_sync::ProgressSync, stats::Stats, users::Users}, writer::Writer}; #[derive(Clone, FromRef)] pub struct AppState { pub users: Users, pub sync: ProgressSync, + pub stats: Stats, } impl AppState { @@ -16,7 +17,8 @@ impl AppState { let reader = Arc::new(Reader::new(&db_path).await?); let users = Users::new(writer.clone(), reader.clone(), config.server_secret.clone()); let sync = ProgressSync::new(writer.clone(), reader.clone()); - Ok(Self{users, sync}) + let stats = Stats::new(reader.clone()); + Ok(Self{users, sync, stats}) } } diff --git a/src/app_error.rs b/src/app_error.rs index 9397c80..58f3079 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -1,7 +1,7 @@ use axum::{Json, http::StatusCode, response::IntoResponse}; use thiserror::Error; -use crate::repository::{progress_sync::SyncError, users::UserError}; +use crate::repository::{progress_sync::SyncError, stats::StatsError, users::UserError}; @@ -36,6 +36,14 @@ impl From for AppError { } } +impl From for AppError { + fn from(value: StatsError) -> Self { + match value { + StatsError::DatabaseFailure(error) => Self::Internal(error.into()), + } + } +} + impl IntoResponse for AppError { fn into_response(self) -> axum::response::Response { let (status, error_code, msg) = match self { diff --git a/src/main.rs b/src/main.rs index be66a06..bcd9f30 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use axum::{Json, Router, response::{IntoResponse as _, Response}, routing}; -use rukosync::{api::{syncs, users}, app::AppState, config::Config, }; +use rukosync::{api::{stats, syncs, users}, app::AppState, config::Config, }; use tower_http::trace::TraceLayer; use tracing::level_filters::LevelFilter; use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _}; @@ -8,7 +8,7 @@ use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt as _, util::Subscr #[tokio::main] async fn main() -> anyhow::Result<()> { let level_filter = EnvFilter::builder() - .with_default_directive(LevelFilter::DEBUG.into()) + .with_default_directive(LevelFilter::INFO.into()) .from_env_lossy(); tracing_subscriber::registry() .with(fmt::layer()) @@ -25,6 +25,7 @@ async fn main() -> anyhow::Result<()> { .route("/healthcheck", routing::get(healthcheck)) .nest("/users", users::users_routes()) .nest("/syncs", syncs::syncs_routes()) + .nest("/stats", stats::stats_routes()) .with_state(app_state) .layer(TraceLayer::new_for_http()); let address = format!("0.0.0.0:{}", config.port); diff --git a/src/repository/mod.rs b/src/repository/mod.rs index 069772c..ac4a636 100644 --- a/src/repository/mod.rs +++ b/src/repository/mod.rs @@ -1,2 +1,3 @@ pub mod progress_sync; pub mod users; +pub mod stats; diff --git a/src/repository/stats.rs b/src/repository/stats.rs new file mode 100644 index 0000000..ed215dc --- /dev/null +++ b/src/repository/stats.rs @@ -0,0 +1,126 @@ +use std::sync::Arc; + +use chrono::NaiveDate; +use thiserror::Error; +use tokio_rusqlite::{named_params, rusqlite}; + +use crate::reader::Reader; + +#[derive(Clone)] +pub struct Stats { + reader: Arc, +} + +// Github style contributions +#[derive(Debug, PartialEq)] +pub struct Contribution { + pub date: NaiveDate, + pub start_percentage: f32, + pub end_percentage: f32, + pub progress: f32, + pub document_id: String, +} + +#[derive(Error, Debug)] +pub enum StatsError { + #[error("Database call failed {0}")] + DatabaseFailure(#[from] tokio_rusqlite::Error) +} + +impl Stats { + pub fn new(reader: Arc) -> Self { + Self{reader} + } + + pub async fn get_contributions(&self, username : &str) -> Result, StatsError> { + let username = username.to_owned(); + let cs = self.reader.connection.call(move |conn| -> rusqlite::Result> { + let mut stmt = conn.prepare("select + date(progress.timestamp, 'unixepoch') as day, + progress.document_id, + min(progress.percentage) as start_percentage, + max(progress.percentage) as end_percentage, + max(progress.percentage) - min(progress.percentage) as progress + from progress + join users on users.id = progress.user_id + where users.username = :username + group by day, progress.document_id + order by day")?; + let x = stmt.query_map(named_params!{":username": username}, |row| { + let date = row.get(0)?; + let document_id = row.get(1)?; + let start_percentage = row.get(2)?; + let end_percentage = row.get(3)?; + let progress = row.get(4)?; + Ok(Contribution{date, start_percentage, end_percentage, progress, document_id}) + })?.collect::>>()?; + Ok(x) + }).await?; + Ok(cs) + } +} + +#[cfg(test)] +mod tests { + +use secrecy::SecretString; +use tempfile::{TempDir, tempdir}; + +use crate::{reader::Reader, repository::{progress_sync::{Document, ProgressSync}, stats::Stats, users::Users}, writer::Writer}; + use super::*; + + const USERNAME : &str = "foo@example.com"; + + async fn create_repositories() -> anyhow::Result<(TempDir, ProgressSync, Stats)> { + let temp_dir = tempdir()?; + let path = temp_dir.path().join("kosync.db"); + let writer = Arc::new(Writer::new(&path).await?); + let reader = Arc::new(Reader::new(&path).await?); + let users = Users::new(writer.clone(), reader.clone(), SecretString::from("secret")); + let sync = ProgressSync::new(writer.clone(), reader.clone()); + let stats = Stats::new(reader.clone()); + users.create_user(USERNAME, SecretString::from("password")).await?; + Ok((temp_dir, sync, stats)) + } + + #[tokio::test] + async fn test_empty_progress() -> anyhow::Result<()> { + let (_t, _, stats) = create_repositories().await?; + let contributions = stats.get_contributions(USERNAME).await?; + assert_eq!(contributions, vec![]); + Ok(()) + } + + fn now() -> i64 { + chrono::Utc::now().timestamp() + } + + #[tokio::test] + async fn test_single_read() -> anyhow::Result<()> { + let (_t, sync, stats) = create_repositories().await?; + let document_id = "foo".to_string(); + let doc1 = Document { + document_id: document_id.clone(), + progress: "prog".to_string(), + percentage: 0.5, + device: "kobo".to_string(), + timestamp: now(), + filename: Some("foo.epub".to_string()), + title: Some("A Book".to_string()), + author: Some("A.N. Author".to_string()), + ..Document::default() + }; + sync.set_progress(USERNAME, &doc1).await?; + let contributions = stats.get_contributions(USERNAME).await?; + let today = chrono::Utc::now().date_naive(); + let wanted = Contribution { + date: today, + start_percentage: 0.5, + end_percentage: 0.5, + progress: 0.0, + document_id: document_id.clone() + }; + assert_eq!(contributions, vec![wanted]); + Ok(()) + } +}