create todo app

This commit is contained in:
Conrad Ludgate 2023-07-11 23:14:56 +01:00
parent 8b1abff170
commit 9347eedbe3
8 changed files with 1025 additions and 35 deletions

656
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -6,6 +6,7 @@ members = [
"atuin-server-postgres",
"atuin-server-database",
"atuin-common",
"atuin-todo",
]
[workspace.package]

View File

@ -1,4 +1,4 @@
//! A collect of all well known tag+version pairs.
//! A collect of some well known tag+version pairs.
pub mod key;
pub mod kv;

View File

@ -195,6 +195,20 @@ pub trait Encryption {
fn decrypt(data: EncryptedData, ad: AdditionalData, key: &[u8; 32]) -> Result<DecryptedData>;
}
impl<T> Record<T> {
pub fn try_map<U, E>(self, f: impl FnOnce(T) -> Result<U, E>) -> Result<Record<U>, E> {
Ok(Record {
data: f(self.data)?,
id: self.id,
host: self.host,
parent: self.parent,
timestamp: self.timestamp,
version: self.version,
tag: self.tag,
})
}
}
impl Record<DecryptedData> {
pub fn encrypt<E: Encryption>(self, key: &[u8; 32]) -> Record<EncryptedData> {
let ad = AdditionalData {

20
atuin-todo/Cargo.toml Normal file
View File

@ -0,0 +1,20 @@
[package]
name = "atuin-todo"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
atuin-common = { path = "../atuin-common", version = "15.0.0" }
atuin-client = { path = "../atuin-client", version = "15.0.0" }
eyre = { workspace = true }
async-trait = { workspace = true }
clap = { workspace = true }
fs-err = { workspace = true }
rmp = { version = "0.8.11" }
serde = { workspace = true }
serde_json = { workspace = true }
tantivy = "0.20.2"
tokio = { workspace = true }

83
atuin-todo/src/main.rs Normal file
View File

@ -0,0 +1,83 @@
use std::path::PathBuf;
use atuin_client::{
record::{
encodings::key::{EncryptionKey, KeyStore},
store::sqlite::SqliteStore,
},
settings::Settings,
};
use atuin_common::record::HostId;
use clap::Parser;
use eyre::{bail, Context, Result};
use record::TodoStore;
mod record;
mod search;
#[derive(clap::Parser)]
#[command(author = "Conrad Ludgate <conradludgate@gmail.com>")]
enum Cmd {
Push {
/// The state this todo item is in. eg "todo", "in progress", etc
#[arg(short = 's', long = "state")]
state: String,
/// tags for this todo item
#[arg(short = 't', long = "tag")]
tags: Vec<String>,
/// todo text
text: String,
},
Search {
#[arg(long, default_value = "20")]
limit: usize,
/// search query
query: String,
},
}
#[tokio::main]
async fn main() -> Result<()> {
let cmd = Cmd::parse();
let settings = Settings::new().wrap_err("could not load client settings")?;
let record_store_path = PathBuf::from(settings.record_store_path.as_str());
let mut store = SqliteStore::new(record_store_path).await?;
let key_store = KeyStore::new();
// ensure this encryption key is the latest registered key before encrypting anything new.
let encryption_key = match key_store
.validate_encryption_key(&mut store, &settings)
.await?
{
EncryptionKey::Valid { encryption_key } => encryption_key,
EncryptionKey::Invalid {
kid,
host_id: HostId(host_id),
} => {
bail!("A new encryption key [id:{kid}] has been set by [host:{host_id}]. You must update to this encryption key to continue")
}
};
let todo_store = TodoStore::new();
match cmd {
Cmd::Push { state, tags, text } => {
todo_store
.create_item(&mut store, &encryption_key, state, text, tags)
.await?;
}
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());
}
}
}
Ok(())
}

224
atuin-todo/src/record.rs Normal file
View File

@ -0,0 +1,224 @@
//! A todo store.
//!
//! * `tag` = "todo"
//! * `version`s:
//! - "v0"
//!
//! ## Encryption schemes
//!
//! ### v0
//!
//! [`PASETO_V4`]
//!
//! ## Encoding schemes
//!
//! ### v0
//!
//! Message pack encoding of
//!
//! ```text
//! [
//! state,
//! text,
//! [tag],
//! ]
//! ```
use atuin_common::record::{DecryptedData, Record, RecordId};
use eyre::{bail, ensure, eyre, 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 crate::search::{self, TodoSchema};
const TODO_VERSION: &str = "v0";
const TODO_TAG: &str = "todo";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TodoRecord {
pub state: String,
pub text: String,
pub tags: Vec<String>,
}
impl TodoRecord {
pub fn serialize(&self) -> Result<DecryptedData> {
use rmp::encode;
let mut output = vec![];
// INFO: ensure this is updated when adding new fields
encode::write_array_len(&mut output, 3)?;
encode::write_str(&mut output, &self.state)?;
encode::write_str(&mut output, &self.text)?;
encode::write_array_len(&mut output, self.tags.len() as u32)?;
for tag in &self.tags {
encode::write_str(&mut output, tag)?;
}
Ok(DecryptedData(output))
}
pub fn deserialize(data: &DecryptedData, version: &str) -> Result<Self> {
use rmp::decode;
fn error_report<E: std::fmt::Debug>(err: E) -> eyre::Report {
eyre!("{err:?}")
}
match version {
TODO_VERSION => {
let mut bytes = decode::Bytes::new(&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 bytes = bytes.remaining_slice();
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 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;
tags.push(value.to_owned())
}
if !bytes.is_empty() {
bail!("trailing bytes in encoded todo record. malformed")
}
Ok(TodoRecord {
state: state.to_owned(),
text: text.to_owned(),
tags,
})
}
_ => {
bail!("unknown version {version:?}")
}
}
}
}
pub struct TodoStore {
schema: TodoSchema,
index: Index,
}
impl Default for TodoStore {
fn default() -> Self {
Self::new()
}
}
impl TodoStore {
// will want to init the actual kv store when that is done
pub fn new() -> TodoStore {
let (ts, schema) = search::schema();
let index = search::index(schema).unwrap();
TodoStore { schema: ts, index }
}
pub async fn create_item(
&self,
store: &mut (impl Store + Send + Sync),
encryption_key: &[u8; 32],
state: String,
text: String,
tags: Vec<String>,
) -> 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()
.host(host_id)
.version(TODO_VERSION.to_string())
.tag(TODO_TAG.to_owned())
.parent(parent)
.data(todo)
.build();
let mut writer = self.index.writer(3_000_000)?;
search::write_record(&mut writer, &self.schema, &record)?;
tokio::task::spawn_blocking(|| {
writer.commit()?;
writer.wait_merging_threads()
})
.await??;
let record = record.try_map(|s| s.serialize())?;
let record = record.encrypt::<PASETO_V4>(encryption_key);
store.push(&record).await?;
Ok(())
}
pub async fn get(
&self,
store: &mut (impl Store + Send + Sync),
encryption_key: &[u8; 32],
id: RecordId,
) -> Result<Record<TodoRecord>> {
let record = store.get(id).await?;
match &*record.version {
"v0" => {
let record = record.decrypt::<PASETO_V4>(encryption_key)?;
let record = record.try_map(|s| TodoRecord::deserialize(&s, "v0"))?;
Ok(record)
}
_ => bail!("unsupported todo record version"),
}
}
pub async fn search(&self, query: &str, limit: usize) -> Result<Vec<RecordId>> {
let query_parser = QueryParser::new(
self.index.schema(),
vec![self.schema.text, self.schema.tag],
self.index.tokenizers().clone(),
);
let query = query_parser.parse_query(query)?;
let searcher = self.index.reader()?.searcher();
let mut output = vec![];
let docs = searcher.search(&query, &TopDocs::with_limit(limit))?;
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()))
}
Ok(output)
}
}
#[cfg(test)]
mod tests {
use super::{TodoRecord, TODO_VERSION};
#[test]
fn encode_decode() {
let kv = TodoRecord {
state: "todo".to_owned(),
text: "implement todo".to_owned(),
tags: vec!["atuin".to_owned(), "rust".to_owned()],
};
let encoded = kv.serialize().unwrap();
let decoded = TodoRecord::deserialize(&encoded, TODO_VERSION).unwrap();
assert_eq!(decoded, kv);
}
}

60
atuin-todo/src/search.rs Normal file
View File

@ -0,0 +1,60 @@
use atuin_common::record::Record;
use eyre::Result;
use tantivy::{
directory::MmapDirectory,
doc,
schema::{Field, Schema, STORED, STRING, TEXT},
DateTime, Index, IndexWriter,
};
use crate::record::TodoRecord;
pub fn schema() -> (TodoSchema, Schema) {
let mut schema_builder = Schema::builder();
(
TodoSchema {
id: schema_builder.add_text_field("id", STRING | STORED),
text: schema_builder.add_text_field("text", TEXT),
timestamp: schema_builder.add_date_field("timestamp", STORED),
tag: schema_builder.add_text_field("tag", TEXT),
},
schema_builder.build(),
)
}
pub struct TodoSchema {
pub id: Field,
pub text: Field,
pub timestamp: Field,
pub tag: Field,
}
pub fn index(schema: Schema) -> Result<Index> {
let data_dir = atuin_common::utils::data_dir().join("todo");
let tantivy_dir = data_dir.join("tantivy");
fs_err::create_dir_all(&tantivy_dir)?;
let dir = MmapDirectory::open(tantivy_dir)?;
Ok(Index::open_or_create(dir, schema)?)
}
pub fn write_record(
writer: &mut IndexWriter,
schema: &TodoSchema,
record: &Record<TodoRecord>,
) -> Result<()> {
let timestamp = DateTime::from_timestamp_nanos(record.timestamp as i64);
let mut doc = doc!(
schema.id => record.id.0.to_string(),
schema.text => record.data.text.clone(),
schema.timestamp => timestamp,
);
for tag in record.data.tags.clone() {
doc.add_field_value(schema.tag, tag)
}
writer.add_document(doc)?;
Ok(())
}