summary refs log tree commit diff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/lib.rs71
-rw-r--r--src/micropub.rs81
-rw-r--r--src/util.rs27
3 files changed, 173 insertions, 6 deletions
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<gtk::gio::ApplicationBusyGuard>,
+    #[no_eq] submit_busy_guard: Option<gtk::gio::ApplicationBusyGuard>,
 
     #[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<micropub::Client>,
 }
 
 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<Self> {
         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<Config, Error> {
+        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<glib::Uri, Error> {
+        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::<MicropubError>(&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<Item = (String, String)>) -> 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(())
+    }
+}