diff --git a/Cargo.lock b/Cargo.lock index 61cc498..01619c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1048,6 +1048,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "chrono", "dbus", "dbus-codegen", "dbus-crossroads", diff --git a/kordophoned/Cargo.toml b/kordophoned/Cargo.toml index 5a25583..1222916 100644 --- a/kordophoned/Cargo.toml +++ b/kordophoned/Cargo.toml @@ -6,6 +6,7 @@ edition = "2021" [dependencies] anyhow = "1.0.98" async-trait = "0.1.88" +chrono = "0.4.38" dbus = "0.9.7" dbus-crossroads = "0.5.2" dbus-tokio = "0.7.6" diff --git a/kordophoned/include/net.buzzert.kordophonecd.Server.xml b/kordophoned/include/net.buzzert.kordophonecd.Server.xml index 1eeee32..d771c05 100644 --- a/kordophoned/include/net.buzzert.kordophonecd.Server.xml +++ b/kordophoned/include/net.buzzert.kordophonecd.Server.xml @@ -65,8 +65,16 @@ 'text' (string): Message body text 'date' (int64): Message timestamp 'sender' (string): Sender display name - 'file_transfer_guids' (string, optional): JSON array of file transfer GUIDs - 'attachment_metadata' (string, optional): JSON string of attachment metadata"/> + 'attachments' (array of dictionaries): List of attachments + 'guid' (string): Attachment GUID + 'path' (string): Attachment path + 'preview_path' (string): Preview attachment path + 'downloaded' (boolean): Whether the attachment is downloaded + 'preview_downloaded' (boolean): Whether the preview is downloaded + 'metadata' (dictionary, optional): Attachment metadata + 'attribution_info' (dictionary, optional): Attribution info + 'width' (int32, optional): Width + 'height' (int32, optional): Height"/> @@ -116,7 +124,7 @@ - + diff --git a/kordophoned/src/daemon/attachment_store.rs b/kordophoned/src/daemon/attachment_store.rs index 429c19c..e0cd3a7 100644 --- a/kordophoned/src/daemon/attachment_store.rs +++ b/kordophoned/src/daemon/attachment_store.rs @@ -13,6 +13,7 @@ use kordophone_db::database::DatabaseAccess; use crate::daemon::events::Event; use crate::daemon::events::Reply; +use crate::daemon::models::Attachment; use crate::daemon::Daemon; use std::sync::Arc; @@ -26,23 +27,6 @@ mod target { pub static ATTACHMENTS: &str = "attachments"; } -#[derive(Debug, Clone)] -pub struct Attachment { - pub guid: String, - pub base_path: PathBuf, -} - -impl Attachment { - pub fn get_path(&self, preview: bool) -> PathBuf { - self.base_path.with_extension(if preview { "preview" } else { "full" }) - } - - pub fn is_downloaded(&self, preview: bool) -> bool { - std::fs::exists(&self.get_path(preview)) - .expect(format!("Wasn't able to check for the existence of an attachment file path at {}", &self.get_path(preview).display()).as_str()) - } -} - #[derive(Debug)] pub enum AttachmentStoreEvent { // Get the attachment info for a given attachment guid. @@ -75,8 +59,13 @@ pub struct AttachmentStore { } impl AttachmentStore { - pub fn new(data_dir: &PathBuf, database: Arc>, daemon_event_sink: Sender) -> AttachmentStore { - let store_path = data_dir.join("attachments"); + pub fn get_default_store_path() -> PathBuf { + let data_dir = Daemon::get_data_dir().expect("Unable to get data path"); + data_dir.join("attachments") + } + + pub fn new(database: Arc>, daemon_event_sink: Sender) -> AttachmentStore { + let store_path = Self::get_default_store_path(); log::info!(target: target::ATTACHMENTS, "Attachment store path: {}", store_path.display()); // Create the attachment store if it doesn't exist @@ -98,11 +87,16 @@ impl AttachmentStore { self.event_sink.take().unwrap() } - fn get_attachment(&self, guid: &String, preview: bool) -> Attachment { - let base_path = self.store_path.join(guid); + fn get_attachment(&self, guid: &String) -> Attachment { + Self::get_attachment_impl(&self.store_path, guid) + } + + pub fn get_attachment_impl(store_path: &PathBuf, guid: &String) -> Attachment { + let base_path = store_path.join(guid); Attachment { guid: guid.to_owned(), base_path: base_path, + metadata: None, } } @@ -147,7 +141,7 @@ impl AttachmentStore { match event { AttachmentStoreEvent::QueueDownloadAttachment(guid, preview) => { - let attachment = self.get_attachment(&guid, preview); + let attachment = self.get_attachment(&guid); if !attachment.is_downloaded(preview) { self.download_attachment(&attachment, preview).await.unwrap_or_else(|e| { log::error!(target: target::ATTACHMENTS, "Error downloading attachment: {}", e); @@ -158,7 +152,7 @@ impl AttachmentStore { } AttachmentStoreEvent::GetAttachmentInfo(guid, reply) => { - let attachment = self.get_attachment(&guid, false); + let attachment = self.get_attachment(&guid); reply.send(attachment).unwrap(); } } diff --git a/kordophoned/src/daemon/events.rs b/kordophoned/src/daemon/events.rs index 2e11ff2..627461e 100644 --- a/kordophoned/src/daemon/events.rs +++ b/kordophoned/src/daemon/events.rs @@ -3,10 +3,10 @@ use uuid::Uuid; use kordophone::model::ConversationID; use kordophone::model::OutgoingMessage; -use kordophone_db::models::{Conversation, Message}; +use kordophone_db::models::Conversation; use crate::daemon::settings::Settings; -use crate::daemon::Attachment; +use crate::daemon::{Attachment, Message}; pub type Reply = oneshot::Sender; diff --git a/kordophoned/src/daemon/mod.rs b/kordophoned/src/daemon/mod.rs index 4c444e4..c735034 100644 --- a/kordophoned/src/daemon/mod.rs +++ b/kordophoned/src/daemon/mod.rs @@ -23,7 +23,7 @@ use uuid::Uuid; use kordophone_db::{ database::{Database, DatabaseAccess}, - models::{Conversation, Message}, + models::Conversation, }; use kordophone::api::http_client::HTTPAPIClient; @@ -41,8 +41,11 @@ mod post_office; use post_office::Event as PostOfficeEvent; use post_office::PostOffice; +mod models; +pub use models::Attachment; +pub use models::Message; + mod attachment_store; -pub use attachment_store::Attachment; pub use attachment_store::AttachmentStore; pub use attachment_store::AttachmentStoreEvent; @@ -147,8 +150,7 @@ impl Daemon { } // Attachment store - let data_path = Self::get_data_dir().expect("Unable to get data path"); - let mut attachment_store = AttachmentStore::new(&data_path, self.database.clone(), self.event_sender.clone()); + let mut attachment_store = AttachmentStore::new(self.database.clone(), self.event_sender.clone()); self.attachment_store_sink = Some(attachment_store.get_event_sink()); tokio::spawn(async move { attachment_store.run().await; @@ -270,7 +272,7 @@ impl Daemon { self.database .lock() .await - .with_repository(|r| r.insert_message(&conversation_id, message)) + .with_repository(|r| r.insert_message(&conversation_id, message.into())) .await .unwrap(); @@ -355,6 +357,7 @@ impl Daemon { r.get_messages_for_conversation(&conversation_id) .unwrap() .into_iter() + .map(|m| m.into()) // Convert db::Message to daemon::Message .chain(outgoing_messages.into_iter().map(|m| m.into())) .collect() }) diff --git a/kordophoned/src/daemon/models/attachment.rs b/kordophoned/src/daemon/models/attachment.rs new file mode 100644 index 0000000..51ab0c9 --- /dev/null +++ b/kordophoned/src/daemon/models/attachment.rs @@ -0,0 +1,64 @@ +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct AttachmentMetadata { + pub attribution_info: Option, +} + +#[derive(Debug, Clone)] +pub struct AttributionInfo { + pub width: Option, + pub height: Option, +} + +#[derive(Debug, Clone)] +pub struct Attachment { + pub guid: String, + pub base_path: PathBuf, + pub metadata: Option, +} + +impl Attachment { + pub fn get_path(&self, preview: bool) -> PathBuf { + self.base_path.with_extension(if preview { "preview" } else { "full" }) + } + + pub fn is_downloaded(&self, preview: bool) -> bool { + std::fs::exists(&self.get_path(preview)) + .expect(format!("Wasn't able to check for the existence of an attachment file path at {}", &self.get_path(preview).display()).as_str()) + } +} + +impl From for AttachmentMetadata { + fn from(metadata: kordophone::model::message::AttachmentMetadata) -> Self { + Self { + attribution_info: metadata.attribution_info.map(|info| info.into()), + } + } +} + +impl From for AttributionInfo { + fn from(info: kordophone::model::message::AttributionInfo) -> Self { + Self { + width: info.width, + height: info.height, + } + } +} + +impl From for kordophone::model::message::AttachmentMetadata { + fn from(metadata: AttachmentMetadata) -> Self { + Self { + attribution_info: metadata.attribution_info.map(|info| info.into()), + } + } +} + +impl From for kordophone::model::message::AttributionInfo { + fn from(info: AttributionInfo) -> Self { + Self { + width: info.width, + height: info.height, + } + } +} \ No newline at end of file diff --git a/kordophoned/src/daemon/models/message.rs b/kordophoned/src/daemon/models/message.rs new file mode 100644 index 0000000..da40f80 --- /dev/null +++ b/kordophoned/src/daemon/models/message.rs @@ -0,0 +1,217 @@ +use chrono::NaiveDateTime; +use chrono::DateTime; + +use std::collections::HashMap; +use uuid::Uuid; +use kordophone::model::message::AttachmentMetadata; +use kordophone::model::outgoing_message::OutgoingMessage; +use crate::daemon::models::Attachment; +use crate::daemon::attachment_store::AttachmentStore; + +#[derive(Clone, Debug)] +pub enum Participant { + Me, + Remote { + id: Option, + display_name: String, + }, +} + +impl From for Participant { + fn from(display_name: String) -> Self { + Participant::Remote { + id: None, + display_name, + } + } +} + +impl From<&str> for Participant { + fn from(display_name: &str) -> Self { + Participant::Remote { + id: None, + display_name: display_name.to_string(), + } + } +} + +impl From for Participant { + fn from(participant: kordophone_db::models::Participant) -> Self { + match participant { + kordophone_db::models::Participant::Me => Participant::Me, + kordophone_db::models::Participant::Remote { id, display_name } => { + Participant::Remote { id, display_name } + } + } + } +} + +impl Participant { + pub fn display_name(&self) -> String { + match self { + Participant::Me => "(Me)".to_string(), + Participant::Remote { display_name, .. } => display_name.clone(), + } + } +} + +#[derive(Clone, Debug)] +pub struct Message { + pub id: String, + pub sender: Participant, + pub text: String, + pub date: NaiveDateTime, + pub attachments: Vec, +} + +impl Message { + pub fn builder() -> MessageBuilder { + MessageBuilder::new() + } +} + +fn attachments_from(file_transfer_guids: &Vec, attachment_metadata: &Option>) -> Vec { + file_transfer_guids + .iter() + .map(|guid| { + let mut attachment = AttachmentStore::get_attachment_impl(&AttachmentStore::get_default_store_path(), guid); + attachment.metadata = match attachment_metadata { + Some(attachment_metadata) => attachment_metadata.get(guid).cloned().map(|metadata| metadata.into()), + None => None, + }; + + attachment + }) + .collect() +} + +impl From for Message { + fn from(message: kordophone_db::models::Message) -> Self { + let attachments = attachments_from(&message.file_transfer_guids, &message.attachment_metadata); + Self { + id: message.id, + sender: message.sender.into(), + text: message.text, + date: message.date, + attachments, + } + } +} + +impl From for kordophone_db::models::Message { + fn from(message: Message) -> Self { + Self { + id: message.id, + sender: match message.sender { + Participant::Me => kordophone_db::models::Participant::Me, + Participant::Remote { id, display_name } => { + kordophone_db::models::Participant::Remote { id, display_name } + } + }, + text: message.text, + date: message.date, + file_transfer_guids: message.attachments.iter().map(|a| a.guid.clone()).collect(), + attachment_metadata: { + let metadata_map: HashMap = message.attachments + .iter() + .filter_map(|a| a.metadata.as_ref().map(|m| (a.guid.clone(), m.clone().into()))) + .collect(); + if metadata_map.is_empty() { None } else { Some(metadata_map) } + }, + } + } +} + +impl From for Message { + fn from(message: kordophone::model::Message) -> Self { + let attachments = attachments_from(&message.file_transfer_guids, &message.attachment_metadata); + Self { + id: message.guid, + sender: match message.sender { + Some(sender) => Participant::Remote { + id: None, + display_name: sender, + }, + None => Participant::Me, + }, + text: message.text, + date: DateTime::from_timestamp( + message.date.unix_timestamp(), + message.date.unix_timestamp_nanos() + .try_into() + .unwrap_or(0), + ) + .unwrap() + .naive_local(), + attachments, + } + } +} + +impl From<&OutgoingMessage> for Message { + fn from(value: &OutgoingMessage) -> Self { + Self { + id: value.guid.to_string(), + sender: Participant::Me, + text: value.text.clone(), + date: value.date, + attachments: Vec::new(), // Outgoing messages don't have attachments initially + } + } +} + +pub struct MessageBuilder { + id: Option, + sender: Option, + text: Option, + date: Option, + attachments: Vec, +} + +impl Default for MessageBuilder { + fn default() -> Self { + Self::new() + } +} + +impl MessageBuilder { + pub fn new() -> Self { + Self { + id: None, + sender: None, + text: None, + date: None, + attachments: Vec::new(), + } + } + + pub fn sender(mut self, sender: Participant) -> Self { + self.sender = Some(sender); + self + } + + pub fn text(mut self, text: String) -> Self { + self.text = Some(text); + self + } + + pub fn date(mut self, date: NaiveDateTime) -> Self { + self.date = Some(date); + self + } + + pub fn attachments(mut self, attachments: Vec) -> Self { + self.attachments = attachments; + self + } + + pub fn build(self) -> Message { + Message { + id: self.id.unwrap_or_else(|| Uuid::new_v4().to_string()), + sender: self.sender.unwrap_or(Participant::Me), + text: self.text.unwrap_or_default(), + date: self.date.unwrap_or_else(|| chrono::Utc::now().naive_utc()), + attachments: self.attachments, + } + } +} \ No newline at end of file diff --git a/kordophoned/src/daemon/models/mod.rs b/kordophoned/src/daemon/models/mod.rs new file mode 100644 index 0000000..8a63c4a --- /dev/null +++ b/kordophoned/src/daemon/models/mod.rs @@ -0,0 +1,5 @@ +pub mod attachment; +pub mod message; + +pub use attachment::Attachment; +pub use message::Message; \ No newline at end of file diff --git a/kordophoned/src/dbus/server_impl.rs b/kordophoned/src/dbus/server_impl.rs index 2e7155f..f66f676 100644 --- a/kordophoned/src/dbus/server_impl.rs +++ b/kordophoned/src/dbus/server_impl.rs @@ -138,29 +138,50 @@ impl DbusRepository for ServerImpl { arg::Variant(Box::new(msg.sender.display_name())), ); - // Add file transfer GUIDs if present - if !msg.file_transfer_guids.is_empty() { - match serde_json::to_string(&msg.file_transfer_guids) { - Ok(json_str) => { - map.insert("file_transfer_guids".into(), arg::Variant(Box::new(json_str))); + // Add attachments array + let attachments: Vec = msg.attachments + .into_iter() + .map(|attachment| { + let mut attachment_map = arg::PropMap::new(); + attachment_map.insert("guid".into(), arg::Variant(Box::new(attachment.guid.clone()))); + + // Get attachment paths and download status + let path = attachment.get_path(false); + let preview_path = attachment.get_path(true); + let downloaded = attachment.is_downloaded(false); + let preview_downloaded = attachment.is_downloaded(true); + + attachment_map.insert("path".into(), arg::Variant(Box::new(path.to_string_lossy().to_string()))); + attachment_map.insert("preview_path".into(), arg::Variant(Box::new(preview_path.to_string_lossy().to_string()))); + attachment_map.insert("downloaded".into(), arg::Variant(Box::new(downloaded))); + attachment_map.insert("preview_downloaded".into(), arg::Variant(Box::new(preview_downloaded))); + + // Add metadata if present + if let Some(ref metadata) = attachment.metadata { + let mut metadata_map = arg::PropMap::new(); + + // Add attribution_info if present + if let Some(ref attribution_info) = metadata.attribution_info { + let mut attribution_map = arg::PropMap::new(); + + if let Some(width) = attribution_info.width { + attribution_map.insert("width".into(), arg::Variant(Box::new(width as i32))); + } + if let Some(height) = attribution_info.height { + attribution_map.insert("height".into(), arg::Variant(Box::new(height as i32))); + } + + metadata_map.insert("attribution_info".into(), arg::Variant(Box::new(attribution_map))); + } + + attachment_map.insert("metadata".into(), arg::Variant(Box::new(metadata_map))); } - Err(e) => { - log::warn!("Failed to serialize file transfer GUIDs for message {}: {}", msg_id, e); - } - } - } + + attachment_map + }) + .collect(); - // Add attachment metadata if present - if let Some(ref attachment_metadata) = msg.attachment_metadata { - match serde_json::to_string(attachment_metadata) { - Ok(json_str) => { - map.insert("attachment_metadata".into(), arg::Variant(Box::new(json_str))); - } - Err(e) => { - log::warn!("Failed to serialize attachment metadata for message {}: {}", msg_id, e); - } - } - } + map.insert("attachments".into(), arg::Variant(Box::new(attachments))); map })