Compare commits

..

3 Commits

Author SHA1 Message Date
projectmoon 58ce7e6ed5 Update version
continuous-integration/drone/push Build is passing Details
2024-03-22 12:28:10 +01:00
projectmoon c17e030d05 Emoji-related tests 2024-03-22 12:27:59 +01:00
projectmoon 9958783e62 Move command executions into their own files, and fix bug with emoji in gemfeed title. 2024-03-22 12:21:46 +01:00
8 changed files with 354 additions and 139 deletions

2
Cargo.lock generated
View File

@ -428,7 +428,7 @@ dependencies = [
[[package]]
name = "gemfreely"
version = "0.1.3"
version = "0.1.4"
dependencies = [
"anyhow",
"atom_syndication",

View File

@ -1,6 +1,6 @@
[package]
name = "gemfreely"
version = "0.1.3"
version = "0.1.4"
edition = "2021"
license = "AGPL-3.0-or-later"
description = "Synchronize Gemini protocol blogs to the Fediverse"

55
src/commands/login.rs Normal file
View File

@ -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<Self, Self::Error> {
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(())
}
}

56
src/commands/logout.rs Normal file
View File

@ -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<Self, Self::Error> {
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(())
}
}

3
src/commands/mod.rs Normal file
View File

@ -0,0 +1,3 @@
pub(crate) mod sync;
pub(crate) mod login;
pub(crate) mod logout;

123
src/commands/sync.rs Normal file
View File

@ -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<String>,
strip_after_marker: &'a Option<String>,
}
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<Self, Self::Error> {
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(())
}

View File

@ -18,6 +18,10 @@ use crate::Cli;
static GEMFEED_POST_REGEX: Lazy<regex::Regex> =
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<Gemfeed> {
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,110 @@ 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_at_start() -> 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_in_middle() -> 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_at_end() -> 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#"

View File

@ -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<String>,
strip_after_marker: &'a Option<String>,
}
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(())