diff --git a/src/post.rs b/src/post.rs index 3d880e4..b2ea6cb 100644 --- a/src/post.rs +++ b/src/post.rs @@ -105,6 +105,10 @@ fn send_single_post_to_mastodon(mastodon: &Mastodon, toot: &NewStatus) -> Result let mut status_builder = StatusBuilder::new(); status_builder.status(&toot.text); + if let Some(spoiler) = toot.content_warning.as_ref() { + status_builder.sensitive(true); + status_builder.spoiler_text(spoiler); + } status_builder.media_ids(media_ids); if let Some(parent_id) = toot.in_reply_to_id { status_builder.in_reply_to(parent_id.to_string()); diff --git a/src/sync.rs b/src/sync.rs index 1b2aedf..758b884 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -3,11 +3,13 @@ use anyhow::Result; use egg_mode::tweet::Tweet; use egg_mode_text::character_count; use elefren::entities::status::Status; -use regex::Regex; +use regex::{Regex, RegexBuilder}; use std::collections::HashSet; use std::fs; use unicode_segmentation::UnicodeSegmentation; +const TWITTER_CW_REGEX: &'static str = r"^(RT .*: )?CW: (.*?\n)(.*)$"; + // Represents new status updates that should be posted to Twitter (tweets) and // Mastodon (toots). #[derive(Debug, Clone)] @@ -30,6 +32,7 @@ impl StatusUpdates { pub struct NewStatus { pub text: String, pub attachments: Vec, + pub content_warning: Option, // A list of further statuses that are new replies to this new status. Used // to sync threads. pub replies: Vec, @@ -99,7 +102,13 @@ pub fn determine_posts( // The tweet is not on Mastodon yet, check if we should post it. // Fetch the tweet text into a String object - let decoded_tweet = tweet_unshorten_decode(tweet); + let mut decoded_tweet = tweet_unshorten_decode(tweet); + + // Check for a content warning + let content_warning = tweet_find_content_warning(&decoded_tweet); + + // If present, strip CW from post text. Mastodon has a dedicated field for that + decoded_tweet = tweet_strip_content_warning(&decoded_tweet); // Check if hashtag filtering is enabled and if the tweet matches. if let Some(sync_hashtag) = &options.sync_hashtag_twitter { @@ -111,6 +120,7 @@ pub fn determine_posts( updates.toots.push(NewStatus { text: decoded_tweet, + content_warning, attachments: tweet_get_attachments(tweet), replies: Vec::new(), in_reply_to_id: None, @@ -128,7 +138,15 @@ pub fn determine_posts( // Skip reblogs when sync_reblogs is disabled continue; } - let fulltext = mastodon_toot_get_text(toot); + let mut fulltext = mastodon_toot_get_text(toot); + let mut content_warning: Option = None; + + // add content_warning if present + if toot.spoiler_text.len() > 0 { + fulltext = add_content_warning_to_post_text(&fulltext, toot.spoiler_text.as_str()); + content_warning = Some(toot.spoiler_text.clone()); + } + // If this is a reblog/boost then take the URL to the original toot. let post = match &toot.reblog { None => tweet_shorten(&fulltext, &toot.url), @@ -158,6 +176,7 @@ pub fn determine_posts( updates.tweets.push(NewStatus { text: post, + content_warning, attachments: toot_get_attachments(toot), replies: Vec::new(), in_reply_to_id: None, @@ -187,7 +206,13 @@ pub fn toot_and_tweet_are_equal(toot: &Status, tweet: &Tweet) -> bool { } // Strip markup from Mastodon toot and unify message for comparison. - let toot_text = unify_post_content(mastodon_toot_get_text(toot)); + let mut toot_text = unify_post_content(mastodon_toot_get_text(toot)); + + // if toot has a spoiler add for comparison + if toot.spoiler_text.len() > 0 { + toot_text = add_content_warning_to_post_text(&toot_text, toot.spoiler_text.as_str()); + } + // Replace those ugly t.co URLs in the tweet text. let tweet_text = unify_post_content(tweet_unshorten_decode(tweet)); @@ -544,6 +569,44 @@ fn truncate_option_string(stringy: Option, max_chars: usize) -> Option String { + format!("CW: {}\n\n{}", content_warning, post_text) +} + +/// searches for a inline-content warning +pub fn tweet_find_content_warning(decoded_tweet: &str) -> Option { + // Check for a content warning + let re = RegexBuilder::new(TWITTER_CW_REGEX) + .dot_matches_new_line(true) + .build() + .unwrap(); + + match re.captures(decoded_tweet) { + Some(captures) => Some(captures.get(2).unwrap().as_str().trim().to_string()), + None => None, + } +} + +/// strips an inline-content warning if present +pub fn tweet_strip_content_warning(decoded_tweet: &str) -> String { + // Check for a content warning + let re = RegexBuilder::new(TWITTER_CW_REGEX) + .dot_matches_new_line(true) + .build() + .unwrap(); + + match re.captures(decoded_tweet) { + Some(captures) => format!( + "{}{}", + captures.get(1).map_or("", |m| m.as_str()), + captures.get(3).unwrap().as_str(), + ), + None => decoded_tweet.to_string(), + } +} + #[cfg(test)] pub mod tests { @@ -1390,6 +1453,29 @@ QT test123: Original text" assert_eq!(tweet.attachments[0].alt_text, Some("a".repeat(1_000))); } + #[test] + fn tweet_add_content_warning() { + let fulltext = "blabalblabla"; + let spoiler_text = "this is a unittest"; + let expected = "CW: ".to_string() + spoiler_text + "\n\n" + fulltext; + + assert_eq!( + expected, + add_content_warning_to_post_text(fulltext, spoiler_text) + ); + } + + #[test] + fn tweet_recognize_content_warning() { + let expected = "Some Unittest dude"; + let decoded_tweet = "CW: ".to_string() + expected + "\nsome text"; + + assert_eq!( + expected, + tweet_find_content_warning(&decoded_tweet).unwrap() + ); + } + pub fn get_mastodon_status() -> Status { read_mastodon_status("src/mastodon_status.json") } diff --git a/src/thread_replies.rs b/src/thread_replies.rs index c3529f5..880a658 100644 --- a/src/thread_replies.rs +++ b/src/thread_replies.rs @@ -7,6 +7,7 @@ use elefren::entities::status::Status; struct Reply { pub id: u64, pub text: String, + pub content_warning: Option, pub attachments: Vec, pub in_reply_to_id: u64, } @@ -43,7 +44,13 @@ pub fn determine_thread_replies( // The tweet is not on Mastodon yet, check if we should post it. // Fetch the tweet text into a String object - let decoded_tweet = tweet_unshorten_decode(tweet); + let mut decoded_tweet = tweet_unshorten_decode(tweet); + + // Check for a content warning + let content_warning = tweet_find_content_warning(&decoded_tweet); + + // If present, strip CW from post text. Mastodon has a dedicated field for that + decoded_tweet = tweet_strip_content_warning(&decoded_tweet); // Check if hashtag filtering is enabled and if the tweet matches. if let Some(sync_hashtag) = &options.sync_hashtag_twitter { @@ -59,6 +66,7 @@ pub fn determine_thread_replies( Reply { id: tweet.id, text: decoded_tweet, + content_warning, attachments: tweet_get_attachments(tweet), in_reply_to_id: tweet.in_reply_to_status_id.unwrap_or_else(|| { panic!("Twitter reply ID missing on tweet {}", tweet.id) @@ -90,7 +98,14 @@ pub fn determine_thread_replies( } } - let fulltext = mastodon_toot_get_text(toot); + let mut fulltext = mastodon_toot_get_text(toot); + let mut content_warning: Option = None; + + // add content_warning if present + if toot.spoiler_text.len() > 0 { + fulltext = add_content_warning_to_post_text(&fulltext, toot.spoiler_text.as_str()); + content_warning = Some(toot.spoiler_text.clone()); + } // The toot is not on Twitter yet, check if we should post it. // Check if hashtag filtering is enabled and if the tweet matches. @@ -116,6 +131,7 @@ pub fn determine_thread_replies( .parse::() .unwrap_or_else(|_| panic!("Mastodon status ID is not u64: {}", toot.id)), text: post, + content_warning, attachments: toot_get_attachments(toot), in_reply_to_id: in_reply_to_id.parse::().unwrap_or_else(|_| { panic!("Mastodon reply ID is not u64: {in_reply_to_id}") @@ -157,6 +173,7 @@ fn insert_twitter_replies( if toot_and_tweet_are_equal(toot, tweet) { sync_statuses.push(NewStatus { text: reply.text.clone(), + content_warning: reply.content_warning.clone(), attachments: reply.attachments.clone(), replies: Vec::new(), in_reply_to_id: Some(toot.id.parse().unwrap_or_else(|_| { @@ -197,6 +214,7 @@ fn insert_mastodon_replies( if toot_and_tweet_are_equal(toot, tweet) { sync_statuses.push(NewStatus { text: reply.text.clone(), + content_warning: reply.content_warning.clone(), attachments: reply.attachments.clone(), replies: Vec::new(), in_reply_to_id: Some(tweet.id), @@ -216,6 +234,7 @@ fn insert_reply_on_status(status: &mut NewStatus, reply: &Reply) -> bool { if reply.in_reply_to_id == status.original_id { status.replies.push(NewStatus { text: reply.text.clone(), + content_warning: None, attachments: reply.attachments.clone(), replies: Vec::new(), in_reply_to_id: None,