Add MVP
This commit is contained in:
2
src/database/mod.rs
Normal file
2
src/database/mod.rs
Normal file
@ -0,0 +1,2 @@
|
||||
pub mod unversioned;
|
||||
pub mod v1;
|
||||
6
src/database/unversioned.rs
Normal file
6
src/database/unversioned.rs
Normal 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
46
src/database/v1.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
47
src/main.rs
47
src/main.rs
@ -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
126
src/routes.rs
Normal 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
86
src/status_json.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user