diff --git a/src/commands/login.rs b/src/commands/login.rs new file mode 100644 index 0000000..95af2c0 --- /dev/null +++ b/src/commands/login.rs @@ -0,0 +1,55 @@ +use crate::{ + wf::{WriteFreely, WriteFreelyCredentials}, + Cli, Command, +}; +use anyhow::{anyhow, Result}; +use std::result::Result as StdResult; +use url::Url; + +pub(crate) struct LoginCommand<'a> { + wf_url: &'a str, + username: &'a str, + password: &'a str, +} + +impl<'a> TryFrom<&'a Cli> for LoginCommand<'a> { + type Error = anyhow::Error; + + fn try_from(cli: &'a Cli) -> StdResult { + if let Some(Command::Login { + ref wf_url, + ref username, + ref password, + }) = cli.command + { + Ok(Self { + wf_url, + username, + password, + }) + } else { + Err(anyhow!("Not a valid login command")) + } + } +} + +impl<'a> From<&'a LoginCommand<'a>> for WriteFreelyCredentials<'a> { + fn from(cmd: &'a LoginCommand<'a>) -> Self { + WriteFreelyCredentials::UsernameAndPassword(cmd.username, cmd.password) + } +} + +impl LoginCommand<'_> { + pub async fn execute(self) -> Result<()> { + let wf_url = Url::parse(self.wf_url)?; + let creds = WriteFreelyCredentials::from(&self); + let wf_client = WriteFreely::new(&wf_url, &self.username, &creds).await?; + + println!( + "{}", + wf_client.access_token().unwrap_or("[No Token Returned]") + ); + + Ok(()) + } +} diff --git a/src/commands/logout.rs b/src/commands/logout.rs new file mode 100644 index 0000000..f681aa5 --- /dev/null +++ b/src/commands/logout.rs @@ -0,0 +1,56 @@ +use crate::wf::{WriteFreely, WriteFreelyCredentials}; +use crate::{Cli, Command}; +use anyhow::{anyhow, Result}; +use std::result::Result as StdResult; +use url::Url; + +pub(crate) struct LogoutCommand<'a> { + wf_url: &'a str, + wf_alias: &'a str, + wf_access_token: &'a str, +} + +impl<'a> TryFrom<&'a Cli> for LogoutCommand<'a> { + type Error = anyhow::Error; + fn try_from(cli: &'a Cli) -> StdResult { + if let Some(Command::Logout { ref wf_url }) = cli.command { + let wf_access_token = cli + .wf_access_token + .as_deref() + .ok_or(anyhow!("WriteFreely access token required"))?; + + let wf_alias = cli + .wf_alias + .as_deref() + .ok_or(anyhow!("WriteFreely alias required"))?; + + Ok(Self { + wf_url, + wf_access_token, + wf_alias, + }) + } else { + Err(anyhow!("Not a valid logout command")) + } + } +} + +impl<'a> From<&LogoutCommand<'a>> for WriteFreelyCredentials<'a> { + fn from(cmd: &LogoutCommand<'a>) -> Self { + WriteFreelyCredentials::AccessToken(cmd.wf_access_token) + } +} + +impl LogoutCommand<'_> { + pub async fn execute(self) -> Result<()> { + let wf_url = Url::parse(self.wf_url)?; + let creds = WriteFreelyCredentials::from(&self); + + let wf_client = WriteFreely::new(&wf_url, &self.wf_alias, &creds).await?; + wf_client.logout().await?; + + println!("Successfully logged out from {}", wf_url); + + Ok(()) + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..e7e55a9 --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod sync; +pub(crate) mod login; +pub(crate) mod logout; diff --git a/src/commands/sync.rs b/src/commands/sync.rs new file mode 100644 index 0000000..fc24eb0 --- /dev/null +++ b/src/commands/sync.rs @@ -0,0 +1,123 @@ +use anyhow::{anyhow, Result}; +use url::Url; + +use crate::gemfeed::{Gemfeed, GemfeedParserSettings}; +use crate::sanitization; +use crate::wf::{WriteFreely, WriteFreelyCredentials}; +use crate::Cli; +use crate::Command; +use std::collections::HashSet; + +struct SanitizeConfig<'a> { + strip_before_marker: &'a Option, + strip_after_marker: &'a Option, +} + +pub(crate) struct SyncCommand<'a> { + parser_settings: GemfeedParserSettings<'a>, + wf_alias: &'a str, + wf_token: &'a str, + gemlog_url: &'a str, + wf_url: &'a str, + config: SanitizeConfig<'a>, +} + +impl<'a> TryFrom<&'a Cli> for SyncCommand<'a> { + type Error = anyhow::Error; + + fn try_from(cli: &'a Cli) -> std::prelude::v1::Result { + if let Some(Command::Sync { + ref wf_url, + ref gemlog_url, + ref strip_before_marker, + ref strip_after_marker, + }) = cli.command + { + let wf_token = cli + .wf_access_token + .as_deref() + .ok_or(anyhow!("WriteFreely access token required"))?; + + let sanitize_cfg = SanitizeConfig { + strip_before_marker, + strip_after_marker, + }; + + Ok(Self { + wf_url, + gemlog_url, + wf_token, + config: sanitize_cfg, + parser_settings: GemfeedParserSettings::from(cli), + wf_alias: cli.wf_alias.as_deref().expect("WriteFreely Alias required"), + }) + } else { + Err(anyhow!("Invalid sync command")) + } + } +} + +impl SyncCommand<'_> { + pub async fn execute(self) -> Result<()> { + let gemfeed_url = Url::parse(self.gemlog_url)?; + let wf_url = Url::parse(self.wf_url)?; + + let wf_creds = WriteFreelyCredentials::AccessToken(self.wf_token); + let wf_client = WriteFreely::new(&wf_url, self.wf_alias, &wf_creds).await?; + + let mut gemfeed = Gemfeed::load_with_settings(&gemfeed_url, &self.parser_settings)?; + sync_gemlog(&self.config, &mut gemfeed, &wf_client).await?; + + Ok(()) + } +} + +async fn sync_gemlog( + config: &SanitizeConfig<'_>, + gemfeed: &mut Gemfeed, + wf: &WriteFreely, +) -> Result<()> { + println!( + "Beginning sync of posts for WriteFreely user: {}", + wf.user().await? + ); + + let wf_slugs: HashSet<_> = wf.slugs().await?.into_iter().collect(); + let gemfeed_slugs: HashSet<_> = gemfeed.slugs().into_iter().collect(); + let slugs_to_post: Vec<_> = gemfeed_slugs.difference(&wf_slugs).collect(); + + sanitize_gemlogs(gemfeed, config)?; + + let gemlogs_to_post = slugs_to_post + .into_iter() + .flat_map(|slug| gemfeed.find_entry_by_slug(slug)); + + let mut count = 0; + for entry in gemlogs_to_post { + let post = wf.create_post(entry).await?; + count += 1; + println!( + "Created post: {} [title={}]", + post.id, + post.title.unwrap_or_default() + ); + } + + println!("Post synchronization complete [posts synced={}]", count); + + Ok(()) +} + +fn sanitize_gemlogs(gemfeed: &mut Gemfeed, config: &SanitizeConfig) -> Result<()> { + for entry in gemfeed.entries_mut() { + if let Some(ref before_marker) = config.strip_before_marker { + sanitization::strip_before(entry, before_marker)?; + } + + if let Some(ref after_marker) = config.strip_after_marker { + sanitization::strip_after(entry, after_marker)?; + } + } + + Ok(()) +} diff --git a/src/gemfeed.rs b/src/gemfeed.rs index a199561..fc34cfe 100644 --- a/src/gemfeed.rs +++ b/src/gemfeed.rs @@ -18,6 +18,10 @@ use crate::Cli; static GEMFEED_POST_REGEX: Lazy = Lazy::new(|| Regex::new(r#"(\d\d\d\d-\d\d-\d\d)"#).unwrap()); +fn is_header(level: usize) -> bool { + // For some reason, Germ reports headers with an emoji as header level 0. + level == 0 || level == 1 +} fn is_gemfeed_post_link(node: &GemtextNode) -> bool { match node { GemtextNode::Link { @@ -162,7 +166,7 @@ impl Gemfeed { fn load_from_ast(url: &Url, feed: &GemtextAst) -> Result { let feed_title = feed.inner().iter().find_map(|node| match node { - GemtextNode::Heading { level, text } if *level == (1 as usize) => Some(text), + GemtextNode::Heading { level, text } if is_header(*level) => Some(text), _ => None, }); @@ -425,6 +429,58 @@ mod gemfeed_tests { Ok(()) } + #[test] + fn parse_gemfeed_valid_if_has_title() -> Result<()> { + let gemfeed: String = r#" + # My Gemfeed + + This is a gemfeed with a title. + => atom.xml Atom Feed + + ## Posts + + => post2.gmi 2023-03-05 Post 2 + => post1.gmi 2023-02-01 Post 1 + "# + .lines() + .map(|line| line.trim_start()) + .map(|line| format!("{}\n", line)) + .collect(); + + let base_url = Url::parse("gemini://example.com/posts")?; + let ast = GemtextAst::from_string(gemfeed); + let result = Gemfeed::load_from_ast(&base_url, &ast); + + assert!(matches!(result, Ok(_))); + Ok(()) + } + + #[test] + fn parse_gemfeed_valid_if_has_title_with_emoji() -> Result<()> { + let gemfeed: String = r#" + # 🖊️ My Gemfeed + + This is a gemfeed with a title. + => atom.xml Atom Feed + + ## Posts + + => post2.gmi 2023-03-05 Post 2 + => post1.gmi 2023-02-01 Post 1 + "# + .lines() + .map(|line| line.trim_start()) + .map(|line| format!("{}\n", line)) + .collect(); + + let base_url = Url::parse("gemini://example.com/posts")?; + let ast = GemtextAst::from_string(gemfeed); + let result = Gemfeed::load_from_ast(&base_url, &ast); + + assert!(matches!(result, Ok(_))); + Ok(()) + } + #[test] fn parse_gemfeed_ignores_non_post_links() -> Result<()> { let gemfeed: String = r#" diff --git a/src/main.rs b/src/main.rs index e980183..19b0641 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,13 @@ -use crate::{gemfeed::Gemfeed, wf::WriteFreelyCredentials}; +use crate::commands::sync::SyncCommand; use clap::{Parser, Subcommand}; -use gemfeed::GemfeedParserSettings; -use std::collections::HashSet; -use url::Url; +use commands::{login::LoginCommand, logout::LogoutCommand}; use anyhow::Result; -use wf::WriteFreely; mod gemfeed; mod sanitization; mod wf; +mod commands; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] @@ -76,143 +74,15 @@ enum Command { }, } -struct SanitizeConfig<'a> { - strip_before_marker: &'a Option, - strip_after_marker: &'a Option, -} - -fn sanitize_gemlogs(gemfeed: &mut Gemfeed, config: &SanitizeConfig) -> Result<()> { - for entry in gemfeed.entries_mut() { - if let Some(ref before_marker) = config.strip_before_marker { - sanitization::strip_before(entry, before_marker)?; - } - - if let Some(ref after_marker) = config.strip_after_marker { - sanitization::strip_after(entry, after_marker)?; - } - } - - Ok(()) -} - -async fn sync( - cli: &Cli, - gemlog_url: &str, - wf_url: &str, - config: &SanitizeConfig<'_>, -) -> Result<()> { - let wf_token = cli - .wf_access_token - .as_deref() - .expect("WriteFreely access token required"); - - let settings = GemfeedParserSettings::from(cli); - let gemfeed_url = Url::parse(gemlog_url)?; - let wf_url = Url::parse(wf_url)?; - - let wf_creds = WriteFreelyCredentials::AccessToken(wf_token); - let wf_alias = cli.wf_alias.as_deref().expect("WriteFreely Alias required"); - let wf_client = wf::WriteFreely::new(&wf_url, wf_alias, &wf_creds).await?; - - let mut gemfeed = Gemfeed::load_with_settings(&gemfeed_url, &settings)?; - sync_gemlog(&config, &mut gemfeed, &wf_client).await?; - - Ok(()) -} - -async fn sync_gemlog( - config: &SanitizeConfig<'_>, - gemfeed: &mut Gemfeed, - wf: &WriteFreely, -) -> Result<()> { - println!( - "Beginning sync of posts for WriteFreely user: {}", - wf.user().await? - ); - - let wf_slugs: HashSet<_> = wf.slugs().await?.into_iter().collect(); - let gemfeed_slugs: HashSet<_> = gemfeed.slugs().into_iter().collect(); - let slugs_to_post: Vec<_> = gemfeed_slugs.difference(&wf_slugs).collect(); - - sanitize_gemlogs(gemfeed, config)?; - - let gemlogs_to_post = slugs_to_post - .into_iter() - .flat_map(|slug| gemfeed.find_entry_by_slug(slug)); - - let mut count = 0; - for entry in gemlogs_to_post { - let post = wf.create_post(entry).await?; - count += 1; - println!( - "Created post: {} [title={}]", - post.id, - post.title.unwrap_or_default() - ); - } - - println!("Post synchronization complete [posts synced={}]", count); - - Ok(()) -} - -async fn wf_login(wf_url: &str, username: &str, password: &str) -> Result<()> { - let wf_url = Url::parse(wf_url)?; - let creds = WriteFreelyCredentials::UsernameAndPassword(username, password); - - let wf_client = wf::WriteFreely::new(&wf_url, &username, &creds).await?; - - println!( - "{}", - wf_client.access_token().unwrap_or("[No Token Returned]") - ); - - Ok(()) -} - -async fn wf_logout(wf_url: &str, wf_alias: &str, access_token: &str) -> Result<()> { - let wf_url = Url::parse(wf_url)?; - let creds = WriteFreelyCredentials::AccessToken(access_token); - - let wf_client = wf::WriteFreely::new(&wf_url, &wf_alias, &creds).await?; - wf_client.logout().await?; - - println!("Successfully logged out from {}", wf_url); - - Ok(()) -} - #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); if let Some(ref cmd) = cli.command { match cmd { - Command::Login { - ref wf_url, - ref username, - ref password, - } => wf_login(wf_url, username, password).await, - Command::Logout { ref wf_url } => { - wf_logout( - wf_url, - &cli.wf_alias.as_deref().expect("WriteFreely alias required"), - &cli.wf_access_token.expect("Access token required"), - ) - .await - } - Command::Sync { - wf_url, - gemlog_url, - strip_before_marker, - strip_after_marker, - } => { - let sanitize_cfg = SanitizeConfig { - strip_before_marker, - strip_after_marker, - }; - sync(&cli, gemlog_url, wf_url, &sanitize_cfg).await - } + Command::Login { .. } => LoginCommand::try_from(&cli)?.execute().await, + Command::Logout { .. } => LogoutCommand::try_from(&cli)?.execute().await, + Command::Sync { .. } => SyncCommand::try_from(&cli)?.execute().await, } } else { Ok(())