add updates

This commit is contained in:
Conrad Ludgate 2023-07-12 10:03:04 +01:00
parent 9347eedbe3
commit 214e3c337c
5 changed files with 181 additions and 36 deletions

22
Cargo.lock generated
View File

@ -75,6 +75,12 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "arrayvec"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.58" version = "0.1.58"
@ -270,6 +276,7 @@ dependencies = [
"async-trait", "async-trait",
"atuin-client", "atuin-client",
"atuin-common", "atuin-common",
"chrono",
"clap", "clap",
"eyre", "eyre",
"fs-err", "fs-err",
@ -278,6 +285,8 @@ dependencies = [
"serde_json", "serde_json",
"tantivy", "tantivy",
"tokio", "tokio",
"type-safe-id",
"uuid",
] ]
[[package]] [[package]]
@ -3507,6 +3516,19 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" 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]] [[package]]
name = "typed-builder" name = "typed-builder"
version = "0.14.0" version = "0.14.0"

View File

@ -11,6 +11,7 @@ atuin-client = { path = "../atuin-client", version = "15.0.0" }
eyre = { workspace = true } eyre = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true } clap = { workspace = true }
fs-err = { workspace = true } fs-err = { workspace = true }
rmp = { version = "0.8.11" } rmp = { version = "0.8.11" }
@ -18,3 +19,5 @@ serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
tantivy = "0.20.2" tantivy = "0.20.2"
tokio = { workspace = true } tokio = { workspace = true }
type-safe-id = { version = "0.2.1", features = ["serde"] }
uuid = { workspace = true }

View File

@ -8,13 +8,23 @@ use atuin_client::{
settings::Settings, settings::Settings,
}; };
use atuin_common::record::HostId; use atuin_common::record::HostId;
use chrono::{Local, TimeZone};
use clap::Parser; use clap::Parser;
use eyre::{bail, Context, Result}; use eyre::{bail, Context, Result};
use record::TodoStore; use record::{TodoRecord, TodoStore};
use type_safe_id::{StaticType, TypeSafeId};
use uuid::Uuid;
mod record; mod record;
mod search; mod search;
#[derive(Default, Copy, Clone, PartialEq, Eq)]
pub struct Todo;
impl StaticType for Todo {
const TYPE: &'static str = "todo";
}
pub type TodoId = TypeSafeId<Todo>;
#[derive(clap::Parser)] #[derive(clap::Parser)]
#[command(author = "Conrad Ludgate <conradludgate@gmail.com>")] #[command(author = "Conrad Ludgate <conradludgate@gmail.com>")]
enum Cmd { enum Cmd {
@ -30,6 +40,25 @@ enum Cmd {
/// todo text /// todo text
text: String, 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<String>,
/// 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<String>,
/// todo text
text: Option<String>,
},
Search { Search {
#[arg(long, default_value = "20")] #[arg(long, default_value = "20")]
limit: usize, limit: usize,
@ -37,6 +66,10 @@ enum Cmd {
/// search query /// search query
query: String, query: String,
}, },
View {
#[arg(long)]
id: TodoId,
},
} }
#[tokio::main] #[tokio::main]
@ -66,15 +99,74 @@ async fn main() -> Result<()> {
match cmd { match cmd {
Cmd::Push { state, tags, text } => { Cmd::Push { state, tags, text } => {
todo_store let record = TodoRecord {
.create_item(&mut store, &encryption_key, state, text, tags) tags,
state,
text,
updates: TodoId::from_uuid(Uuid::nil()),
};
let record = todo_store
.create_item(&mut store, &encryption_key, record)
.await?; .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 } => { Cmd::Search { limit, query } => {
let ids = todo_store.search(&query, limit).await?; let ids = todo_store.search(&query, limit).await?;
for id in ids { for id in ids {
let record = todo_store.get(&mut store, &encryption_key, id).await?; 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!()
}
} }
} }
} }

View File

@ -21,19 +21,24 @@
//! state, //! state,
//! text, //! text,
//! [tag], //! [tag],
//! updates,
//! ] //! ]
//! ``` //! ```
use atuin_common::record::{DecryptedData, Record, RecordId}; use atuin_common::record::{DecryptedData, EncryptedData, Record, RecordId};
use eyre::{bail, ensure, eyre, Result}; use eyre::{bail, ensure, eyre, Context, ContextCompat, Result};
use atuin_client::record::encryption::paseto_v4::PASETO_V4; use atuin_client::record::encryption::paseto_v4::PASETO_V4;
use atuin_client::record::store::Store; use atuin_client::record::store::Store;
use atuin_client::settings::Settings; use atuin_client::settings::Settings;
use serde::Serialize; use serde::Serialize;
use tantivy::{collector::TopDocs, query::QueryParser, Index}; 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_VERSION: &str = "v0";
const TODO_TAG: &str = "todo"; const TODO_TAG: &str = "todo";
@ -43,6 +48,7 @@ pub struct TodoRecord {
pub state: String, pub state: String,
pub text: String, pub text: String,
pub tags: Vec<String>, pub tags: Vec<String>,
pub updates: TodoId,
} }
impl TodoRecord { impl TodoRecord {
@ -52,7 +58,7 @@ impl TodoRecord {
let mut output = vec![]; let mut output = vec![];
// INFO: ensure this is updated when adding new fields // 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.state)?;
encode::write_str(&mut output, &self.text)?; encode::write_str(&mut output, &self.text)?;
@ -62,6 +68,8 @@ impl TodoRecord {
encode::write_str(&mut output, tag)?; encode::write_str(&mut output, tag)?;
} }
encode::write_bin(&mut output, self.updates.uuid().as_bytes())?;
Ok(DecryptedData(output)) Ok(DecryptedData(output))
} }
@ -74,32 +82,39 @@ impl TodoRecord {
match version { match version {
TODO_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)?; let nfields = decode::read_array_len(&mut data)?;
ensure!(nfields == 3, "too many entries in v0 todo record"); 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 ntags = decode::read_array_len(&mut data)?;
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 mut tags = Vec::with_capacity(ntags as usize); let mut tags = Vec::with_capacity(ntags as usize);
for _ in 0..ntags { for _ in 0..ntags {
let (value, b) = decode::read_str_from_slice(bytes).map_err(error_report)?; let (value, b) = decode::read_str_from_slice(data).map_err(error_report)?;
bytes = b; data = b;
tags.push(value.to_owned()) tags.push(value.to_owned())
} }
if !bytes.is_empty() { let updates_len = decode::read_bin_len(&mut data)?;
bail!("trailing bytes in encoded todo record. malformed") 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 { Ok(TodoRecord {
state: state.to_owned(), state: state.to_owned(),
text: text.to_owned(), text: text.to_owned(),
tags, tags,
updates,
}) })
} }
_ => { _ => {
@ -132,14 +147,10 @@ impl TodoStore {
&self, &self,
store: &mut (impl Store + Send + Sync), store: &mut (impl Store + Send + Sync),
encryption_key: &[u8; 32], encryption_key: &[u8; 32],
state: String, record: TodoRecord,
text: String, ) -> Result<Record<EncryptedData>> {
tags: Vec<String>,
) -> Result<()> {
let host_id = Settings::host_id().expect("failed to get host_id"); 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 parent = store.tail(host_id, TODO_TAG).await?.map(|entry| entry.id);
let record = Record::builder() let record = Record::builder()
@ -147,7 +158,7 @@ impl TodoStore {
.version(TODO_VERSION.to_string()) .version(TODO_VERSION.to_string())
.tag(TODO_TAG.to_owned()) .tag(TODO_TAG.to_owned())
.parent(parent) .parent(parent)
.data(todo) .data(record)
.build(); .build();
let mut writer = self.index.writer(3_000_000)?; let mut writer = self.index.writer(3_000_000)?;
@ -162,16 +173,16 @@ impl TodoStore {
let record = record.encrypt::<PASETO_V4>(encryption_key); let record = record.encrypt::<PASETO_V4>(encryption_key);
store.push(&record).await?; store.push(&record).await?;
Ok(()) Ok(record)
} }
pub async fn get( pub async fn get(
&self, &self,
store: &mut (impl Store + Send + Sync), store: &mut (impl Store + Send + Sync),
encryption_key: &[u8; 32], encryption_key: &[u8; 32],
id: RecordId, id: TodoId,
) -> Result<Record<TodoRecord>> { ) -> Result<Record<TodoRecord>> {
let record = store.get(id).await?; let record = store.get(RecordId(id.uuid())).await?;
match &*record.version { match &*record.version {
"v0" => { "v0" => {
let record = record.decrypt::<PASETO_V4>(encryption_key)?; let record = record.decrypt::<PASETO_V4>(encryption_key)?;
@ -182,7 +193,7 @@ impl TodoStore {
} }
} }
pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<RecordId>> { pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<TodoId>> {
let query_parser = QueryParser::new( let query_parser = QueryParser::new(
self.index.schema(), self.index.schema(),
vec![self.schema.text, self.schema.tag], vec![self.schema.text, self.schema.tag],
@ -197,7 +208,12 @@ impl TodoStore {
for (_, doc) in docs { for (_, doc) in docs {
let doc = searcher.doc(doc)?; let doc = searcher.doc(doc)?;
let id = doc.get_first(self.schema.id); 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) Ok(output)
@ -206,6 +222,10 @@ impl TodoStore {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use uuid::Uuid;
use crate::TodoId;
use super::{TodoRecord, TODO_VERSION}; use super::{TodoRecord, TODO_VERSION};
#[test] #[test]
@ -214,6 +234,7 @@ mod tests {
state: "todo".to_owned(), state: "todo".to_owned(),
text: "implement todo".to_owned(), text: "implement todo".to_owned(),
tags: vec!["atuin".to_owned(), "rust".to_owned()], tags: vec!["atuin".to_owned(), "rust".to_owned()],
updates: TodoId::from_uuid(Uuid::nil()),
}; };
let encoded = kv.serialize().unwrap(); let encoded = kv.serialize().unwrap();

View File

@ -4,10 +4,10 @@ use tantivy::{
directory::MmapDirectory, directory::MmapDirectory,
doc, doc,
schema::{Field, Schema, STORED, STRING, TEXT}, 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) { pub fn schema() -> (TodoSchema, Schema) {
let mut schema_builder = Schema::builder(); let mut schema_builder = Schema::builder();
@ -47,7 +47,7 @@ pub fn write_record(
) -> Result<()> { ) -> Result<()> {
let timestamp = DateTime::from_timestamp_nanos(record.timestamp as i64); let timestamp = DateTime::from_timestamp_nanos(record.timestamp as i64);
let mut doc = doc!( 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.text => record.data.text.clone(),
schema.timestamp => timestamp, schema.timestamp => timestamp,
); );
@ -56,5 +56,12 @@ pub fn write_record(
} }
writer.add_document(doc)?; 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(()) Ok(())
} }