Print stats

This commit is contained in:
MasseR committed 2026-10-06 20:14:43 +03:00
1 parent ef2074f65b
commit d38be55598
9 files changed
+296 -6

No files matched your search

Generated
+123
View File
@@ -11,6 +11,15 @@ dependencies = [
"memchr", "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]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.104" version = "1.0.104"
@@ -23,6 +32,12 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]] [[package]]
name = "axum" name = "axum"
version = "0.8.9" version = "0.8.9"
@@ -130,6 +145,20 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" 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]] [[package]]
name = "cmov" name = "cmov"
version = "0.5.4" version = "0.5.4"
@@ -142,6 +171,12 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.3.1" version = "0.3.1"
@@ -420,6 +455,30 @@ dependencies = [
"tower-service", "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]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -528,6 +587,15 @@ dependencies = [
"windows-sys", "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]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
@@ -641,6 +709,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
"chrono",
"dotenvy", "dotenvy",
"hmac", "hmac",
"secrecy", "secrecy",
@@ -665,6 +734,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"chrono",
"fallible-iterator", "fallible-iterator",
"fallible-streaming-iterator", "fallible-streaming-iterator",
"hashlink", "hashlink",
@@ -1155,12 +1225,65 @@ dependencies = [
"unicode-ident", "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]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
+2 -1
View File
@@ -6,6 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
anyhow = { version = "1.0.104", features = ["backtrace"] } anyhow = { version = "1.0.104", features = ["backtrace"] }
axum = { version = "0.8.9", features = ["macros"] } axum = { version = "0.8.9", features = ["macros"] }
chrono = { version = "0.4.45", features = ["serde"] }
dotenvy = "0.15.7" dotenvy = "0.15.7"
hmac = "0.13.0" hmac = "0.13.0"
secrecy = { version = "0.10.3", features = ["serde"] } secrecy = { version = "0.10.3", features = ["serde"] }
@@ -15,7 +16,7 @@ sha2 = "0.11.0"
tempfile = "3.27.0" tempfile = "3.27.0"
thiserror = "2.0.21" thiserror = "2.0.21"
tokio = { version = "1.53.2", features = ["full"] } 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 = { version = "0.5.3", features = ["tracing"] }
tower-http = { version = "0.7.1", features = ["trace", "request-id"] } tower-http = { version = "0.7.1", features = ["trace", "request-id"] }
tracing = "0.1.44" tracing = "0.1.44"
+1
View File
@@ -1,4 +1,5 @@
pub mod users; pub mod users;
pub mod syncs; pub mod syncs;
pub mod stats;
+27
View File
@@ -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<AppState> {
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<AppState>, AuthUser{username} : AuthUser) -> Result<Json<Vec<ResponseContribution>>, 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))
}
+4 -2
View File
@@ -2,12 +2,13 @@ use std::{path::Path, sync::Arc};
use axum::extract::FromRef; 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)] #[derive(Clone, FromRef)]
pub struct AppState { pub struct AppState {
pub users: Users, pub users: Users,
pub sync: ProgressSync, pub sync: ProgressSync,
pub stats: Stats,
} }
impl AppState { impl AppState {
@@ -16,7 +17,8 @@ impl AppState {
let reader = Arc::new(Reader::new(&db_path).await?); let reader = Arc::new(Reader::new(&db_path).await?);
let users = Users::new(writer.clone(), reader.clone(), config.server_secret.clone()); let users = Users::new(writer.clone(), reader.clone(), config.server_secret.clone());
let sync = ProgressSync::new(writer.clone(), reader.clone()); let sync = ProgressSync::new(writer.clone(), reader.clone());
Ok(Self{users, sync}) let stats = Stats::new(reader.clone());
Ok(Self{users, sync, stats})
} }
} }
+9 -1
View File
@@ -1,7 +1,7 @@
use axum::{Json, http::StatusCode, response::IntoResponse}; use axum::{Json, http::StatusCode, response::IntoResponse};
use thiserror::Error; 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<SyncError> for AppError {
} }
} }
impl From<StatsError> for AppError {
fn from(value: StatsError) -> Self {
match value {
StatsError::DatabaseFailure(error) => Self::Internal(error.into()),
}
}
}
impl IntoResponse for AppError { impl IntoResponse for AppError {
fn into_response(self) -> axum::response::Response { fn into_response(self) -> axum::response::Response {
let (status, error_code, msg) = match self { let (status, error_code, msg) = match self {
+3 -2
View File
@@ -1,5 +1,5 @@
use axum::{Json, Router, response::{IntoResponse as _, Response}, routing}; 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 tower_http::trace::TraceLayer;
use tracing::level_filters::LevelFilter; use tracing::level_filters::LevelFilter;
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _}; 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] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
let level_filter = EnvFilter::builder() let level_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::DEBUG.into()) .with_default_directive(LevelFilter::INFO.into())
.from_env_lossy(); .from_env_lossy();
tracing_subscriber::registry() tracing_subscriber::registry()
.with(fmt::layer()) .with(fmt::layer())
@@ -25,6 +25,7 @@ async fn main() -> anyhow::Result<()> {
.route("/healthcheck", routing::get(healthcheck)) .route("/healthcheck", routing::get(healthcheck))
.nest("/users", users::users_routes()) .nest("/users", users::users_routes())
.nest("/syncs", syncs::syncs_routes()) .nest("/syncs", syncs::syncs_routes())
.nest("/stats", stats::stats_routes())
.with_state(app_state) .with_state(app_state)
.layer(TraceLayer::new_for_http()); .layer(TraceLayer::new_for_http());
let address = format!("0.0.0.0:{}", config.port); let address = format!("0.0.0.0:{}", config.port);
+1
View File
@@ -1,2 +1,3 @@
pub mod progress_sync; pub mod progress_sync;
pub mod users; pub mod users;
pub mod stats;
+126
View File
@@ -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<Reader>,
}
// 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<Reader>) -> Self {
Self{reader}
}
pub async fn get_contributions(&self, username : &str) -> Result<Vec<Contribution>, StatsError> {
let username = username.to_owned();
let cs = self.reader.connection.call(move |conn| -> rusqlite::Result<Vec<Contribution>> {
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::<rusqlite::Result<Vec<_>>>()?;
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(())
}
}