kensho/src/format.rs

161 lines
4.7 KiB
Rust

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<dyn Megalodon + Send + Sync>,
depth: usize,
account: &Account,
status: &Status,
) -> Result<String> {
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::<Vec<String>>()
.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!("{} <img src=\"{}\" />", quote_prefix(depth), src)
})
.collect::<Vec<String>>()
.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<S: AsRef<str>>(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<initial>[^>\\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("</p><p>", "\n\n");
let content = content.replace("<p>", "");
let content = content.replace("</p>", "");
// 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, "<p>Foo</p><p>Bar</p>")
);
}
}