diff --git a/Cargo.lock b/Cargo.lock index c1f5e4e..623f326 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1584,7 +1584,7 @@ checksum = "7345c971d1ef21ffdbd103a75990a15eb03604fc8b8852ca8cb418ee1a099028" [[package]] name = "stl" -version = "0.2.0" +version = "2.0.0" dependencies = [ "bincode", "chrono", diff --git a/Cargo.toml b/Cargo.toml index cf29d36..a110565 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stl" description = "studielogg aka scuffed toggl" -version = "0.2.0" +version = "2.0.0" authors = ["Joakim Hulthe "] license = "MPL-2.0" edition = "2018" diff --git a/src/database/migrations/mod.rs b/src/database/migrations/mod.rs new file mode 100644 index 0000000..a7def24 --- /dev/null +++ b/src/database/migrations/mod.rs @@ -0,0 +1,36 @@ +pub mod v1_to_v2; + +use crate::database::unversioned::global::schema_version; +use duplicate::duplicate; +use sled::Db; + +pub fn migrate(db: &mut Db, from: schema_version::V, to: schema_version::V) -> Result<(), MigrationError> { + for current in from..to { + let next = current + 1; + + println!("Will migrate from {} to {}", current, next); + match (current, next) { + (1, 2) => v1_to_v2::migrate(db)?, + _ => panic!("No valid migration from version {} to version {}", current, next), + } + } + + Ok(()) +} + +#[derive(Debug)] +pub enum MigrationError { + Serde(bincode::Error), + Sled(sled::Error), +} + +#[duplicate( + Variant Error; + [ Sled ] [ sled::Error ]; + [ Serde ] [ bincode::Error ]; +)] +impl From for MigrationError { + fn from(e: Error) -> MigrationError { + MigrationError::Variant(e) + } +} diff --git a/src/database/migrations/v1_to_v2.rs b/src/database/migrations/v1_to_v2.rs new file mode 100644 index 0000000..f9d3d3f --- /dev/null +++ b/src/database/migrations/v1_to_v2.rs @@ -0,0 +1,84 @@ +use crate::database::v1; +use crate::database::v2; +use super::MigrationError; +use bincode::{serialize, deserialize}; +use sled::Db; +use chrono::{DateTime, Utc}; + +/// Migrate from database version 1 to version 2 +pub fn migrate(db: &mut Db) -> Result<(), MigrationError> { + + { // Migrate sessions + + // Open old & new trees + let v1_past_sessions = db.open_tree(v1::trees::past_sessions::NAME)?; + let v2_sessions = db.open_tree(v2::trees::sessions::NAME)?; + + // Iterate over old tree + for r in v1_past_sessions.iter() { + let (k, v) = r?; + + // Deserialize old value + let v1::trees::past_sessions::V { + category, + started, + ended + } = deserialize(&v)?; + + // Convert to new value + let v2_value = v2::trees::sessions::V { + category, + started: DateTime::::from_utc(started, Utc).into(), + ended: DateTime::::from_utc(ended, Utc).into(), + deleted: false, + }; + + // Insert into new tree + v2_sessions.insert(k, serialize(&v2_value)?)?; + } + + // Remove old tree + v1_past_sessions.clear()?; + } + + { // Migrate categories + // Open the old tree, and a TMP tree since the old tree name hasn't changed + let categories = db.open_tree(v1::trees::categories::NAME)?; + let v2_categories_tmp = db.open_tree("TEMP")?; + + // Iterate over old tree + for r in categories.iter() { + let (k, v) = r?; + + // Deserialize old value + let v1::trees::categories::V { + name, + color, + started, + } = deserialize(&v)?; + + // Convert to new value + let v2_value = v2::trees::categories::V { + name, + description: None, + color, + started: started.map(|ndt| DateTime::::from_utc(ndt, Utc).into()), + parent: None, + deleted: false, + }; + + // Insert into temporary tree + v2_categories_tmp.insert(k, serialize(&v2_value)?)?; + } + + // Copy data from temp-tree back into old tree + categories.clear()?; + for r in v2_categories_tmp.iter() { + let (k, v) = r?; + categories.insert(k ,v)?; + } + v2_categories_tmp.clear()?; + } + + Ok(()) +} diff --git a/src/database/mod.rs b/src/database/mod.rs index 2678305..93559e2 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,5 +1,8 @@ pub mod unversioned; pub mod v1; +pub mod v2; -pub const SCHEMA_VERSION: unversioned::global::schema_version::V = 1; -pub use self::v1 as latest; +pub mod migrations; + +pub const SCHEMA_VERSION: unversioned::global::schema_version::V = 2; +pub use v2 as latest; diff --git a/src/database/v1.rs b/src/database/v1.rs index abb0e00..6efcded 100644 --- a/src/database/v1.rs +++ b/src/database/v1.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] // old schema, not used anymore + pub(self) use chrono::NaiveDateTime; pub(self) use serde_derive::{Deserialize, Serialize}; pub(self) use uuid::Uuid; @@ -18,7 +20,7 @@ pub mod trees { /// The name of the category pub name: String, - /// The color of the button in the rendered view + /// The HTML color of the category in the rendered view pub color: String, /// If the session is not active, this will be None diff --git a/src/database/v2.rs b/src/database/v2.rs new file mode 100644 index 0000000..bb840c5 --- /dev/null +++ b/src/database/v2.rs @@ -0,0 +1,64 @@ +pub(self) use chrono::{DateTime, Local}; +pub(self) use serde_derive::{Deserialize, Serialize}; +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 description of the category + pub description: Option, + + /// The HTML color of the category in the rendered view + pub color: String, + + /// If the session is not active, this will be None + pub started: Option>, + + /// The parent category of this category + /// If none, the category has no parent + pub parent: Option, + + // FIXME: this field is currently not used + /// Whether the item has been "deleted", e.g. it shoudn't be shown in the view + pub deleted: bool, + } + } + + pub mod sessions { + use super::super::*; + + pub const NAME: &str = "SESSIONS"; + + pub type K = Uuid; + + #[derive(Debug, Clone, Serialize, Deserialize)] + pub struct V { + /// The UUID of the category to which this session belongs + pub category: trees::categories::K, + + /// The time when this session was started + pub started: DateTime, + + /// The time when this session was ended + pub ended: DateTime, + + // FIXME: this field is currently not used + /// Whether the item has been "deleted", e.g. it shoudn't be shown in the view + pub deleted: bool, + } + } +} + diff --git a/src/handlebars_util.rs b/src/handlebars_util.rs index d3e989f..f5f5e82 100644 --- a/src/handlebars_util.rs +++ b/src/handlebars_util.rs @@ -1,10 +1,10 @@ -use chrono::NaiveDateTime; +use chrono::{DateTime, Local}; use handlebars::handlebars_helper; use rocket_contrib::templates::Engines; pub fn register_helpers(engines: &mut Engines) { handlebars_helper!(pretty_datetime: |dt: str| { - let dt: NaiveDateTime = dt.parse().unwrap(); + let dt: DateTime = dt.parse().unwrap(); format!("{}", dt.format("%Y-%m-%d %H:%M")) }); engines diff --git a/src/main.rs b/src/main.rs index 6e52c97..b82d19c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,7 +5,9 @@ mod routes; mod status_json; use bincode::{deserialize, serialize}; -use database::{unversioned::global::schema_version, SCHEMA_VERSION}; +use crate::database::SCHEMA_VERSION; +use crate::database::unversioned::global::schema_version; +use crate::database::migrations::migrate; use dotenv::dotenv; use rocket_contrib::serve::StaticFiles; use rocket_contrib::templates::Template; @@ -16,7 +18,7 @@ fn main() -> io::Result<()> { let db_path = env::var("DB_PATH").expect("DB_PATH not set"); - let sled = sled::open(db_path)?; + let mut sled = sled::open(db_path)?; match sled.insert( serialize(schema_version::K).unwrap(), serialize(&SCHEMA_VERSION).unwrap(), @@ -27,6 +29,9 @@ fn main() -> io::Result<()> { "Schema version: {}, previously: {}", SCHEMA_VERSION, prev_schema_version ); + + migrate(&mut sled, prev_schema_version, SCHEMA_VERSION) + .expect("Migration failed") } None => { println!("Schema version: {}, previously: None", SCHEMA_VERSION); diff --git a/src/routes/api.rs b/src/routes/api.rs index 8e1d519..f26cae1 100644 --- a/src/routes/api.rs +++ b/src/routes/api.rs @@ -1,9 +1,8 @@ -use crate::database::latest::trees::{categories, past_sessions}; +use crate::database::latest::trees::{categories, sessions}; use crate::routes::pages; use crate::status_json::StatusJson; use bincode::{deserialize, serialize}; -use chrono::NaiveDateTime; -use chrono::{Duration, Utc}; +use chrono::{NaiveDateTime, TimeZone, Duration, Local}; use rocket::http::Status; use rocket::request::{Form, FromForm}; use rocket::response::Redirect; @@ -29,8 +28,11 @@ pub fn create_category( serialize(&uuid::Uuid::new_v4())?, serialize(&categories::V { name: category.name, + description: None, color: category.color, started: None, + parent: None, + deleted: false, })?, )?; @@ -68,8 +70,16 @@ pub fn bump_session( let mut category: categories::V = deserialize(&data).unwrap(); match category.started.as_mut() { Some(started) => { - *started -= duration; - tx_categories.insert(&category_uuid_s, serialize(&category).unwrap())?; + if let Some(new_started) = started.checked_sub_signed(duration) { + *started = new_started; + tx_categories.insert(&category_uuid_s, serialize(&category).unwrap())?; + } else { + return Ok(Err(StatusJson::new( + Status::BadRequest, + "Duration subtract resulted in overflow", + ))) + } + Ok(Ok(Redirect::to(uri!(pages::index)))) } None => { @@ -92,15 +102,15 @@ pub fn toggle_category_session( let category_uuid_s = sled::IVec::from(serialize(&category_uuid.into_inner())?); let categories_tree = db.open_tree(categories::NAME)?; - let past_sessions_tree = db.open_tree(past_sessions::NAME)?; + let sessions_tree = db.open_tree(sessions::NAME)?; - Ok((&categories_tree, &past_sessions_tree).transaction( - |(tx_categories, tx_past_sessions)| { + Ok((&categories_tree, &sessions_tree).transaction( + |(tx_categories, tx_sessions)| { match tx_categories.get(&category_uuid_s)? { None => return Ok(Err(Status::NotFound)), Some(data) => { let mut category: categories::V = deserialize(&data).unwrap(); - let now = Utc::now().naive_utc(); + let now = Local::now(); match (set_active, category.started.take()) { (false, Some(started)) => { @@ -108,13 +118,14 @@ pub fn toggle_category_session( let duration = now - started; if duration > Duration::minutes(5) { let session_uuid = serialize(&uuid::Uuid::new_v4()).unwrap(); - let past_session = past_sessions::V { + let session = sessions::V { category: category_uuid.into_inner(), started, ended: now, + deleted: category.deleted, }; - tx_past_sessions - .insert(session_uuid, serialize(&past_session).unwrap())?; + tx_sessions + .insert(session_uuid, serialize(&session).unwrap())?; } } (true, None) => { @@ -140,6 +151,7 @@ pub struct EditSession { category: Uuid, started: String, ended: String, + deleted: bool, } #[post("/session//edit", data = "")] @@ -150,12 +162,11 @@ pub fn edit_session( ) -> Result { let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?); - dbg!(&session); - - let session = past_sessions::V { + let session = sessions::V { category: session.category.into_inner(), - started: NaiveDateTime::parse_from_str(&session.started, "%Y-%m-%d %H:%M")?, - ended: NaiveDateTime::parse_from_str(&session.ended, "%Y-%m-%d %H:%M")?, + started: Local.from_local_datetime(&NaiveDateTime::parse_from_str(&session.started, "%Y-%m-%d %H:%M")?).unwrap(), + ended: Local.from_local_datetime(&NaiveDateTime::parse_from_str(&session.ended, "%Y-%m-%d %H:%M")?).unwrap(), + deleted: session.deleted, }; if session.started >= session.ended { @@ -165,7 +176,7 @@ pub fn edit_session( )); } - db.open_tree(past_sessions::NAME)? + db.open_tree(sessions::NAME)? .insert(session_uuid_s, serialize(&session)?)?; // FIXME: Uuid does not implement FromUriParam for some reason... File an issue? @@ -177,19 +188,19 @@ pub fn edit_session( pub fn delete_session(session_uuid: Uuid, db: State<'_, sled::Db>) -> Result { let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?); - let past_sessions_tree = db.open_tree(past_sessions::NAME)?; + let sessions_tree = db.open_tree(sessions::NAME)?; - match past_sessions_tree.remove(session_uuid_s)? { + match sessions_tree.remove(session_uuid_s)? { Some(_) => Ok(Redirect::to(uri!(pages::history))), None => Err(Status::NotFound.into()), } // TODO: mark as deleted instead of removing - // Ok(past_sessions_tree.transaction(|tx_past_sessions| { - // match tx_past_sessions.get(&session_uuid_s)? { + // Ok(sessions_tree.transaction(|tx_sessions| { + // match tx_sessions.get(&session_uuid_s)? { // None => return Ok(Err(Status::NotFound)), // Some(data) => { - // let mut past_session: past_sessions::V = deserialize(&data).unwrap(); + // let mut session: sessions::V = deserialize(&data).unwrap(); // } // } // Ok(Ok(Redirect::to(uri!(pages::history)))) diff --git a/src/routes/pages.rs b/src/routes/pages.rs index 560b3d8..8502f4f 100644 --- a/src/routes/pages.rs +++ b/src/routes/pages.rs @@ -1,4 +1,4 @@ -use crate::database::latest::trees::{categories, past_sessions}; +use crate::database::latest::trees::{categories, sessions}; use crate::status_json::StatusJson; use bincode::deserialize; use bincode::serialize; @@ -35,14 +35,14 @@ pub fn index(db: State<'_, sled::Db>) -> Result { pub fn session_edit(session_uuid: Uuid, db: State<'_, sled::Db>) -> Result { #[derive(Debug, Serialize, Deserialize)] struct SessionPageContext { - session: past_sessions::V, + session: sessions::V, session_id: uuid::Uuid, } let session_uuid_s = sled::IVec::from(serialize(&session_uuid.into_inner())?); - let past_sessions_tree = db.open_tree(past_sessions::NAME)?; - match past_sessions_tree.get(session_uuid_s)? { + let sessions_tree = db.open_tree(sessions::NAME)?; + match sessions_tree.get(session_uuid_s)? { None => Err(Status::NotFound)?, Some(data) => { let context = SessionPageContext { @@ -60,7 +60,7 @@ pub fn history(db: State<'_, sled::Db>) -> Result { #[derive(Debug, Serialize, Deserialize)] struct HistoryEntryContext { category: categories::V, - session: past_sessions::V, + session: sessions::V, session_id: uuid::Uuid, duration: Duration, } @@ -71,7 +71,7 @@ pub fn history(db: State<'_, sled::Db>) -> Result { } let categories_tree = db.open_tree(categories::NAME)?; - let past_sessions_tree = db.open_tree(past_sessions::NAME)?; + let sessions_tree = db.open_tree(sessions::NAME)?; let categories: HashMap = categories_tree .iter() @@ -80,7 +80,7 @@ pub fn history(db: State<'_, sled::Db>) -> Result { }) .collect::, _>>()??; - let past_sessions: HashMap = past_sessions_tree + let sessions: HashMap = sessions_tree .iter() .map(|result| { result.map(|(k, v)| deserialize(&k).and_then(|k| deserialize(&v).map(|v| (k, v)))) @@ -88,7 +88,7 @@ pub fn history(db: State<'_, sled::Db>) -> Result { .collect::, _>>()??; let mut context = TemplateContext { - entries: past_sessions + entries: sessions .into_iter() .map(|(session_id, session)| HistoryEntryContext { duration: (session.ended - session.started).to_std().unwrap(), diff --git a/templates/edit_session.hbs b/templates/edit_session.hbs index a7d63d6..3c24358 100644 --- a/templates/edit_session.hbs +++ b/templates/edit_session.hbs @@ -20,7 +20,8 @@

- + + Started: