This commit is contained in:
2020-10-27 01:41:50 +01:00
parent c709ca2166
commit 11aa131186
16 changed files with 2706 additions and 5 deletions

2
src/database/mod.rs Normal file
View File

@ -0,0 +1,2 @@
pub mod unversioned;
pub mod v1;

View File

@ -0,0 +1,6 @@
pub mod global {
pub mod schema_version {
pub const K: &str = "SCHEMA_VERSION";
pub type V = (u32, u32, u32);
}
}

46
src/database/v1.rs Normal file
View File

@ -0,0 +1,46 @@
pub(self) use chrono::NaiveDateTime;
pub(self) use serde_derive::{Serialize, Deserialize};
pub(self) use uuid::Uuid;
/// Stuff in the default namespace
pub mod global {
}
pub mod trees {
pub mod categories {
use super::super::*;
pub const NAME: &str = "CATEGORIES";
pub type K = Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct V {
/// The name of the category
pub name: String,
/// The color of the button in the rendered view
pub color: String,
/// If the session is not active, this will be None
pub started: Option<NaiveDateTime>,
}
}
pub mod past_sessions {
use super::super::*;
pub const NAME: &str = "PAST_SESSIONS";
pub type K = Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct V {
pub category: trees::categories::K,
pub started: NaiveDateTime,
pub ended: NaiveDateTime,
}
}
}

View File

@ -1,3 +1,46 @@
fn main() {
println!("Hello, world!");
#![feature(decl_macro)]
mod database;
mod routes;
mod status_json;
use std::{io, env};
use dotenv::dotenv;
use rocket_contrib::templates::Template;
use rocket_contrib::serve::StaticFiles;
use handlebars::handlebars_helper;
fn main() -> io::Result<()> {
dotenv().ok();
let db_path = env::var("DB_PATH").expect("DB_PATH not set");
let sled = sled::open(db_path)?;
handlebars_helper!(pretty_datetime: |dt: str| {
let date = dt.trim_end_matches(|c| c != 'T').trim_end_matches('T');
let time = dt.trim_start_matches(|c| c != 'T').trim_start_matches('T')
.trim_end_matches(|c| c != ':').trim_end_matches(':');
format!("{} {}", date, time)
});
let rocket = rocket::ignite()
.attach(Template::custom(|engines| {
engines.handlebars.register_helper("pretty_datetime", Box::new(pretty_datetime));
}))
.manage(sled)
.mount("/static", StaticFiles::from("static"))
.mount(
"/",
rocket::routes![
routes::index,
routes::history,
routes::create_category,
routes::toggle_category,
],
);
rocket.launch();
Ok(())
}

126
src/routes.rs Normal file
View File

@ -0,0 +1,126 @@
use serde_derive::{Serialize, Deserialize};
use rocket::{State, get, post, uri};
use uuid::Uuid;
use rocket_contrib::templates::Template;
use crate::database::v1::trees::{categories, past_sessions};
use crate::status_json::StatusJson;
use rocket::http::Status;
use bincode::{serialize, deserialize};
use rocket::response::Redirect;
use rocket::request::{FromForm, Form};
use sled::Transactional;
use chrono::{Duration, Utc};
use std::collections::HashMap;
#[derive(FromForm)]
pub struct NewCategory {
name: String,
color: String,
}
#[post("/create_category", data="<category>")]
pub fn create_category(category: Form<NewCategory>, db: State<'_, sled::Db>) -> Result<Redirect, StatusJson> {
let category = category.into_inner();
let categories_tree = db.open_tree(categories::NAME)?;
categories_tree.insert(serialize(&Uuid::new_v4())?, serialize(&categories::V {
name: category.name,
color: category.color,
started: None,
})?)?;
Ok(Redirect::to(uri!(index)))
}
#[post("/toggle_category/<category_uuid>")]
pub fn toggle_category(category_uuid: String, db: State<'_, sled::Db>) -> Result<Redirect, StatusJson> {
let category_uuid = Uuid::parse_str(&category_uuid)?;
let category_uuid_s = sled::IVec::from(serialize(&category_uuid)?);
let categories_tree = db.open_tree(categories::NAME)?;
let past_sessions_tree = db.open_tree(past_sessions::NAME)?;
Ok((&categories_tree, &past_sessions_tree)
.transaction(|(tx_categories, tx_past_sessions)| {
match tx_categories.get(&category_uuid_s)? {
None => return Ok(Err(Status::NotFound)),
Some(data) => {
let mut category: categories::V = dbg!(deserialize(&data).unwrap());
let now = Utc::now().naive_utc();
match category.started.take() {
Some(started) => {
// only save sessions longer than 5 minutes
let duration = now - started;
if duration > Duration::minutes(5) {
let session_uuid = serialize(&Uuid::new_v4()).unwrap();
let past_session = past_sessions::V {
category: category_uuid,
started,
ended: now,
};
tx_past_sessions.insert(session_uuid, serialize(&past_session).unwrap())?;
}
}
None => {
category.started = Some(now);
}
}
tx_categories.insert(&category_uuid_s, serialize(&category).unwrap())?;
}
}
Ok(Ok(Redirect::to(uri!(index))))
})??)
}
#[get("/")]
pub fn index(db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
#[derive(Debug, Serialize, Deserialize)]
struct TemplateContext {
categories: Vec<(Uuid, categories::V)>,
}
let categories_tree = db.open_tree(categories::NAME)?;
let context = TemplateContext {
categories: categories_tree.iter()
.map(|result| result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v)))))
.collect::<Result<Result<_, _>, _>>()??,
};
Ok(Template::render("index", dbg!(&context)))
}
#[get("/history")]
pub fn history(db: State<'_, sled::Db>) -> Result<Template, StatusJson> {
#[derive(Debug, Serialize, Deserialize)]
struct TemplateContext {
sessions: Vec<(categories::V, past_sessions::V)>,
}
let categories_tree = db.open_tree(categories::NAME)?;
let past_sessions_tree = db.open_tree(past_sessions::NAME)?;
let categories: HashMap<categories::K, categories::V> = categories_tree.iter()
.map(|result| result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v)))))
.collect::<Result<Result<_, _>, _>>()??;
let past_sessions: HashMap<past_sessions::K, past_sessions::V> = past_sessions_tree.iter()
.map(|result| result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v)))))
.collect::<Result<Result<_, _>, _>>()??;
let context = TemplateContext {
sessions: past_sessions.into_iter()
.map(|(_, session)| {
let category = categories.get(&session.category).unwrap().clone();
(category, session)
})
.collect(),
};
Ok(Template::render("history", dbg!(&context)))
}

86
src/status_json.rs Normal file
View File

@ -0,0 +1,86 @@
use duplicate::duplicate;
use log::{info, warn};
use rocket::http::Status;
use rocket::response::{Responder, Response};
use rocket::Request;
use rocket_contrib::json;
use rocket_contrib::json::Json; // macro
/// An error message which can be serialized as JSON.
///
/// #### Example JSON
/// ```json
/// {
/// "status": 404,
/// "description": "Not Found"
/// }
/// ```
#[derive(Debug, Clone)]
pub struct StatusJson {
pub status: Status,
pub description: String,
}
impl StatusJson {
pub fn new<S: ToString>(status: Status, description: S) -> Self {
StatusJson {
status,
description: description.to_string(),
}
}
pub fn describe<S: ToString>(mut self, description: S) -> Self {
self.description = description.to_string();
self
}
}
impl<'r> Responder<'r> for StatusJson {
fn respond_to(self, req: &Request) -> Result<Response<'r>, Status> {
if self.status.code >= 400 {
warn!(
"Responding with status {}.\n\
Description: {}",
self.status, self.description,
);
} else {
info!("Responding with status {}", self.status);
}
let mut response = Json(json!({
"status": self.status.code,
"description": self.description,
}))
.respond_to(req)?;
response.set_status(self.status);
Ok(response)
}
}
#[duplicate(
status_code T;
[ Status::InternalServerError ] [ sled::Error ];
[ Status::InternalServerError ] [ sled::transaction::TransactionError ];
[ Status::InternalServerError ] [ bincode::Error ];
[ Status::BadRequest ] [ uuid::Error ];
)]
impl From<T> for StatusJson {
fn from(e: T) -> StatusJson {
StatusJson {
status: status_code,
description: e.to_string(),
}
}
}
impl From<Status> for StatusJson {
fn from(status: Status) -> StatusJson {
StatusJson {
description: status.reason.to_string(),
status,
}
}
}