use anyhow::Result; use chrono::Local; use html2md::parse_html; use megalodon::{ entities::{Account, Attachment, Status}, response::Response, Megalodon, }; use regex::{Captures, Regex}; use rustyline::DefaultEditor; use url::Url; use url_open::UrlOpen; pub(super) async fn format_status( client: &Box, depth: usize, account: &Account, status: &Status, ) -> Result { let time_prefix = quote_prefix(0); let ancestor_prefix = quote_prefix(depth); let ancestor = format!( "{time_prefix}{timestamp} {ancestor_prefix}{status}{attachments}", time_prefix = time_prefix, timestamp = status.created_at.with_timezone(&Local).format("%H:%M"), ancestor_prefix = ancestor_prefix, status = apply_block_quote(depth, parse_html(&status.content).trim()), attachments = format_attachments(depth, &status.media_attachments) ); let Response { json, .. } = client.get_status_context(status.id.clone(), None).await?; let descendant_prefix = quote_prefix(depth + 1); let thread = json .descendants .into_iter() .filter(|s| { s.in_reply_to_account_id == Some(account.id.clone()) && s.account.id == account.id }) .map(|status| { format!( "{ancestor_prefix} {ancestor_prefix}{timestamp} {descendant_prefix}{status}{attachments}", ancestor_prefix = ancestor_prefix, timestamp = status.created_at.with_timezone(&Local).format("%H:%M"), descendant_prefix = descendant_prefix, status = apply_block_quote(depth + 1, parse_html(&status.content).trim()), attachments = format_attachments(depth + 1, &status.media_attachments) ) }) .collect::>() .join("\n"); Ok(format!( "{}{}", ancestor, if thread.is_empty() { String::default() } else { format!("\n{}", thread) } )) } fn quote_prefix(depth: usize) -> String { if depth == 0 { String::default() } else { format!("{} ", vec![">"; depth].join("")) } } fn format_attachments(depth: usize, attachments: &[Attachment]) -> String { if attachments.is_empty() { String::default() } else { format!( "\n{}", attachments .iter() .map(|a| { Url::parse(&a.url).unwrap().open(); let mut editor = DefaultEditor::new().unwrap(); let src = if let Ok(line) = editor.readline("Filename: ") { line } else { a.url.clone() }; format!("{} ", quote_prefix(depth), src) }) .collect::>() .join("\n") ) } } // without processing the content per line, the block quote markdown is only added to the first // line, breaking the desired formatting fn apply_block_quote>(depth: usize, content: S) -> String { let prefix = quote_prefix(depth); let empty = Regex::new("\n\\s").expect("Failed to compiled regex for nested empty lines!"); let non_empty = Regex::new("\n(?P[^>\\s])").expect("Failed to compile regex for nested lines!"); // parsing some multiline content adds paragraph tags, convert those back to new lines to apply // the block quote level to each line let content = content.as_ref().replace("

", "\n\n"); let content = content.replace("

", ""); let content = content.replace("

", ""); // replace separately to avoid trailing spaces when replacing empty lines with the prefix let content = empty.replace_all(content.as_ref(), format!("\n{}\n", prefix.trim())); let content = non_empty.replace_all(&content, |c: &Captures| { format!("\n{}{}", prefix, c["initial"].to_string()) }); content.to_string() } #[cfg(test)] mod test { use crate::format::apply_block_quote; #[test] fn test_apply_bq() { assert_eq!( "Foo > > Bar" .to_owned(), apply_block_quote( 1, "Foo Bar" ) ); } #[test] fn test_apply_bq_deeper() { assert_eq!( "Foo >> >> Bar" .to_owned(), apply_block_quote( 2, "Foo Bar" ) ); } #[test] fn test_apply_bq_strip_ps() { assert_eq!( "Foo >> >> Bar" .to_owned(), apply_block_quote(2, "

Foo

Bar

") ); } }