From 7e403c00af4956a3996e5570eb0aa578745c520d Mon Sep 17 00:00:00 2001 From: Vika Date: Tue, 20 Aug 2024 18:41:40 +0300 Subject: Send posts made in the post composer --- src/lib.rs | 71 +++++++++++++++++++++++++++++++++++++++++++++----- src/micropub.rs | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/util.rs | 27 +++++++++++++++++++ 3 files changed, 173 insertions(+), 6 deletions(-) create mode 100644 src/micropub.rs create mode 100644 src/util.rs (limited to 'src') diff --git a/src/lib.rs b/src/lib.rs index 5ee80c2..f5cb19e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,8 @@ use gtk::GridLayoutChild; use relm4::{gtk, prelude::{AsyncComponent, AsyncComponentParts}, AsyncComponentSender, RelmWidgetExt}; mod widgets; +pub mod micropub; +pub mod util; pub const APPLICATION_ID: &str = "xyz.vikanezrimaya.kittybox.Bowl"; #[tracker::track] @@ -13,6 +15,7 @@ pub struct PostComposerModel { /// Busy guard for generating the summary using an LLM. /// Makes the summary field read-only and blocks the Smart Summary button. #[no_eq] ai_summary_busy_guard: Option, + #[no_eq] submit_busy_guard: Option, #[do_not_track] name_buffer: gtk::EntryBuffer, #[do_not_track] summary_buffer: gtk::EntryBuffer, @@ -20,6 +23,8 @@ pub struct PostComposerModel { #[do_not_track] wide_layout: gtk::GridLayout, #[do_not_track] narrow_layout: gtk::BoxLayout, + + #[do_not_track] micropub: Arc, } impl PostComposerModel { @@ -59,7 +64,7 @@ impl AsyncComponent for PostComposerModel { /// The type of the messages that this component can send. type Output = (); /// The type of data with which this component will be initialized. - type Init = (); + type Init = micropub::Client; /// The type of the command outputs that this component can receive. type CommandOutput = PostComposerCommandOutput; @@ -74,6 +79,8 @@ impl AsyncComponent for PostComposerModel { send_button = gtk::Button { set_label: "Post", connect_clicked => Self::Input::Submit, + #[track = "model.changed(Self::submit_busy_guard())"] + set_sensitive: model.submit_busy_guard.is_none(), }, bar = adw::HeaderBar::new() { @@ -106,6 +113,8 @@ impl AsyncComponent for PostComposerModel { gtk::Entry { set_hexpand: true, set_buffer: &model.name_buffer, + #[track = "model.changed(Self::submit_busy_guard())"] + set_sensitive: model.submit_busy_guard.is_none(), }, #[name = "summary_label"] @@ -125,15 +134,15 @@ impl AsyncComponent for PostComposerModel { gtk::Entry { set_hexpand: true, set_buffer: &model.summary_buffer, - #[track = "model.changed(Self::ai_summary_busy_guard())"] - set_sensitive: model.ai_summary_busy_guard.is_none(), + #[track = "model.changed(Self::ai_summary_busy_guard() | Self::submit_busy_guard())"] + set_sensitive: model.ai_summary_busy_guard.is_none() && model.submit_busy_guard.is_none(), }, #[name = "ai_summary_button"] gtk::Button { connect_clicked => Self::Input::AiGenSummaryBegin, - #[track = "model.changed(Self::ai_summary_busy_guard())"] - set_sensitive: model.ai_summary_busy_guard.is_none(), + #[track = "model.changed(Self::ai_summary_busy_guard() | Self::submit_busy_guard())"] + set_sensitive: model.ai_summary_busy_guard.is_none() && model.submit_busy_guard.is_none(), set_tooltip: "Smart Summary\nAsk a language model to summarize the content of your post in a single sentence.", gtk::Stack { @@ -191,6 +200,9 @@ impl AsyncComponent for PostComposerModel { set_right_margin: 8, set_top_margin: 8, set_bottom_margin: 8, + + #[track = "model.changed(Self::submit_busy_guard())"] + set_sensitive: model.submit_busy_guard.is_none(), }, }, @@ -221,6 +233,7 @@ impl AsyncComponent for PostComposerModel { ) -> AsyncComponentParts { let model = PostComposerModel { ai_summary_busy_guard: None, + submit_busy_guard: None, name_buffer: gtk::EntryBuffer::default(), summary_buffer: gtk::EntryBuffer::default(), @@ -229,6 +242,8 @@ impl AsyncComponent for PostComposerModel { wide_layout: gtk::GridLayout::new(), narrow_layout: gtk::BoxLayout::new(gtk::Orientation::Vertical), + micropub: Arc::new(init), + tracker: Default::default() }; let widgets = view_output!(); @@ -293,7 +308,51 @@ impl AsyncComponent for PostComposerModel { self.summary_buffer.insert_text(self.summary_buffer.length(), text); }, PostComposerInput::Submit => { - log::warn!("Submitting posts is not yet implemented."); + self.set_submit_busy_guard( + Some(relm4::main_adw_application().mark_busy()) + ); + // Update view to lock the interface up + self.update_view(widgets, sender.clone()); + self.reset(); + + use microformats::types::{Item, Class, KnownClass, PropertyValue}; + let mut mf2 = Item::new(vec![Class::Known(KnownClass::Entry)]); + if self.name_buffer.length() > 0 { + let proplist = mf2.properties.entry("name".to_owned()).or_default(); + proplist.push(PropertyValue::Plain(self.name_buffer.text().into())); + } + if self.summary_buffer.length() > 0 { + let proplist = mf2.properties.entry("summary".to_owned()).or_default(); + proplist.push(PropertyValue::Plain(self.summary_buffer.text().into())); + } + + // TODO: tags + + { + let proplist = mf2.properties.entry("content".to_owned()).or_default(); + proplist.push(PropertyValue::Plain(self.content_buffer.text( + &self.content_buffer.start_iter(), + &self.content_buffer.end_iter(), + false + ).into())); + } + + log::warn!("sending post: {:?}", &mf2); + match self.micropub.send_post(mf2).await { + Ok(location) => { + self.name_buffer.set_text(""); + self.summary_buffer.set_text(""); + // TODO: tags + self.content_buffer.set_text(""); + // TODO: display toast! + log::warn!("post submitted: {}", location); + }, + Err(err) => { + // TODO: display error dialog + log::warn!("error sending the post: {}", err); + } + } + self.set_submit_busy_guard(None); }, } diff --git a/src/micropub.rs b/src/micropub.rs new file mode 100644 index 0000000..e855459 --- /dev/null +++ b/src/micropub.rs @@ -0,0 +1,81 @@ +use soup::prelude::*; +pub use kittybox_util::micropub::{Error as MicropubError, Config, QueryType}; + +#[derive(Debug)] +pub struct Client { + micropub: String, + access_token: String, + + //http: soup::Session, +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("glib error: {0}")] + Glib(#[from] glib::Error), + #[error("json serialization error: {0}")] + Json(#[from] serde_json::Error), + #[error("micropub error: {0}")] + Micropub(#[from] MicropubError), + #[error("micropub server did not return a location: header")] + NoLocationHeader +} + +impl Client { + pub fn new(uri: glib::Uri, token: String) -> Self { + Self { + micropub: uri.to_string(), + access_token: token, + + //http: soup::Session::new() + } + } + + pub async fn config(&self) -> Result { + let uri = glib::Uri::parse(&self.micropub, glib::UriFlags::NONE).unwrap(); + let uri = super::util::append_query( + &uri, [("q".to_string(), "config".to_string())] + ); + + let exch = soup::Message::from_uri("GET", &uri); + let headers = exch.request_headers().expect("SoupMessage with no headers"); + // TODO: create a SoupAuth subclass that allows pasting in a token + headers.append("Authorization", &format!("Bearer {}", self.access_token)); + + let http = soup::Session::new(); + let body = http.send_and_read_future(&exch, glib::Priority::DEFAULT).await?; + + Ok(serde_json::from_slice(&body)?) + } + + pub async fn send_post(&self, post: microformats::types::Item) -> Result { + let uri = glib::Uri::parse(&self.micropub, glib::UriFlags::NONE).unwrap(); + let exch = soup::Message::from_uri("POST", &uri); + let headers = exch.request_headers().expect("SoupMessage with no headers"); + headers.append("Authorization", &format!("Bearer {}", self.access_token)); + + exch.set_request_body_from_bytes(Some("application/json"), + Some(&glib::Bytes::from_owned(serde_json::to_vec(&post).unwrap())) + ); + + let http = soup::Session::new(); + let body = http.send_and_read_future(&exch, glib::Priority::DEFAULT).await?; + + match exch.status() { + soup::Status::Created | soup::Status::Accepted => { + let response_headers = exch.response_headers().expect("Successful SoupMessage with no response headers"); + let location = response_headers.one("Location").ok_or(Error::NoLocationHeader)?; + + Ok(glib::Uri::parse(&location, glib::UriFlags::NONE)?) + }, + soup::Status::InternalServerError | soup::Status::BadGateway | soup::Status::ServiceUnavailable => { + todo!("micropub server is down") + }, + _ => { + let error = serde_json::from_slice::(&body)?; + + Err(error.into()) + } + } + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..c3d5bd7 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,27 @@ +use std::borrow::Cow; + +pub fn append_query(uri: &glib::Uri, q: impl IntoIterator) -> glib::Uri { + let mut oq: Vec<(Cow<'static, str>, Cow<'static, str>)> = uri.query() + .map(|q| serde_urlencoded::from_str(&q).unwrap()) + .unwrap_or_default(); + oq.extend(q.into_iter().map(|(k, v)| (k.into(), v.into()))); + let nq = "?".to_owned() + &serde_urlencoded::to_string(oq).unwrap(); + uri.parse_relative(&nq, glib::UriFlags::NONE).unwrap() +} + +#[cfg(test)] +mod tests { + #[test] + fn test_append_query() -> Result<(), glib::Error> { + let uri = glib::Uri::parse("https://fireburn.ru/.kittybox/micropub?test=a", glib::UriFlags::NONE)?; + let q = [ + ("q".to_owned(), "config".to_owned()), + ("awoo".to_owned(), "nya".to_owned()), + ]; + assert_eq!( + super::append_query(&uri, q).to_string().as_str(), + "https://fireburn.ru/.kittybox/micropub?test=a&q=config&awoo=nya" + ); + Ok(()) + } +} -- cgit 1.4.1