diff --git a/Cargo.lock b/Cargo.lock index b9c95b90..c147d635 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,6 +75,12 @@ dependencies = [ "password-hash", ] +[[package]] +name = "arrayvec" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" + [[package]] name = "async-trait" version = "0.1.58" @@ -270,6 +276,7 @@ dependencies = [ "async-trait", "atuin-client", "atuin-common", + "chrono", "clap", "eyre", "fs-err", @@ -278,6 +285,8 @@ dependencies = [ "serde_json", "tantivy", "tokio", + "type-safe-id", + "uuid", ] [[package]] @@ -3507,6 +3516,19 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" +[[package]] +name = "type-safe-id" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1211a53644c6a5ae26c45bdd520d1533b07dab50dc6af1a15063772f9061ad" +dependencies = [ + "arrayvec", + "rand 0.8.5", + "serde", + "thiserror", + "uuid", +] + [[package]] name = "typed-builder" version = "0.14.0" diff --git a/atuin-todo/Cargo.toml b/atuin-todo/Cargo.toml index 98a68a29..166404af 100644 --- a/atuin-todo/Cargo.toml +++ b/atuin-todo/Cargo.toml @@ -11,6 +11,7 @@ atuin-client = { path = "../atuin-client", version = "15.0.0" } eyre = { workspace = true } async-trait = { workspace = true } +chrono = { workspace = true } clap = { workspace = true } fs-err = { workspace = true } rmp = { version = "0.8.11" } @@ -18,3 +19,5 @@ serde = { workspace = true } serde_json = { workspace = true } tantivy = "0.20.2" tokio = { workspace = true } +type-safe-id = { version = "0.2.1", features = ["serde"] } +uuid = { workspace = true } diff --git a/atuin-todo/src/main.rs b/atuin-todo/src/main.rs index b9946b69..9fe718c7 100644 --- a/atuin-todo/src/main.rs +++ b/atuin-todo/src/main.rs @@ -8,13 +8,23 @@ use atuin_client::{ settings::Settings, }; use atuin_common::record::HostId; +use chrono::{Local, TimeZone}; use clap::Parser; use eyre::{bail, Context, Result}; -use record::TodoStore; +use record::{TodoRecord, TodoStore}; +use type_safe_id::{StaticType, TypeSafeId}; +use uuid::Uuid; mod record; mod search; +#[derive(Default, Copy, Clone, PartialEq, Eq)] +pub struct Todo; +impl StaticType for Todo { + const TYPE: &'static str = "todo"; +} +pub type TodoId = TypeSafeId; + #[derive(clap::Parser)] #[command(author = "Conrad Ludgate ")] enum Cmd { @@ -30,6 +40,25 @@ enum Cmd { /// todo text text: String, }, + Update { + #[arg(long)] + id: TodoId, + + /// The state this todo item is in. eg "todo", "in progress", etc + #[arg(short = 's', long = "state")] + state: Option, + + /// Whether to append the new tags or overwrite the tags + #[arg(short, long)] + append: bool, + + /// tags for this todo item + #[arg(short = 't', long = "tag")] + tags: Vec, + + /// todo text + text: Option, + }, Search { #[arg(long, default_value = "20")] limit: usize, @@ -37,6 +66,10 @@ enum Cmd { /// search query query: String, }, + View { + #[arg(long)] + id: TodoId, + }, } #[tokio::main] @@ -66,15 +99,74 @@ async fn main() -> Result<()> { match cmd { Cmd::Push { state, tags, text } => { - todo_store - .create_item(&mut store, &encryption_key, state, text, tags) + let record = TodoRecord { + tags, + state, + text, + updates: TodoId::from_uuid(Uuid::nil()), + }; + let record = todo_store + .create_item(&mut store, &encryption_key, record) .await?; + + println!("created {}", TodoId::from_uuid(record.id.0)); + } + Cmd::Update { + id, + state, + append, + mut tags, + text, + } => { + let mut record = todo_store.get(&mut store, &encryption_key, id).await?.data; + record.updates = id; + if append { + record.tags.append(&mut tags) + } else if !tags.is_empty() { + record.tags = tags; + } + if let Some(state) = state { + record.state = state; + } + if let Some(text) = text { + record.text = text; + } + + let record = todo_store + .create_item(&mut store, &encryption_key, record) + .await?; + + println!("updated from {} to {}", id, TodoId::from_uuid(record.id.0)); } Cmd::Search { limit, query } => { let ids = todo_store.search(&query, limit).await?; for id in ids { let record = todo_store.get(&mut store, &encryption_key, id).await?; - println!("{}", serde_json::to_string(&record).unwrap()); + println!("{id} - [{}] {}", record.data.state, record.data.text); + if !record.data.tags.is_empty() { + print!("\t"); + for tag in record.data.tags { + print!("#{tag} ") + } + println!() + } + } + } + Cmd::View { mut id } => { + while !id.uuid().is_nil() { + let record = todo_store.get(&mut store, &encryption_key, id).await?; + id = record.data.updates; + let ts = Local + .timestamp_nanos(record.timestamp as i64) + .format("%Y %B %-d %R"); + println!("{ts} - [{}] {}", record.data.state, record.data.text); + if !record.data.tags.is_empty() { + print!("\t"); + for tag in record.data.tags { + print!("#{tag} ") + } + println!() + } } } } diff --git a/atuin-todo/src/record.rs b/atuin-todo/src/record.rs index 19a8eef6..856a313c 100644 --- a/atuin-todo/src/record.rs +++ b/atuin-todo/src/record.rs @@ -21,19 +21,24 @@ //! state, //! text, //! [tag], +//! updates, //! ] //! ``` -use atuin_common::record::{DecryptedData, Record, RecordId}; -use eyre::{bail, ensure, eyre, Result}; +use atuin_common::record::{DecryptedData, EncryptedData, Record, RecordId}; +use eyre::{bail, ensure, eyre, Context, ContextCompat, Result}; use atuin_client::record::encryption::paseto_v4::PASETO_V4; use atuin_client::record::store::Store; use atuin_client::settings::Settings; use serde::Serialize; use tantivy::{collector::TopDocs, query::QueryParser, Index}; +use uuid::Uuid; -use crate::search::{self, TodoSchema}; +use crate::{ + search::{self, TodoSchema}, + TodoId, +}; const TODO_VERSION: &str = "v0"; const TODO_TAG: &str = "todo"; @@ -43,6 +48,7 @@ pub struct TodoRecord { pub state: String, pub text: String, pub tags: Vec, + pub updates: TodoId, } impl TodoRecord { @@ -52,7 +58,7 @@ impl TodoRecord { let mut output = vec![]; // INFO: ensure this is updated when adding new fields - encode::write_array_len(&mut output, 3)?; + encode::write_array_len(&mut output, 4)?; encode::write_str(&mut output, &self.state)?; encode::write_str(&mut output, &self.text)?; @@ -62,6 +68,8 @@ impl TodoRecord { encode::write_str(&mut output, tag)?; } + encode::write_bin(&mut output, self.updates.uuid().as_bytes())?; + Ok(DecryptedData(output)) } @@ -74,32 +82,39 @@ impl TodoRecord { match version { TODO_VERSION => { - let mut bytes = decode::Bytes::new(&data.0); + // let mut rd = decode::Bytes::new(&data.0); + let mut data = &*data.0; - let nfields = decode::read_array_len(&mut bytes).map_err(error_report)?; - ensure!(nfields == 3, "too many entries in v0 todo record"); + let nfields = decode::read_array_len(&mut data)?; + ensure!( + nfields == 4, + "incorrect number of entries in v0 todo record" + ); - let bytes = bytes.remaining_slice(); + let (state, data) = decode::read_str_from_slice(data).map_err(error_report)?; + let (text, mut data) = decode::read_str_from_slice(data).map_err(error_report)?; - let (state, bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; - let (text, mut bytes) = decode::read_str_from_slice(bytes).map_err(error_report)?; - - let ntags = decode::read_array_len(&mut bytes).map_err(error_report)?; + let ntags = decode::read_array_len(&mut data)?; let mut tags = Vec::with_capacity(ntags as usize); for _ in 0..ntags { - let (value, b) = decode::read_str_from_slice(bytes).map_err(error_report)?; - bytes = b; + let (value, b) = decode::read_str_from_slice(data).map_err(error_report)?; + data = b; tags.push(value.to_owned()) } - if !bytes.is_empty() { - bail!("trailing bytes in encoded todo record. malformed") - } + let updates_len = decode::read_bin_len(&mut data)?; + ensure!(updates_len == 16, "incorrect UUID encoding in todo record"); + let updates: [u8; 16] = data + .try_into() + .context("incorrect UUID encoding in todo record")?; + + let updates = TodoId::from_uuid(Uuid::from_bytes(updates)); Ok(TodoRecord { state: state.to_owned(), text: text.to_owned(), tags, + updates, }) } _ => { @@ -132,14 +147,10 @@ impl TodoStore { &self, store: &mut (impl Store + Send + Sync), encryption_key: &[u8; 32], - state: String, - text: String, - tags: Vec, - ) -> Result<()> { + record: TodoRecord, + ) -> Result> { let host_id = Settings::host_id().expect("failed to get host_id"); - let todo = TodoRecord { state, text, tags }; - let parent = store.tail(host_id, TODO_TAG).await?.map(|entry| entry.id); let record = Record::builder() @@ -147,7 +158,7 @@ impl TodoStore { .version(TODO_VERSION.to_string()) .tag(TODO_TAG.to_owned()) .parent(parent) - .data(todo) + .data(record) .build(); let mut writer = self.index.writer(3_000_000)?; @@ -162,16 +173,16 @@ impl TodoStore { let record = record.encrypt::(encryption_key); store.push(&record).await?; - Ok(()) + Ok(record) } pub async fn get( &self, store: &mut (impl Store + Send + Sync), encryption_key: &[u8; 32], - id: RecordId, + id: TodoId, ) -> Result> { - let record = store.get(id).await?; + let record = store.get(RecordId(id.uuid())).await?; match &*record.version { "v0" => { let record = record.decrypt::(encryption_key)?; @@ -182,7 +193,7 @@ impl TodoStore { } } - pub async fn search(&self, query: &str, limit: usize) -> Result> { + pub async fn search(&self, query: &str, limit: usize) -> Result> { let query_parser = QueryParser::new( self.index.schema(), vec![self.schema.text, self.schema.tag], @@ -197,7 +208,12 @@ impl TodoStore { for (_, doc) in docs { let doc = searcher.doc(doc)?; let id = doc.get_first(self.schema.id); - output.push(RecordId(id.unwrap().as_text().unwrap().parse().unwrap())) + output.push( + id.context("missing id")? + .as_text() + .context("invalid id")? + .parse()?, + ) } Ok(output) @@ -206,6 +222,10 @@ impl TodoStore { #[cfg(test)] mod tests { + use uuid::Uuid; + + use crate::TodoId; + use super::{TodoRecord, TODO_VERSION}; #[test] @@ -214,6 +234,7 @@ mod tests { state: "todo".to_owned(), text: "implement todo".to_owned(), tags: vec!["atuin".to_owned(), "rust".to_owned()], + updates: TodoId::from_uuid(Uuid::nil()), }; let encoded = kv.serialize().unwrap(); diff --git a/atuin-todo/src/search.rs b/atuin-todo/src/search.rs index 253ee453..9b087315 100644 --- a/atuin-todo/src/search.rs +++ b/atuin-todo/src/search.rs @@ -4,10 +4,10 @@ use tantivy::{ directory::MmapDirectory, doc, schema::{Field, Schema, STORED, STRING, TEXT}, - DateTime, Index, IndexWriter, + DateTime, Index, IndexWriter, Term, }; -use crate::record::TodoRecord; +use crate::{record::TodoRecord, TodoId}; pub fn schema() -> (TodoSchema, Schema) { let mut schema_builder = Schema::builder(); @@ -47,7 +47,7 @@ pub fn write_record( ) -> Result<()> { let timestamp = DateTime::from_timestamp_nanos(record.timestamp as i64); let mut doc = doc!( - schema.id => record.id.0.to_string(), + schema.id => TodoId::from_uuid(record.id.0).to_string(), schema.text => record.data.text.clone(), schema.timestamp => timestamp, ); @@ -56,5 +56,12 @@ pub fn write_record( } writer.add_document(doc)?; + if !record.data.updates.uuid().is_nil() { + writer.delete_term(Term::from_field_text( + schema.id, + &record.data.updates.to_string(), + )); + } + Ok(()) }