diff --git a/Cargo.lock b/Cargo.lock index 07d270e..b5107df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -702,6 +702,7 @@ dependencies = [ "chrono", "diesel", "diesel_migrations", + "kordophone", "serde", "time", "uuid", @@ -715,8 +716,10 @@ dependencies = [ "clap", "dotenv", "kordophone", + "kordophone-db", "log", "pretty", + "time", "tokio", ] diff --git a/kordophone-db/Cargo.toml b/kordophone-db/Cargo.toml index 1fd691e..626cc7e 100644 --- a/kordophone-db/Cargo.toml +++ b/kordophone-db/Cargo.toml @@ -8,6 +8,7 @@ anyhow = "1.0.94" chrono = "0.4.38" diesel = { version = "2.2.6", features = ["chrono", "sqlite", "time"] } diesel_migrations = { version = "2.2.0", features = ["sqlite"] } +kordophone = { path = "../kordophone" } serde = { version = "1.0.215", features = ["derive"] } time = "0.3.37" uuid = { version = "1.11.0", features = ["v4"] } diff --git a/kordophone-db/src/chat_database.rs b/kordophone-db/src/chat_database.rs index 71dc246..380659d 100644 --- a/kordophone-db/src/chat_database.rs +++ b/kordophone-db/src/chat_database.rs @@ -1,6 +1,7 @@ use anyhow::Result; -use diesel::prelude::*; +use diesel::{prelude::*, sqlite::Sqlite}; use diesel::query_dsl::BelongingToDsl; +use std::path::{Path, PathBuf}; use crate::{ models::{ @@ -20,7 +21,11 @@ pub struct ChatDatabase { impl ChatDatabase { pub fn new_in_memory() -> Result { - let mut db = SqliteConnection::establish(":memory:")?; + Self::new(":memory:") + } + + pub fn new(db_path: &str) -> Result { + let mut db = SqliteConnection::establish(db_path)?; db.run_pending_migrations(MIGRATIONS) .map_err(|e| anyhow::anyhow!("Error running migrations: {}", e))?; diff --git a/kordophone-db/src/lib.rs b/kordophone-db/src/lib.rs index 5ef901f..3ca1345 100644 --- a/kordophone-db/src/lib.rs +++ b/kordophone-db/src/lib.rs @@ -4,3 +4,5 @@ pub mod schema; #[cfg(test)] mod tests; + +pub use chat_database::ChatDatabase; \ No newline at end of file diff --git a/kordophone-db/src/models/conversation.rs b/kordophone-db/src/models/conversation.rs index 415e151..85a9a96 100644 --- a/kordophone-db/src/models/conversation.rs +++ b/kordophone-db/src/models/conversation.rs @@ -1,4 +1,4 @@ -use chrono::NaiveDateTime; +use chrono::{DateTime, NaiveDateTime}; use uuid::Uuid; use crate::models::participant::Participant; @@ -29,6 +29,29 @@ impl Conversation { } } +impl From for Conversation { + fn from(value: kordophone::model::Conversation) -> Self { + Self { + guid: value.guid, + unread_count: u16::try_from(value.unread_count).unwrap(), + display_name: value.display_name, + last_message_preview: value.last_message_preview, + date: DateTime::from_timestamp( + value.date.unix_timestamp(), + value.date.unix_timestamp_nanos() + .try_into() + .unwrap_or(0), + ) + .unwrap() + .naive_local(), + participants: value.participant_display_names + .into_iter() + .map(|p| p.into()) + .collect(), + } + } +} + #[derive(Default)] pub struct ConversationBuilder { guid: Option, diff --git a/kpcli/Cargo.toml b/kpcli/Cargo.toml index 20f4d4d..760462e 100644 --- a/kpcli/Cargo.toml +++ b/kpcli/Cargo.toml @@ -10,6 +10,8 @@ anyhow = "1.0.93" clap = { version = "4.5.20", features = ["derive"] } dotenv = "0.15.0" kordophone = { path = "../kordophone" } +kordophone-db = { path = "../kordophone-db" } log = "0.4.22" pretty = { version = "0.12.3", features = ["termcolor"] } +time = "0.3.37" tokio = "1.41.1" diff --git a/kpcli/src/client/mod.rs b/kpcli/src/client/mod.rs index 8e35fa5..55e6333 100644 --- a/kpcli/src/client/mod.rs +++ b/kpcli/src/client/mod.rs @@ -3,9 +3,28 @@ use kordophone::api::http_client::HTTPAPIClient; use kordophone::api::http_client::Credentials; use dotenv; +use anyhow::Result; use clap::Subcommand; use crate::printers::ConversationPrinter; +pub fn make_api_client_from_env() -> HTTPAPIClient { + dotenv::dotenv().ok(); + + // read from env + let base_url = std::env::var("KORDOPHONE_API_URL") + .expect("KORDOPHONE_API_URL must be set"); + + let credentials = Credentials { + username: std::env::var("KORDOPHONE_USERNAME") + .expect("KORDOPHONE_USERNAME must be set"), + + password: std::env::var("KORDOPHONE_PASSWORD") + .expect("KORDOPHONE_PASSWORD must be set"), + }; + + HTTPAPIClient::new(base_url.parse().unwrap(), credentials.into()) +} + #[derive(Subcommand)] pub enum Commands { /// Prints all known conversations on the server. @@ -16,7 +35,7 @@ pub enum Commands { } impl Commands { - pub async fn run(cmd: Commands) -> Result<(), Box> { + pub async fn run(cmd: Commands) -> Result<()> { let mut client = ClientCli::new(); match cmd { Commands::Version => client.print_version().await, @@ -31,34 +50,20 @@ struct ClientCli { impl ClientCli { pub fn new() -> Self { - dotenv::dotenv().ok(); - - // read from env - let base_url = std::env::var("KORDOPHONE_API_URL") - .expect("KORDOPHONE_API_URL must be set"); - - let credentials = Credentials { - username: std::env::var("KORDOPHONE_USERNAME") - .expect("KORDOPHONE_USERNAME must be set"), - - password: std::env::var("KORDOPHONE_PASSWORD") - .expect("KORDOPHONE_PASSWORD must be set"), - }; - - let api = HTTPAPIClient::new(base_url.parse().unwrap(), credentials.into()); + let api = make_api_client_from_env(); Self { api: api } } - pub async fn print_version(&mut self) -> Result<(), Box> { + pub async fn print_version(&mut self) -> Result<()> { let version = self.api.get_version().await?; println!("Version: {}", version); Ok(()) } - pub async fn print_conversations(&mut self) -> Result<(), Box> { + pub async fn print_conversations(&mut self) -> Result<()> { let conversations = self.api.get_conversations().await?; for conversation in conversations { - println!("{}", ConversationPrinter::new(&conversation)); + println!("{}", ConversationPrinter::new(&conversation.into())); } Ok(()) diff --git a/kpcli/src/db/mod.rs b/kpcli/src/db/mod.rs new file mode 100644 index 0000000..36b68ea --- /dev/null +++ b/kpcli/src/db/mod.rs @@ -0,0 +1,83 @@ +use anyhow::Result; +use clap::Subcommand; +use kordophone::APIInterface; +use std::{env, path::{Path, PathBuf}}; + +use kordophone_db::ChatDatabase; +use crate::{client, printers::ConversationPrinter}; + +#[derive(Subcommand)] +pub enum Commands { + /// For dealing with the table of cached conversations. + Conversations { + #[clap(subcommand)] + command: ConversationCommands + }, +} + +#[derive(Subcommand)] +pub enum ConversationCommands { + /// Lists all conversations currently in the database. + List, + + /// Syncs with an API client. + Sync, +} + +impl Commands { + pub async fn run(cmd: Commands) -> Result<()> { + let mut db = DbClient::new()?; + match cmd { + Commands::Conversations { command: cmd } => match cmd { + ConversationCommands::List => db.print_conversations(), + ConversationCommands::Sync => db.sync_with_client().await, + }, + } + } +} + +struct DbClient { + db: ChatDatabase +} + +impl DbClient { + fn database_path() -> PathBuf { + let temp_dir = env::temp_dir(); + temp_dir.join("kpcli_chat.db") + } + + pub fn new() -> Result { + let path = Self::database_path(); + let path_str: &str = path.as_path().to_str().unwrap(); + + println!("kpcli: Using temporary db at {}", path_str); + + let db = ChatDatabase::new(path_str)?; + Ok( Self { db }) + } + + pub fn print_conversations(&mut self) -> Result<()> { + let all_conversations = self.db.all_conversations()?; + + println!("{} Conversations: ", all_conversations.len()); + for conversation in all_conversations { + println!("{}", ConversationPrinter::new(&conversation.into())); + } + + Ok(()) + } + + pub async fn sync_with_client(&mut self) -> Result<()> { + let mut client = client::make_api_client_from_env(); + let fetched_conversations = client.get_conversations().await?; + let db_conversations: Vec = fetched_conversations.into_iter() + .map(|c| kordophone_db::models::Conversation::from(c)) + .collect(); + + for conversation in db_conversations { + self.db.insert_conversation(conversation)?; + } + + Ok(()) + } +} diff --git a/kpcli/src/main.rs b/kpcli/src/main.rs index c668088..628a112 100644 --- a/kpcli/src/main.rs +++ b/kpcli/src/main.rs @@ -1,5 +1,8 @@ +mod client; +mod db; mod printers; -mod client; + +use anyhow::Result; use clap::{Parser, Subcommand}; /// A command line interface for the Kordophone library and daemon @@ -17,11 +20,18 @@ enum Commands { #[command(subcommand)] command: client::Commands, }, + + /// Commands for the cache database + Db { + #[command(subcommand)] + command: db::Commands, + } } -async fn run_command(command: Commands) -> Result<(), Box> { +async fn run_command(command: Commands) -> Result<()> { match command { Commands::Client { command } => client::Commands::run(command).await, + Commands::Db { command } => db::Commands::run(command).await, } } diff --git a/kpcli/src/printers.rs b/kpcli/src/printers.rs index 2ed1bb0..37b73e8 100644 --- a/kpcli/src/printers.rs +++ b/kpcli/src/printers.rs @@ -1,14 +1,48 @@ use std::fmt::Display; - +use time::OffsetDateTime; use pretty::RcDoc; -use kordophone::model::Conversation; + +pub struct PrintableConversation { + pub guid: String, + pub date: OffsetDateTime, + pub unread_count: i32, + pub last_message_preview: Option, + pub participants: Vec, + pub display_name: Option, +} + +impl From for PrintableConversation { + fn from(value: kordophone::model::Conversation) -> Self { + Self { + guid: value.guid, + date: value.date, + unread_count: value.unread_count, + last_message_preview: value.last_message_preview, + participants: value.participant_display_names, + display_name: value.display_name, + } + } +} + +impl From for PrintableConversation { + fn from(value: kordophone_db::models::Conversation) -> Self { + Self { + guid: value.guid, + date: OffsetDateTime::from_unix_timestamp(value.date.and_utc().timestamp()).unwrap(), + unread_count: value.unread_count.into(), + last_message_preview: value.last_message_preview, + participants: value.participants.into_iter().map(|p| p.display_name).collect(), + display_name: value.display_name, + } + } +} pub struct ConversationPrinter<'a> { - doc: RcDoc<'a, Conversation> + doc: RcDoc<'a, PrintableConversation> } impl<'a> ConversationPrinter<'a> { - pub fn new(conversation: &'a Conversation) -> Self { + pub fn new(conversation: &'a PrintableConversation) -> Self { let preview = conversation.last_message_preview .as_deref() .unwrap_or("") @@ -27,7 +61,7 @@ impl<'a> ConversationPrinter<'a> { .append("[") .append(RcDoc::line() .append( - conversation.participant_display_names + conversation.participants .iter() .map(|name| RcDoc::text(name)