mirror of
https://github.com/atuinsh/atuin.git
synced 2025-02-16 18:32:05 +01:00
create todo app
This commit is contained in:
parent
8b1abff170
commit
9347eedbe3
656
Cargo.lock
generated
656
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,7 @@ members = [
|
||||
"atuin-server-postgres",
|
||||
"atuin-server-database",
|
||||
"atuin-common",
|
||||
"atuin-todo",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
@ -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;
|
||||
|
@ -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
20
atuin-todo/Cargo.toml
Normal 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
83
atuin-todo/src/main.rs
Normal 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
224
atuin-todo/src/record.rs
Normal 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
60
atuin-todo/src/search.rs
Normal 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(())
|
||||
}
|
Loading…
Reference in New Issue
Block a user