From b8825f9199365c873a4f0edd98a435e986b8daa2 Mon Sep 17 00:00:00 2001 From: ThetaDev Date: Wed, 3 Apr 2024 03:28:13 +0200 Subject: [PATCH] feat: add text formatting (bold/italic/strikethrough) --- src/client/playlist.rs | 2 +- ...s__map_comments_20240401_frameworkupd.snap | 290 +++++++++++++++++- ...s__map_video_details_20221011_rec_isr.snap | 9 + src/model/richtext.rs | 195 +++++++++--- ...rializer__text__tests__split_text_cmp.snap | 50 +++ ...rializer__text__tests__styled_comment.snap | 74 +++++ ...text__tests__t_attributed_description.snap | 80 +++++ src/serializer/text.rs | 260 +++++++++++----- src/util/mod.rs | 12 +- testfiles/text/styled_comment.json | 31 ++ 10 files changed, 858 insertions(+), 145 deletions(-) create mode 100644 src/serializer/snapshots/rustypipe__serializer__text__tests__split_text_cmp.snap create mode 100644 src/serializer/snapshots/rustypipe__serializer__text__tests__styled_comment.snap create mode 100644 testfiles/text/styled_comment.json diff --git a/src/client/playlist.rs b/src/client/playlist.rs index bb3c80e..ce198f5 100644 --- a/src/client/playlist.rs +++ b/src/client/playlist.rs @@ -156,7 +156,7 @@ impl MapResponse for response::Playlist { header .playlist_header_renderer .description_text - .map(|text| TextComponents(vec![TextComponent::Text { text }])) + .map(|text| TextComponents(vec![TextComponent::new(text)])) }) .map(RichText::from); let channel = header diff --git a/src/client/snapshots/rustypipe__client__video_details__tests__map_comments_20240401_frameworkupd.snap b/src/client/snapshots/rustypipe__client__video_details__tests__map_comments_20240401_frameworkupd.snap index df3aedb..9248fb4 100644 --- a/src/client/snapshots/rustypipe__client__video_details__tests__map_comments_20240401_frameworkupd.snap +++ b/src/client/snapshots/rustypipe__client__video_details__tests__map_comments_20240401_frameworkupd.snap @@ -9,7 +9,34 @@ Paginator( id: "UgyNTT8uxDEjgYqybIF4AaABAg", text: RichText([ Text( - text: "⚠\u{fe0f} Important notice: if you put any symbol immediately after markup, it will not work: *here is the comma*, without space.\n\nYou should leave space before and after , to make it work.\n\nSame for _underscore_, and -hyphen-.\n\nLeave space before opening and after closing underscore and hyphen. Put all dots and commas inside markup.", + text: "⚠\u{fe0f} Important notice: if you put any symbol immediately after markup, it will not work: *here is the comma*, without space.\n\nYou should ", + ), + Text( + text: "leave space before and after", + style: Style( + bold: true, + ), + ), + Text( + text: " , to make it work.\n\nSame for _underscore_, and -hyphen-.\n\nLeave space before opening and after closing ", + ), + Text( + text: "underscore", + style: Style( + italic: true, + ), + ), + Text( + text: " and ", + ), + Text( + text: "hyphen.", + style: Style( + strikethrough: true, + ), + ), + Text( + text: " Put all dots and commas inside markup.", ), ]), author: Some(ChannelTag( @@ -43,7 +70,28 @@ Paginator( id: "UgycWgNOoon0A4EV9LZ4AaABAg", text: RichText([ Text( - text: "Me: tests out fonts\nFriend: Why are you doing this?\nMe: my goals are beyond your understanding", + text: "Me: tests out fonts", + style: Style( + bold: true, + ), + ), + Text( + text: "\nFriend: ", + ), + Text( + text: "Why are you doing this?", + style: Style( + bold: true, + ), + ), + Text( + text: "\nMe: ", + ), + Text( + text: "my goals are beyond your understanding", + style: Style( + italic: true, + ), ), ]), author: Some(ChannelTag( @@ -77,7 +125,31 @@ Paginator( id: "Ugy5iq4M1c3WS3lGmih4AaABAg", text: RichText([ Text( - text: "To-do list\n• be dumb\n• get kicked out when i can legally live alone\n• spend money on pointless things", + text: "To-do list\n• ", + ), + Text( + text: "be dumb", + style: Style( + strikethrough: true, + ), + ), + Text( + text: "\n• ", + ), + Text( + text: "get kicked out when i can legally live alone", + style: Style( + strikethrough: true, + ), + ), + Text( + text: "\n• ", + ), + Text( + text: "spend money on pointless things", + style: Style( + strikethrough: true, + ), ), ]), author: Some(ChannelTag( @@ -111,7 +183,31 @@ Paginator( id: "UgxqDIVVcoigjtx4Dtl4AaABAg", text: RichText([ Text( - text: "omg thank you! Ive been looking for this tutorial for a year forever", + text: "omg ", + ), + Text( + text: "thank", + style: Style( + italic: true, + ), + ), + Text( + text: " you! Ive been looking for this tutorial for a ", + ), + Text( + text: "year", + style: Style( + strikethrough: true, + ), + ), + Text( + text: " ", + ), + Text( + text: "forever", + style: Style( + bold: true, + ), ), ]), author: Some(ChannelTag( @@ -145,7 +241,28 @@ Paginator( id: "UgxDQfVQdYaWR-VUM-94AaABAg", text: RichText([ Text( - text: "tysm\ni finally learned it\nother channel never go straight to the point", + text: "tysm", + style: Style( + bold: true, + ), + ), + Text( + text: "\n", + ), + Text( + text: "i finally learned it", + style: Style( + italic: true, + ), + ), + Text( + text: "\n", + ), + Text( + text: "other channel never go straight to the point", + style: Style( + strikethrough: true, + ), ), ]), author: Some(ChannelTag( @@ -179,7 +296,28 @@ Paginator( id: "UgxFvrmwec-jmfQyGRR4AaABAg", text: RichText([ Text( - text: "I like how this was straight to the point. Unlike other channels lol Thank you!", + text: "I like how this was straight to the point.", + style: Style( + italic: true, + ), + ), + Text( + text: " ", + ), + Text( + text: "Unlike other channels lol", + style: Style( + strikethrough: true, + ), + ), + Text( + text: " ", + ), + Text( + text: "Thank you!", + style: Style( + bold: true, + ), ), ]), author: Some(ChannelTag( @@ -213,7 +351,13 @@ Paginator( id: "Ugy-3OYEcwxkvyrrCqN4AaABAg", text: RichText([ Text( - text: "To the person who is reading this: You\'re intelligent and smart, stay safe", + text: "To the person who is reading this: ", + ), + Text( + text: "You\'re intelligent and smart, stay safe", + style: Style( + bold: true, + ), ), ]), author: Some(ChannelTag( @@ -247,7 +391,17 @@ Paginator( id: "Ugylw3ss_xv9svWbRud4AaABAg", text: RichText([ Text( - text: "‘ life could be a dream, life could be a dream ‘", + text: "‘ ", + ), + Text( + text: "life could be a dream, life could be a dream", + style: Style( + bold: true, + italic: true, + ), + ), + Text( + text: " ‘", ), ]), author: Some(ChannelTag( @@ -281,7 +435,28 @@ Paginator( id: "UgydXobRB0F5dW1KVsF4AaABAg", text: RichText([ Text( - text: "Woah! thank you for showing me this I really needed it!", + text: "Woah!", + style: Style( + bold: true, + ), + ), + Text( + text: " ", + ), + Text( + text: "thank you for showing me this", + style: Style( + strikethrough: true, + ), + ), + Text( + text: " ", + ), + Text( + text: "I really needed it!", + style: Style( + italic: true, + ), ), ]), author: Some(ChannelTag( @@ -316,6 +491,9 @@ Paginator( text: RichText([ Text( text: "The fitness gram pacer test is a multistage aerobic capacity test that progressively gets more difficult as it continues.", + style: Style( + bold: true, + ), ), ]), author: Some(ChannelTag( @@ -384,6 +562,9 @@ Paginator( text: RichText([ Text( text: "omg it works i actuallly cant believe this ive been wanting to do this for ages thankyou so much!", + style: Style( + strikethrough: true, + ), ), ]), author: Some(ChannelTag( @@ -485,7 +666,13 @@ Paginator( id: "UgyfuG2sCDvgnRUYHJp4AaABAg", text: RichText([ Text( - text: "me: types bold\n\nHaruTutorial: your bald", + text: "me: types bold\n\nHaruTutorial: ", + ), + Text( + text: "your bald", + style: Style( + bold: true, + ), ), ]), author: Some(ChannelTag( @@ -520,6 +707,9 @@ Paginator( text: RichText([ Text( text: "the McDonald’s don’t feel like turning the Icecream machine on", + style: Style( + italic: true, + ), ), ]), author: Some(ChannelTag( @@ -554,6 +744,11 @@ Paginator( text: RichText([ Text( text: "YOOO THIS IS SICK! THANK YOU MAN!", + style: Style( + bold: true, + italic: true, + strikethrough: true, + ), ), ]), author: Some(ChannelTag( @@ -587,7 +782,46 @@ Paginator( id: "UgxnFMLrpvbCWzHidml4AaABAg", text: RichText([ Text( - text: "Someone must honor him , this man is the best , no , he is a LEGEND . We must all thank him for his video and for getting to the point immediately.", + text: "Someone must honor him", + style: Style( + bold: true, + ), + ), + Text( + text: " , this man is ", + ), + Text( + text: "the best", + style: Style( + strikethrough: true, + ), + ), + Text( + text: " , no , he is a ", + ), + Text( + text: "LEGEND", + style: Style( + bold: true, + ), + ), + Text( + text: " . ", + ), + Text( + text: "We must all thank him for his video", + style: Style( + italic: true, + ), + ), + Text( + text: " and for ", + ), + Text( + text: "getting to the point immediately.", + style: Style( + bold: true, + ), ), ]), author: Some(ChannelTag( @@ -621,7 +855,13 @@ Paginator( id: "UgwCIwmF6synP7UF_wV4AaABAg", text: RichText([ Text( - text: "Never gonna give you up. Im gonna let u down", + text: "Never gonna give you up.", + style: Style( + bold: true, + ), + ), + Text( + text: " Im gonna let u down", ), ]), author: Some(ChannelTag( @@ -655,7 +895,31 @@ Paginator( id: "Ugyb5Wy91Yon69o3wLh4AaABAg", text: RichText([ Text( - text: "Thank you for being A Legend No, The Goat Lets go dude", + text: "Thank you for being ", + ), + Text( + text: "A Legend", + style: Style( + strikethrough: true, + ), + ), + Text( + text: " No, ", + ), + Text( + text: "The Goat", + style: Style( + bold: true, + ), + ), + Text( + text: " ", + ), + Text( + text: "Lets go dude", + style: Style( + italic: true, + ), ), ]), author: Some(ChannelTag( diff --git a/src/client/snapshots/rustypipe__client__video_details__tests__map_video_details_20221011_rec_isr.snap b/src/client/snapshots/rustypipe__client__video_details__tests__map_video_details_20221011_rec_isr.snap index 4cfe502..b91086b 100644 --- a/src/client/snapshots/rustypipe__client__video_details__tests__map_video_details_20221011_rec_isr.snap +++ b/src/client/snapshots/rustypipe__client__video_details__tests__map_video_details_20221011_rec_isr.snap @@ -95,6 +95,9 @@ VideoDetails( ), Text( text: "-------------------------------------------------", + style: Style( + strikethrough: true, + ), ), Text( text: " \nTwitter: ", @@ -136,6 +139,9 @@ VideoDetails( ), Text( text: "-------------------------------------------------", + style: Style( + strikethrough: true, + ), ), Text( text: "\nIntro: Laszlo - Supernova\nVideo Link: ", @@ -218,6 +224,9 @@ VideoDetails( ), Text( text: "-------------------------------------------------", + style: Style( + strikethrough: true, + ), ), Text( text: "\n", diff --git a/src/model/richtext.rs b/src/model/richtext.rs index aeb81fc..93ef5af 100644 --- a/src/model/richtext.rs +++ b/src/model/richtext.rs @@ -19,6 +19,9 @@ pub enum TextComponent { Text { /// Plain text text: String, + /// Text styling + #[serde(default, skip_serializing_if = "Style::is_unstyled")] + style: Style, }, /// Web link Web { @@ -36,6 +39,78 @@ pub enum TextComponent { }, } +/// Text styling +#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +#[non_exhaustive] +pub struct Style { + /// **Bold** + /// + /// - HTML: `Text` + /// - Markdown: `**Text**` + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub bold: bool, + /// *Italic* + /// + /// - HTML: `Text` + /// - Markdown: `*Text*` + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub italic: bool, + /// ~~Strikethrough~~ + /// + /// - HTML: `Text` + /// - Markdown: `~~Text~~` + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub strikethrough: bool, +} + +impl Style { + /// Return true if the text is styled (bold/italic/strikethrough) + pub fn is_styled(&self) -> bool { + self.bold || self.italic || self.strikethrough + } + + fn is_unstyled(&self) -> bool { + !self.is_styled() + } + + fn html_open(&self, s: &mut String) { + if self.bold { + s.push_str(""); + } + if self.italic { + s.push_str(""); + } + if self.strikethrough { + s.push_str(""); + } + } + + fn html_close(&self, s: &mut String) { + if self.bold { + s.push_str(""); + } + if self.italic { + s.push_str(""); + } + if self.strikethrough { + s.push_str(""); + } + } + + fn md_tag(&self, s: &mut String) { + if self.bold { + s.push_str("**"); + } + if self.italic { + s.push('*'); + } + if self.strikethrough { + s.push_str("~~"); + } + } +} + /// Trait for converting rich text to plain text. pub trait ToPlaintext { /// Convert rich text to plain text. @@ -83,7 +158,7 @@ impl TextComponent { /// Get the text from the component pub fn get_text(&self) -> &str { match self { - TextComponent::Text { text } + TextComponent::Text { text, .. } | TextComponent::Web { text, .. } | TextComponent::YouTube { text, .. } => text, } @@ -104,7 +179,7 @@ impl TextComponent { impl ToPlaintext for TextComponent { fn to_plaintext_yt_host(&self, yt_host: &str) -> String { match self { - TextComponent::Text { text } => text.clone(), + TextComponent::Text { text, .. } => text.clone(), _ => self.get_url(yt_host), } } @@ -113,7 +188,13 @@ impl ToPlaintext for TextComponent { impl ToHtml for TextComponent { fn to_html_yt_host(&self, yt_host: &str) -> String { match self { - TextComponent::Text { text } => util::escape_html(text), + TextComponent::Text { text, style } => { + let mut html = String::with_capacity(text.len()); + style.html_open(&mut html); + util::escape_html_append(text, &mut html); + style.html_close(&mut html); + html + } TextComponent::Web { text, .. } => { format!( r#"{}"#, @@ -135,7 +216,13 @@ impl ToHtml for TextComponent { impl ToMarkdown for TextComponent { fn to_markdown_yt_host(&self, yt_host: &str) -> String { match self { - TextComponent::Text { text } => util::escape_markdown(text), + TextComponent::Text { text, style } => { + let mut md = String::with_capacity(text.len()); + style.md_tag(&mut md); + util::escape_markdown_append(text, &mut md); + style.md_tag(&mut md); + md + } TextComponent::Web { text, .. } | TextComponent::YouTube { text, .. } => { format!( "[{}]({})", @@ -175,6 +262,7 @@ impl ToMarkdown for RichText { mod tests { use super::*; + use insta::assert_snapshot; use once_cell::sync::Lazy; use crate::client::response::url_endpoint::MusicVideoType; @@ -182,37 +270,47 @@ mod tests { static TEXT_SOURCE: Lazy = Lazy::new(|| { text::TextComponents(vec![ - text::TextComponent::Text { text: "🎧Listen and download aespa's debut single \"Black Mamba\": ".to_owned() }, + text::TextComponent::new("🎧Listen and download aespa's debut single \"Black Mamba\": "), text::TextComponent::Web { text: "https://smarturl.it/aespa_BlackMamba".to_owned(), url: "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbFY1QmpQamJPSms0Z1FnVTlQUS00ZFhBZnBJZ3xBQ3Jtc0tuRGJBanludGoyRnphb2dZWVd3cUNnS3dEd0FnNHFOZEY1NHBJaHFmLXpaWUJwX3ZucDZxVnpGeHNGX1FpMzFkZW9jQkI2Mi1wNGJ1UVFNN3h1MnN3R3JLMzdxU01nZ01POHBGcmxHU2puSUk1WHRzQQ&q=https%3A%2F%2Fsmarturl.it%2Faespa_BlackMamba&v=ZeerrnuLi5E".to_owned() }, - text::TextComponent::Text { text: "\n🐍The Debut Stage ".to_owned() }, + text::TextComponent::new("\n🐍The Debut Stage "), text::TextComponent::Video { text: "https://youtu.be/Ky5RT5oGg0w".to_owned(), video_id: "Ky5RT5oGg0w".to_owned(), start_time: 0, vtype: MusicVideoType::Video }, - text::TextComponent::Text { text: "\n\n🎟️ aespa Showcase SYNK in LA! Tickets now on sale: ".to_owned() }, + text::TextComponent::new("\n\n🎟️ aespa Showcase SYNK in LA! Tickets now on sale: "), text::TextComponent::Web { text: "https://www.ticketmaster.com/event/0A...".to_owned(), url: "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbFpUMEZiaXJWWkszaVZXaEM0emxWU1JQV3NoQXxBQ3Jtc0tuU2g4VWNPNE5UY3hoSWYtamFzX0h4bUVQLVJiRy1ubDZrTnh3MUpGdDNSaUo0ZlMyT3lUM28ycUVBdHJLMndGcDhla3BkOFpxSVFfOS1QdVJPVHBUTEV1LXpOV0J2QXdhV05lV210cEJtZUJMeHdaTQ&q=https%3A%2F%2Fwww.ticketmaster.com%2Fevent%2F0A005CCD9E871F6E&v=ZeerrnuLi5E".to_owned() }, - text::TextComponent::Text { text: "\n\nSubscribe to aespa Official YouTube Channel!\n".to_owned() }, + text::TextComponent::new("\n\nSubscribe to aespa Official YouTube Channel!\n"), text::TextComponent::Web { text: "https://www.youtube.com/aespa?sub_con...".to_owned(), url: "https://www.youtube.com/aespa?sub_confirmation=1".to_owned() }, - text::TextComponent::Text { text: "\n\naespa official\n".to_owned() }, + text::TextComponent::new("\n\naespa official\n"), text::TextComponent::Web { text: "https://www.youtube.com/c/aespa".to_owned(), url: "https://www.youtube.com/c/aespa".to_owned() }, - text::TextComponent::Text { text: "\n".to_owned() }, + text::TextComponent::new("\n"), text::TextComponent::Web { text: "https://www.instagram.com/aespa_official".to_owned(), url: "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbmE4UXZBdFM4allpdUkwaGQ1SGFBTklKYVVaQXxBQ3Jtc0tsOVg3WTM2Y0t1eE5YUm5vZjNTVjM4bncxTl9JeFdWeGJlbDZJa3BqTXZDQUdzVndPR3ZpV2ZEOGMzZ1FsT21HMEp5UllpWVZVb3djYTVzNGNFaWlmbzhmTEVmQ0RiVUxMNUM4MDV3ZGt3SHhJM3pGSQ&q=https%3A%2F%2Fwww.instagram.com%2Faespa_official&v=ZeerrnuLi5E".to_owned() }, - text::TextComponent::Text { text: "\n".to_owned() }, + text::TextComponent::new("\n"), text::TextComponent::Web { text: "https://www.tiktok.com/@aespa_official".to_owned(), url: "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqa2hVUk9QQXZmMHk5ZkdEZnVKZXIyXzZvX09zZ3xBQ3Jtc0trZEhjd1lVc1NZMWs4TVY3UmpzdDhnX0lLYnZjekZqNUprWUpHV1ZOR2g0al84TlNLTEFjODktUWE3QUFFTlJ5RlpvOVNOWUdJXzF2ZHhzOHRTdGhlUG1OcmhZVkMtazBzYXJqNFVUYVBKUVI1ZzB4VQ&q=https%3A%2F%2Fwww.tiktok.com%2F%40aespa_official&v=ZeerrnuLi5E".to_owned() }, - text::TextComponent::Text { text: "\n".to_owned() }, + text::TextComponent::new("\n"), text::TextComponent::Web { text: "https://twitter.com/aespa_Official".to_owned(), url: "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbWFlRFFWWVpMeFRzU08ySWhJWVl0RUJpZzIxZ3xBQ3Jtc0tsekJiMUI4Zk1QdENObWpLZVppdk1nRVBkamJmX21VNGxaYjdUTEdoREx4Z3pWTm0wVHg4MWNTVmdxakNJT3VQQk5tSDVnZkNJZkhQSTF1d0ZEX3g0RUVDWjFjVzA1ZzVsTEVvMW5ISTdaZU1xYjhXSQ&q=https%3A%2F%2Ftwitter.com%2Faespa_Official&v=ZeerrnuLi5E".to_owned() }, - text::TextComponent::Text { text: "\n".to_owned() }, + text::TextComponent::new("\n"), text::TextComponent::Web { text: "https://www.facebook.com/aespa.official".to_owned(), url: "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbWJxUWVETWNwM0ltc0JYXzBjQ1h5dmQ2OXNzUXxBQ3Jtc0ttVy1JRHV2VVpUOUtDdUZTU0tROEtLX1k0bVFFNTdoZVpIUDhDbTkydmRmY2diR3RlQmlON1Y4NURsaU1YcTRKLXBzeGdkWWY1d0R3MzhMYXl6cE1OM0hMcEpkdXZvVXItQzRhMTVqVU1ySk93UG9Ydw&q=https%3A%2F%2Fwww.facebook.com%2Faespa.official&v=ZeerrnuLi5E".to_owned() }, - text::TextComponent::Text { text: "\n".to_owned() }, + text::TextComponent::new("\n"), text::TextComponent::Web { text: "https://weibo.com/aespa".to_owned(), url: "https://www.youtube.com/redirect?event=video_description&redir_token=QUFFLUhqbUZFOVFFSEtTRkU5LXluWk9uTVRHbU5tN2JGd3xBQ3Jtc0ttR003eUM4ZVBVM3JPdjdJMnZwRXpxZmJMMkhFbHYtbklJUG9LYXh5VHBXalgyWTZwc3RqcGlhT2JIR0RlNVpWUEpBajZ0X2d5ZkxEZUUyQmF4bE13NjhEdDZOak9saHdnb25qdnB3dnRiYmplbkY0MA&q=https%3A%2F%2Fweibo.com%2Faespa&v=ZeerrnuLi5E".to_owned() }, - text::TextComponent::Text { text: "\n\n".to_owned() }, - text::TextComponent::Text { text: "#aespa".to_owned() }, - text::TextComponent::Text { text: " ".to_owned() }, - text::TextComponent::Text { text: "#æspa".to_owned() }, - text::TextComponent::Text { text: " ".to_owned() }, - text::TextComponent::Text { text: "#BlackMamba".to_owned() }, - text::TextComponent::Text { text: " ".to_owned() }, - text::TextComponent::Text { text: "#블랙맘바".to_owned() }, - text::TextComponent::Text { text: " ".to_owned() }, - text::TextComponent::Text { text: "#에스파".to_owned() }, - text::TextComponent::Text { text: "\naespa 에스파 'Black Mamba' MV ℗ SM Entertainment".to_owned() }, + text::TextComponent::new("\n\n"), + text::TextComponent::new("#aespa"), + text::TextComponent::new(" "), + text::TextComponent::new("#æspa"), + text::TextComponent::new(" "), + text::TextComponent::new("#BlackMamba"), + text::TextComponent::new(" "), + text::TextComponent::new("#블랙맘바"), + text::TextComponent::new(" "), + text::TextComponent::new("#에스파"), + text::TextComponent::new("\naespa 에스파 'Black Mamba' MV ℗ SM Entertainment"), + text::TextComponent::new("\n\n"), + + text::TextComponent::new("Bold: "), + text::TextComponent::Text { text: "Awesome".to_owned(), style: Style { bold: true, italic: false, strikethrough: false } }, + text::TextComponent::new("\nItalic: "), + text::TextComponent::Text { text: "Great".to_owned(), style: Style { bold: false, italic: true, strikethrough: false } }, + text::TextComponent::new("\nStrikethrough: "), + text::TextComponent::Text { text: "Gone".to_owned(), style: Style { bold: false, italic: false, strikethrough: true } }, + text::TextComponent::new("\nMixed: "), + text::TextComponent::Text { text: "Everything".to_owned(), style: Style { bold: true, italic: true, strikethrough: true } }, ]) }); @@ -220,36 +318,41 @@ mod tests { fn to_plaintext() { let richtext = RichText::from(TEXT_SOURCE.clone()); let plaintext = richtext.to_plaintext_yt_host("https://piped.kavin.rocks"); - assert_eq!( - plaintext, - r#"🎧Listen and download aespa's debut single "Black Mamba": https://smarturl.it/aespa_BlackMamba -🐍The Debut Stage https://piped.kavin.rocks/watch?v=Ky5RT5oGg0w -🎟️ aespa Showcase SYNK in LA! Tickets now on sale: https://www.ticketmaster.com/event/0A005CCD9E871F6E + assert_snapshot!(plaintext, @r###" + 🎧Listen and download aespa's debut single "Black Mamba": https://smarturl.it/aespa_BlackMamba + 🐍The Debut Stage https://piped.kavin.rocks/watch?v=Ky5RT5oGg0w -Subscribe to aespa Official YouTube Channel! -https://www.youtube.com/aespa?sub_confirmation=1 + 🎟️ aespa Showcase SYNK in LA! Tickets now on sale: https://www.ticketmaster.com/event/0A005CCD9E871F6E -aespa official -https://www.youtube.com/c/aespa -https://www.instagram.com/aespa_official -https://www.tiktok.com/@aespa_official -https://twitter.com/aespa_Official -https://www.facebook.com/aespa.official -https://weibo.com/aespa + Subscribe to aespa Official YouTube Channel! + https://www.youtube.com/aespa?sub_confirmation=1 -#aespa #æspa #BlackMamba #블랙맘바 #에스파 -aespa 에스파 'Black Mamba' MV ℗ SM Entertainment"# - ); + aespa official + https://www.youtube.com/c/aespa + https://www.instagram.com/aespa_official + https://www.tiktok.com/@aespa_official + https://twitter.com/aespa_Official + https://www.facebook.com/aespa.official + https://weibo.com/aespa + + #aespa #æspa #BlackMamba #블랙맘바 #에스파 + aespa 에스파 'Black Mamba' MV ℗ SM Entertainment + + Bold: Awesome + Italic: Great + Strikethrough: Gone + Mixed: Everything + "###); } #[test] fn to_html() { let richtext = RichText::from(TEXT_SOURCE.clone()); let html = richtext.to_html_yt_host("https://piped.kavin.rocks"); - assert_eq!( + assert_snapshot!( html, - "🎧Listen and download aespa's debut single "Black Mamba": https://smarturl.it/aespa_BlackMamba
🐍The Debut Stage https://youtu.be/Ky5RT5oGg0w

🎟\u{fe0f} aespa Showcase SYNK in LA! Tickets now on sale: https://www.ticketmaster.com/event/0A...

Subscribe to aespa Official YouTube Channel!
https://www.youtube.com/aespa?sub_con...

aespa official
https://www.youtube.com/c/aespa
https://www.instagram.com/aespa_official
https://www.tiktok.com/@aespa_official
https://twitter.com/aespa_Official
https://www.facebook.com/aespa.official
https://weibo.com/aespa

#aespa #æspa #BlackMamba #블랙맘바 #에스파
aespa 에스파 'Black Mamba' MV ℗ SM Entertainment" + @r###"🎧Listen and download aespa's debut single "Black Mamba": https://smarturl.it/aespa_BlackMamba
🐍The Debut Stage https://youtu.be/Ky5RT5oGg0w

🎟️ aespa Showcase SYNK in LA! Tickets now on sale: https://www.ticketmaster.com/event/0A...

Subscribe to aespa Official YouTube Channel!
https://www.youtube.com/aespa?sub_con...

aespa official
https://www.youtube.com/c/aespa
https://www.instagram.com/aespa_official
https://www.tiktok.com/@aespa_official
https://twitter.com/aespa_Official
https://www.facebook.com/aespa.official
https://weibo.com/aespa

#aespa #æspa #BlackMamba #블랙맘바 #에스파
aespa 에스파 'Black Mamba' MV ℗ SM Entertainment

Bold: Awesome
Italic: Great
Strikethrough: Gone
Mixed: Everything"### ); } @@ -258,9 +361,9 @@ aespa 에스파 'Black Mamba' MV ℗ SM Entertainment"# let richtext = RichText::from(TEXT_SOURCE.clone()); let markdown = richtext.to_markdown_yt_host("https://piped.kavin.rocks"); println!("{markdown}"); - assert_eq!( + assert_snapshot!( markdown, - r#"🎧Listen and download aespa's debut single "Black Mamba"\: [https\://smarturl.it/aespa\_BlackMamba](https://smarturl.it/aespa_BlackMamba)
🐍The Debut Stage [https\://youtu.be/Ky5RT5oGg0w](https://piped.kavin.rocks/watch?v=Ky5RT5oGg0w)

🎟️ aespa Showcase SYNK in LA! Tickets now on sale\: [https\://www.ticketmaster.com/event/0A...](https://www.ticketmaster.com/event/0A005CCD9E871F6E)

Subscribe to aespa Official YouTube Channel!
[https\://www.youtube.com/aespa?sub\_con...](https://www.youtube.com/aespa?sub_confirmation=1)

aespa official
[https\://www.youtube.com/c/aespa](https://www.youtube.com/c/aespa)
[https\://www.instagram.com/aespa\_official](https://www.instagram.com/aespa_official)
[https\://www.tiktok.com/@aespa\_official](https://www.tiktok.com/@aespa_official)
[https\://twitter.com/aespa\_Official](https://twitter.com/aespa_Official)
[https\://www.facebook.com/aespa.official](https://www.facebook.com/aespa.official)
[https\://weibo.com/aespa](https://weibo.com/aespa)

\#aespa \#æspa \#BlackMamba \#블랙맘바 \#에스파
aespa 에스파 'Black Mamba' MV ℗ SM Entertainment"# + @r###"🎧Listen and download aespa's debut single "Black Mamba"\: [https\://smarturl.it/aespa\_BlackMamba](https://smarturl.it/aespa_BlackMamba)
🐍The Debut Stage [https\://youtu.be/Ky5RT5oGg0w](https://piped.kavin.rocks/watch?v=Ky5RT5oGg0w)

🎟️ aespa Showcase SYNK in LA! Tickets now on sale\: [https\://www.ticketmaster.com/event/0A...](https://www.ticketmaster.com/event/0A005CCD9E871F6E)

Subscribe to aespa Official YouTube Channel!
[https\://www.youtube.com/aespa?sub\_con...](https://www.youtube.com/aespa?sub_confirmation=1)

aespa official
[https\://www.youtube.com/c/aespa](https://www.youtube.com/c/aespa)
[https\://www.instagram.com/aespa\_official](https://www.instagram.com/aespa_official)
[https\://www.tiktok.com/@aespa\_official](https://www.tiktok.com/@aespa_official)
[https\://twitter.com/aespa\_Official](https://twitter.com/aespa_Official)
[https\://www.facebook.com/aespa.official](https://www.facebook.com/aespa.official)
[https\://weibo.com/aespa](https://weibo.com/aespa)

\#aespa \#æspa \#BlackMamba \#블랙맘바 \#에스파
aespa 에스파 'Black Mamba' MV ℗ SM Entertainment

Bold\: **Awesome**
Italic\: *Great*
Strikethrough\: ~~Gone~~
Mixed\: ***~~Everything***~~"### ); } } diff --git a/src/serializer/snapshots/rustypipe__serializer__text__tests__split_text_cmp.snap b/src/serializer/snapshots/rustypipe__serializer__text__tests__split_text_cmp.snap new file mode 100644 index 0000000..a5b6e05 --- /dev/null +++ b/src/serializer/snapshots/rustypipe__serializer__text__tests__split_text_cmp.snap @@ -0,0 +1,50 @@ +--- +source: src/serializer/text.rs +expression: split +--- +[ + TextComponents( + [ + Text { + text: "Hello", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, + }, + Text { + text: " World", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, + }, + ], + ), + TextComponents( + [ + Text { + text: "T2", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, + }, + ], + ), + TextComponents( + [ + Text { + text: "T3", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, + }, + ], + ), +] diff --git a/src/serializer/snapshots/rustypipe__serializer__text__tests__styled_comment.snap b/src/serializer/snapshots/rustypipe__serializer__text__tests__styled_comment.snap new file mode 100644 index 0000000..c90d4ac --- /dev/null +++ b/src/serializer/snapshots/rustypipe__serializer__text__tests__styled_comment.snap @@ -0,0 +1,74 @@ +--- +source: src/serializer/text.rs +expression: res +--- +SAttributed { + ln: TextComponents( + [ + Text { + text: "Bold: ", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, + }, + Text { + text: "Awesome", + style: Style { + bold: true, + italic: false, + strikethrough: false, + }, + }, + Text { + text: "\nItalic: ", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, + }, + Text { + text: "Great", + style: Style { + bold: false, + italic: true, + strikethrough: false, + }, + }, + Text { + text: "\nCut: ", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, + }, + Text { + text: "Dumb", + style: Style { + bold: false, + italic: false, + strikethrough: true, + }, + }, + Text { + text: "\n\nMixed: ", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, + }, + Text { + text: "Mixer", + style: Style { + bold: true, + italic: true, + strikethrough: true, + }, + }, + ], + ), +} diff --git a/src/serializer/snapshots/rustypipe__serializer__text__tests__t_attributed_description.snap b/src/serializer/snapshots/rustypipe__serializer__text__tests__t_attributed_description.snap index 25c6179..c47814b 100644 --- a/src/serializer/snapshots/rustypipe__serializer__text__tests__t_attributed_description.snap +++ b/src/serializer/snapshots/rustypipe__serializer__text__tests__t_attributed_description.snap @@ -7,6 +7,11 @@ SAttributed { [ Text { text: "🎧Listen and download aespa's debut single \"Black Mamba\": ", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, Web { text: "https://smarturl.it/aespa_BlackMamba", @@ -14,6 +19,11 @@ SAttributed { }, Text { text: "\n🐍The Debut Stage ", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, Video { text: "aespa 에스파 'Black ...", @@ -23,6 +33,11 @@ SAttributed { }, Text { text: "\n\n🎟\u{fe0f} aespa Showcase SYNK in LA! Tickets now on sale: ", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, Web { text: "https://www.ticketmaster.com/event/0A...", @@ -30,6 +45,11 @@ SAttributed { }, Text { text: "\n\nSubscribe to aespa Official YouTube Channel!\n", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, Web { text: "https://www.youtube.com/aespa?sub_con...", @@ -37,6 +57,11 @@ SAttributed { }, Text { text: "\n\naespa official\n", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, Web { text: "aespa", @@ -44,6 +69,11 @@ SAttributed { }, Text { text: "\n", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, Web { text: "https://www.instagram.com/aespa_official", @@ -51,6 +81,11 @@ SAttributed { }, Text { text: "\n", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, Web { text: "https://www.tiktok.com/@aespa_official", @@ -58,6 +93,11 @@ SAttributed { }, Text { text: "\n", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, Web { text: "https://twitter.com/aespa_Official", @@ -65,6 +105,11 @@ SAttributed { }, Text { text: "\n", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, Web { text: "https://www.facebook.com/aespa.official", @@ -72,6 +117,11 @@ SAttributed { }, Text { text: "\n", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, Web { text: "https://weibo.com/aespa", @@ -79,6 +129,11 @@ SAttributed { }, Text { text: "\n\n", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, Browse { text: "#aespa", @@ -87,6 +142,11 @@ SAttributed { }, Text { text: " ", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, Browse { text: "#æspa", @@ -95,6 +155,11 @@ SAttributed { }, Text { text: " ", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, Browse { text: "#BlackMamba", @@ -103,6 +168,11 @@ SAttributed { }, Text { text: " ", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, Browse { text: "#블랙맘바", @@ -111,6 +181,11 @@ SAttributed { }, Text { text: " ", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, Browse { text: "#에스파", @@ -119,6 +194,11 @@ SAttributed { }, Text { text: "\naespa 에스파 'Black Mamba' MV ℗ SM Entertainment", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, ], ), diff --git a/src/serializer/text.rs b/src/serializer/text.rs index 5fbe692..6f4cd5b 100644 --- a/src/serializer/text.rs +++ b/src/serializer/text.rs @@ -9,7 +9,7 @@ use crate::{ client::response::url_endpoint::{ MusicPage, MusicPageType, MusicVideoType, NavigationEndpoint, PageType, }, - model::UrlTarget, + model::{richtext::Style, UrlTarget}, util, }; @@ -110,6 +110,7 @@ pub(crate) enum TextComponent { }, Text { text: String, + style: Style, }, } @@ -130,6 +131,12 @@ struct RichTextRun { #[serde(default)] #[serde_as(as = "DefaultOnError")] navigation_endpoint: Option, + #[serde(default)] + bold: bool, + #[serde(default)] + italic: bool, + #[serde(default)] + strikethrough: bool, } /// This is a new rich text representation format that YouTube is A/B testing @@ -142,31 +149,117 @@ pub(crate) struct AttributedText { content: String, #[serde(default)] #[serde_as(as = "VecSkipError<_>")] - command_runs: Vec, + command_runs: Vec, + #[serde(default)] + #[serde_as(as = "VecSkipError<_>")] + style_runs: Vec, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct AttributedTextRun { +struct CommandRun { start_index: usize, length: usize, on_tap: AttributedTextOnTap, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct StyleRun { + start_index: usize, + length: usize, + #[serde(default)] + weight_label: WeightLabel, + #[serde(default)] + italic: bool, + #[serde(default)] + strikethrough: Strikethrough, +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +enum WeightLabel { + FontWeightMedium, + #[default] + #[serde(other)] + FontWeightNormal, +} + +#[derive(Default, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +enum Strikethrough { + LineStyleSingle, + #[default] + #[serde(other)] + None, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct AttributedTextOnTap { innertube_command: NavigationEndpoint, } +struct AttributedTextRun { + start_index: usize, + length: usize, + content: AttributedTextRunContent, +} + +enum AttributedTextRunContent { + Link(NavigationEndpoint), + Style(Style), +} + impl From for TextComponent { fn from(run: RichTextRun) -> Self { - map_text_component(run.text, run.navigation_endpoint) + map_text_component( + run.text, + Style { + bold: run.bold, + italic: run.italic, + strikethrough: run.strikethrough, + }, + run.navigation_endpoint, + ) + } +} + +impl From for AttributedTextRun { + fn from(value: CommandRun) -> Self { + Self { + start_index: value.start_index, + length: value.length, + content: AttributedTextRunContent::Link(value.on_tap.innertube_command), + } + } +} + +impl StyleRun { + fn into_attributed_text_run(self) -> Option { + let style = Style { + bold: matches!(self.weight_label, WeightLabel::FontWeightMedium), + italic: self.italic, + strikethrough: matches!(self.strikethrough, Strikethrough::LineStyleSingle), + }; + if style.is_styled() { + Some(AttributedTextRun { + start_index: self.start_index, + length: self.length, + content: AttributedTextRunContent::Style(style), + }) + } else { + None + } } } /// Map a single component of a rich text -fn map_text_component(text: String, nav: Option) -> TextComponent { +fn map_text_component( + text: String, + style: Style, + nav: Option, +) -> TextComponent { match nav { Some(NavigationEndpoint::Watch { watch_endpoint }) => TextComponent::Video { text, @@ -185,7 +278,7 @@ fn map_text_component(text: String, nav: Option) -> TextComp Some(bc) => bc.browse_endpoint_context_music_config.page_type, None => match &command_metadata { Some(cm) => cm.web_command_metadata.web_page_type, - None => return TextComponent::Text { text }, + None => return TextComponent::Text { text, style }, }, }, text, @@ -202,7 +295,7 @@ fn map_text_component(text: String, nav: Option) -> TextComp page_type: PageType::Playlist, browse_id: watch_playlist_endpoint.playlist_id, }, - None => TextComponent::Text { text }, + None => TextComponent::Text { text, style }, } } @@ -267,37 +360,51 @@ impl<'de> DeserializeAs<'de, TextComponents> for AttributedText { buf }; - let mut components = Vec::with_capacity(text.command_runs.len() + 1); - text.command_runs.into_iter().for_each(|cmd| { - let txt_before = take_chars(cmd.start_index); - let txt_link = take_chars(cmd.start_index + cmd.length); + let mut runs = text + .command_runs + .into_iter() + .map(AttributedTextRun::from) + .collect::>(); + runs.extend( + text.style_runs + .into_iter() + .filter_map(StyleRun::into_attributed_text_run), + ); + runs.sort_by_key(|run| run.start_index); - // Trim link text: - // 3xnbsp, (/ •), nbsp, Name, 2xnbsp - // Channel: `\u{a0}\u{a0}\u{a0}/\u{a0}aespa\u{a0}\u{a0}` - // Video: `\u{a0}\u{a0}\u{a0}•\u{a0}aespa\u{a0}에스파\u{a0}'Black\u{a0}...\u{a0}\u{a0}` - - // Replace no-break spaces, trim off whitespace and prefix character - let txt_link = txt_link.trim(); - let txt_link = txt_link.replace('\u{a0}', " "); - - static LINK_PREFIX: Lazy = Lazy::new(|| Regex::new("^[/•] *").unwrap()); - let txt_link = LINK_PREFIX.replace(&txt_link, ""); + let mut components = Vec::with_capacity(runs.len() + 1); + for run in runs { + let txt_before = take_chars(run.start_index); + let txt_run = take_chars(run.start_index + run.length); if !txt_before.is_empty() { - components.push(TextComponent::Text { text: txt_before }); + components.push(TextComponent::new(txt_before)); } - components.push(map_text_component( - txt_link.to_string(), - Some(cmd.on_tap.innertube_command), - )); - }); + components.push(match run.content { + AttributedTextRunContent::Link(link) => { + // Trim link text: + // 3xnbsp, (/ •), nbsp, Name, 2xnbsp + // Channel: `\u{a0}\u{a0}\u{a0}/\u{a0}aespa\u{a0}\u{a0}` + // Video: `\u{a0}\u{a0}\u{a0}•\u{a0}aespa\u{a0}에스파\u{a0}'Black\u{a0}...\u{a0}\u{a0}` + + // Replace no-break spaces, trim off whitespace and prefix character + let txt_link = txt_run.trim(); + let txt_link = txt_link.replace('\u{a0}', " "); + + static LINK_PREFIX: Lazy = Lazy::new(|| Regex::new("^[/•] *").unwrap()); + let txt_link = LINK_PREFIX.replace(&txt_link, ""); + + map_text_component(txt_link.to_string(), Style::default(), Some(link)) + } + AttributedTextRunContent::Style(style) => { + map_text_component(txt_run.to_string(), style, None) + } + }) + } let end = chars.as_str(); if !end.is_empty() { - components.push(TextComponent::Text { - text: end.to_owned(), - }); + components.push(TextComponent::new(end)); } Ok(TextComponents(components)) @@ -376,7 +483,7 @@ impl From for crate::model::ArtistId { }, TextComponent::Video { text, .. } | TextComponent::Web { text, .. } - | TextComponent::Text { text } => Self { + | TextComponent::Text { text, .. } => Self { id: None, name: text, }, @@ -405,13 +512,16 @@ impl From for crate::model::richtext::TextComponent { browse_id, } => match page_type.to_url_target(browse_id) { Some(target) => Self::YouTube { text, target }, - None => Self::Text { text }, + None => Self::Text { + text, + style: Default::default(), + }, }, TextComponent::Web { text, url } => Self::Web { text, url: util::sanitize_yt_url(&url), }, - TextComponent::Text { text } => Self::Text { text }, + TextComponent::Text { text, style } => Self::Text { text, style }, } } } @@ -423,12 +533,19 @@ impl From for crate::model::richtext::RichText { } impl TextComponent { + pub fn new>(s: S) -> Self { + Self::Text { + text: s.into(), + style: Style::default(), + } + } + pub fn as_str(&self) -> &str { match self { TextComponent::Video { text, .. } | TextComponent::Browse { text, .. } | TextComponent::Web { text, .. } - | TextComponent::Text { text } => text, + | TextComponent::Text { text, .. } => text, } } @@ -456,7 +573,7 @@ impl From for String { TextComponent::Video { text, .. } | TextComponent::Browse { text, .. } | TextComponent::Web { text, .. } - | TextComponent::Text { text } => text, + | TextComponent::Text { text, .. } => text, } } } @@ -732,6 +849,11 @@ mod tests { SLink { ln: Text { text: "Hello World", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, } "###); @@ -823,6 +945,11 @@ mod tests { }, Text { text: " & ", + style: Style { + bold: false, + italic: false, + strikethrough: false, + }, }, Browse { text: "Maite Kelly", @@ -851,57 +978,26 @@ mod tests { insta::assert_debug_snapshot!(res); } + #[test] + fn styled_comment() { + let json_path = path!(*TESTFILES / "text" / "styled_comment.json"); + let json_file = File::open(json_path).unwrap(); + let res: SAttributed = serde_json::from_reader(BufReader::new(json_file)).unwrap(); + insta::assert_debug_snapshot!(res); + } + #[test] fn split_text_cmp() { let text = TextComponents(vec![ - TextComponent::Text { - text: "Hello".to_owned(), - }, - TextComponent::Text { - text: " World".to_owned(), - }, - TextComponent::Text { - text: util::DOT_SEPARATOR.to_owned(), - }, - TextComponent::Text { - text: "T2".to_owned(), - }, - TextComponent::Text { - text: util::DOT_SEPARATOR.to_owned(), - }, - TextComponent::Text { - text: "T3".to_owned(), - }, + TextComponent::new("Hello"), + TextComponent::new(" World"), + TextComponent::new(util::DOT_SEPARATOR), + TextComponent::new("T2"), + TextComponent::new(util::DOT_SEPARATOR), + TextComponent::new("T3"), ]); let split = text.split(util::DOT_SEPARATOR); - insta::assert_debug_snapshot!(split, @r###" - [ - TextComponents( - [ - Text { - text: "Hello", - }, - Text { - text: " World", - }, - ], - ), - TextComponents( - [ - Text { - text: "T2", - }, - ], - ), - TextComponents( - [ - Text { - text: "T3", - }, - ], - ), - ] - "###); + insta::assert_debug_snapshot!(split); } } diff --git a/src/util/mod.rs b/src/util/mod.rs index 34d09ac..d11c66c 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -327,7 +327,7 @@ impl TryRemove for Vec { /// Check if a channel name equals "YouTube Music" /// (the author of original YouTube music playlists) pub(crate) fn is_ytm(text: &TextComponent) -> bool { - if let TextComponent::Text { text } = text { + if let TextComponent::Text { text, .. } = text { text.starts_with("YouTube") } else { false @@ -422,7 +422,11 @@ where /// Replace all html control characters to make a string safe for inserting into HTML. pub fn escape_html(input: &str) -> String { let mut buf = String::with_capacity(input.len()); + escape_html_append(input, &mut buf); + buf +} +pub fn escape_html_append(input: &str, buf: &mut String) { for c in input.chars() { match c { '<' => buf.push_str("<"), @@ -434,14 +438,17 @@ pub fn escape_html(input: &str) -> String { _ => buf.push(c), }; } - buf } /// Replace all markdown control characters to make a string safe for /// inserting into Markdown. pub fn escape_markdown(input: &str) -> String { let mut buf = String::with_capacity(input.len()); + escape_markdown_append(input, &mut buf); + buf +} +pub fn escape_markdown_append(input: &str, buf: &mut String) { for c in input.chars() { match c { '<' => buf.push_str("<"), @@ -455,7 +462,6 @@ pub fn escape_markdown(input: &str) -> String { _ => buf.push(c), }; } - buf } pub fn video_id_from_thumbnail_url(url: &str) -> Option { diff --git a/testfiles/text/styled_comment.json b/testfiles/text/styled_comment.json new file mode 100644 index 0000000..6fc8621 --- /dev/null +++ b/testfiles/text/styled_comment.json @@ -0,0 +1,31 @@ +{ + "ln": { + "content": "Bold: Awesome\nItalic: Great\nCut: Dumb\n\nMixed: Mixer", + "styleRuns": [ + { + "startIndex": 6, + "length": 7, + "weightLabel": "FONT_WEIGHT_MEDIUM" + }, + { + "startIndex": 22, + "length": 5, + "weightLabel": "FONT_WEIGHT_NORMAL", + "italic": true + }, + { + "startIndex": 33, + "length": 4, + "weightLabel": "FONT_WEIGHT_NORMAL", + "strikethrough": "LINE_STYLE_SINGLE" + }, + { + "startIndex": 46, + "length": 5, + "weightLabel": "FONT_WEIGHT_MEDIUM", + "italic": true, + "strikethrough": "LINE_STYLE_SINGLE" + } + ] + } +}