feat: add text formatting (bold/italic/strikethrough)

This commit is contained in:
ThetaDev 2024-04-03 03:28:13 +02:00
parent bedd73b7d9
commit 5cf2c73ee6
10 changed files with 858 additions and 145 deletions

View file

@ -156,7 +156,7 @@ impl MapResponse<Playlist> for response::Playlist {
header header
.playlist_header_renderer .playlist_header_renderer
.description_text .description_text
.map(|text| TextComponents(vec![TextComponent::Text { text }])) .map(|text| TextComponents(vec![TextComponent::new(text)]))
}) })
.map(RichText::from); .map(RichText::from);
let channel = header let channel = header

View file

@ -9,7 +9,34 @@ Paginator(
id: "UgyNTT8uxDEjgYqybIF4AaABAg", id: "UgyNTT8uxDEjgYqybIF4AaABAg",
text: RichText([ text: RichText([
Text( 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( author: Some(ChannelTag(
@ -43,7 +70,28 @@ Paginator(
id: "UgycWgNOoon0A4EV9LZ4AaABAg", id: "UgycWgNOoon0A4EV9LZ4AaABAg",
text: RichText([ text: RichText([
Text( 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( author: Some(ChannelTag(
@ -77,7 +125,31 @@ Paginator(
id: "Ugy5iq4M1c3WS3lGmih4AaABAg", id: "Ugy5iq4M1c3WS3lGmih4AaABAg",
text: RichText([ text: RichText([
Text( 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( author: Some(ChannelTag(
@ -111,7 +183,31 @@ Paginator(
id: "UgxqDIVVcoigjtx4Dtl4AaABAg", id: "UgxqDIVVcoigjtx4Dtl4AaABAg",
text: RichText([ text: RichText([
Text( 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( author: Some(ChannelTag(
@ -145,7 +241,28 @@ Paginator(
id: "UgxDQfVQdYaWR-VUM-94AaABAg", id: "UgxDQfVQdYaWR-VUM-94AaABAg",
text: RichText([ text: RichText([
Text( 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( author: Some(ChannelTag(
@ -179,7 +296,28 @@ Paginator(
id: "UgxFvrmwec-jmfQyGRR4AaABAg", id: "UgxFvrmwec-jmfQyGRR4AaABAg",
text: RichText([ text: RichText([
Text( 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( author: Some(ChannelTag(
@ -213,7 +351,13 @@ Paginator(
id: "Ugy-3OYEcwxkvyrrCqN4AaABAg", id: "Ugy-3OYEcwxkvyrrCqN4AaABAg",
text: RichText([ text: RichText([
Text( 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( author: Some(ChannelTag(
@ -247,7 +391,17 @@ Paginator(
id: "Ugylw3ss_xv9svWbRud4AaABAg", id: "Ugylw3ss_xv9svWbRud4AaABAg",
text: RichText([ text: RichText([
Text( 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( author: Some(ChannelTag(
@ -281,7 +435,28 @@ Paginator(
id: "UgydXobRB0F5dW1KVsF4AaABAg", id: "UgydXobRB0F5dW1KVsF4AaABAg",
text: RichText([ text: RichText([
Text( 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( author: Some(ChannelTag(
@ -316,6 +491,9 @@ Paginator(
text: RichText([ text: RichText([
Text( Text(
text: "The fitness gram pacer test is a multistage aerobic capacity test that progressively gets more difficult as it continues.", 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( author: Some(ChannelTag(
@ -384,6 +562,9 @@ Paginator(
text: RichText([ text: RichText([
Text( Text(
text: "omg it works i actuallly cant believe this ive been wanting to do this for ages thankyou so much!", 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( author: Some(ChannelTag(
@ -485,7 +666,13 @@ Paginator(
id: "UgyfuG2sCDvgnRUYHJp4AaABAg", id: "UgyfuG2sCDvgnRUYHJp4AaABAg",
text: RichText([ text: RichText([
Text( 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( author: Some(ChannelTag(
@ -520,6 +707,9 @@ Paginator(
text: RichText([ text: RichText([
Text( Text(
text: "the McDonalds dont feel like turning the Icecream machine on", text: "the McDonalds dont feel like turning the Icecream machine on",
style: Style(
italic: true,
),
), ),
]), ]),
author: Some(ChannelTag( author: Some(ChannelTag(
@ -554,6 +744,11 @@ Paginator(
text: RichText([ text: RichText([
Text( Text(
text: "YOOO THIS IS SICK! THANK YOU MAN!", text: "YOOO THIS IS SICK! THANK YOU MAN!",
style: Style(
bold: true,
italic: true,
strikethrough: true,
),
), ),
]), ]),
author: Some(ChannelTag( author: Some(ChannelTag(
@ -587,7 +782,46 @@ Paginator(
id: "UgxnFMLrpvbCWzHidml4AaABAg", id: "UgxnFMLrpvbCWzHidml4AaABAg",
text: RichText([ text: RichText([
Text( 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( author: Some(ChannelTag(
@ -621,7 +855,13 @@ Paginator(
id: "UgwCIwmF6synP7UF_wV4AaABAg", id: "UgwCIwmF6synP7UF_wV4AaABAg",
text: RichText([ text: RichText([
Text( 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( author: Some(ChannelTag(
@ -655,7 +895,31 @@ Paginator(
id: "Ugyb5Wy91Yon69o3wLh4AaABAg", id: "Ugyb5Wy91Yon69o3wLh4AaABAg",
text: RichText([ text: RichText([
Text( 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( author: Some(ChannelTag(

View file

@ -95,6 +95,9 @@ VideoDetails(
), ),
Text( Text(
text: "-------------------------------------------------", text: "-------------------------------------------------",
style: Style(
strikethrough: true,
),
), ),
Text( Text(
text: " \nTwitter: ", text: " \nTwitter: ",
@ -136,6 +139,9 @@ VideoDetails(
), ),
Text( Text(
text: "-------------------------------------------------", text: "-------------------------------------------------",
style: Style(
strikethrough: true,
),
), ),
Text( Text(
text: "\nIntro: Laszlo - Supernova\nVideo Link: ", text: "\nIntro: Laszlo - Supernova\nVideo Link: ",
@ -218,6 +224,9 @@ VideoDetails(
), ),
Text( Text(
text: "-------------------------------------------------", text: "-------------------------------------------------",
style: Style(
strikethrough: true,
),
), ),
Text( Text(
text: "\n", text: "\n",

View file

@ -19,6 +19,9 @@ pub enum TextComponent {
Text { Text {
/// Plain text /// Plain text
text: String, text: String,
/// Text styling
#[serde(default, skip_serializing_if = "Style::is_unstyled")]
style: Style,
}, },
/// Web link /// Web link
Web { 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: `<b>Text</b>`
/// - Markdown: `**Text**`
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub bold: bool,
/// *Italic*
///
/// - HTML: `<i>Text</i>`
/// - Markdown: `*Text*`
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub italic: bool,
/// ~~Strikethrough~~
///
/// - HTML: `<s>Text</s>`
/// - 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("<b>");
}
if self.italic {
s.push_str("<i>");
}
if self.strikethrough {
s.push_str("<s>");
}
}
fn html_close(&self, s: &mut String) {
if self.bold {
s.push_str("</b>");
}
if self.italic {
s.push_str("</i>");
}
if self.strikethrough {
s.push_str("</s>");
}
}
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. /// Trait for converting rich text to plain text.
pub trait ToPlaintext { pub trait ToPlaintext {
/// Convert rich text to plain text. /// Convert rich text to plain text.
@ -83,7 +158,7 @@ impl TextComponent {
/// Get the text from the component /// Get the text from the component
pub fn get_text(&self) -> &str { pub fn get_text(&self) -> &str {
match self { match self {
TextComponent::Text { text } TextComponent::Text { text, .. }
| TextComponent::Web { text, .. } | TextComponent::Web { text, .. }
| TextComponent::YouTube { text, .. } => text, | TextComponent::YouTube { text, .. } => text,
} }
@ -104,7 +179,7 @@ impl TextComponent {
impl ToPlaintext for TextComponent { impl ToPlaintext for TextComponent {
fn to_plaintext_yt_host(&self, yt_host: &str) -> String { fn to_plaintext_yt_host(&self, yt_host: &str) -> String {
match self { match self {
TextComponent::Text { text } => text.clone(), TextComponent::Text { text, .. } => text.clone(),
_ => self.get_url(yt_host), _ => self.get_url(yt_host),
} }
} }
@ -113,7 +188,13 @@ impl ToPlaintext for TextComponent {
impl ToHtml for TextComponent { impl ToHtml for TextComponent {
fn to_html_yt_host(&self, yt_host: &str) -> String { fn to_html_yt_host(&self, yt_host: &str) -> String {
match self { 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, .. } => { TextComponent::Web { text, .. } => {
format!( format!(
r#"<a href="{}" target="_blank" rel="noreferrer">{}</a>"#, r#"<a href="{}" target="_blank" rel="noreferrer">{}</a>"#,
@ -135,7 +216,13 @@ impl ToHtml for TextComponent {
impl ToMarkdown for TextComponent { impl ToMarkdown for TextComponent {
fn to_markdown_yt_host(&self, yt_host: &str) -> String { fn to_markdown_yt_host(&self, yt_host: &str) -> String {
match self { 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, .. } => { TextComponent::Web { text, .. } | TextComponent::YouTube { text, .. } => {
format!( format!(
"[{}]({})", "[{}]({})",
@ -175,6 +262,7 @@ impl ToMarkdown for RichText {
mod tests { mod tests {
use super::*; use super::*;
use insta::assert_snapshot;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use crate::client::response::url_endpoint::MusicVideoType; use crate::client::response::url_endpoint::MusicVideoType;
@ -182,37 +270,47 @@ mod tests {
static TEXT_SOURCE: Lazy<text::TextComponents> = Lazy::new(|| { static TEXT_SOURCE: Lazy<text::TextComponents> = Lazy::new(|| {
text::TextComponents(vec![ 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::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::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::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::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::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::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::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::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::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::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::new("\n\n"),
text::TextComponent::Text { text: "#aespa".to_owned() }, text::TextComponent::new("#aespa"),
text::TextComponent::Text { text: " ".to_owned() }, text::TextComponent::new(" "),
text::TextComponent::Text { text: "#æspa".to_owned() }, text::TextComponent::new("#æspa"),
text::TextComponent::Text { text: " ".to_owned() }, text::TextComponent::new(" "),
text::TextComponent::Text { text: "#BlackMamba".to_owned() }, text::TextComponent::new("#BlackMamba"),
text::TextComponent::Text { text: " ".to_owned() }, text::TextComponent::new(" "),
text::TextComponent::Text { text: "#블랙맘바".to_owned() }, text::TextComponent::new("#블랙맘바"),
text::TextComponent::Text { text: " ".to_owned() }, text::TextComponent::new(" "),
text::TextComponent::Text { text: "#에스파".to_owned() }, text::TextComponent::new("#에스파"),
text::TextComponent::Text { text: "\naespa 에스파 'Black Mamba' MV ℗ SM Entertainment".to_owned() }, 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() { fn to_plaintext() {
let richtext = RichText::from(TEXT_SOURCE.clone()); let richtext = RichText::from(TEXT_SOURCE.clone());
let plaintext = richtext.to_plaintext_yt_host("https://piped.kavin.rocks"); 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! 🎟 aespa Showcase SYNK in LA! Tickets now on sale: https://www.ticketmaster.com/event/0A005CCD9E871F6E
https://www.youtube.com/aespa?sub_confirmation=1
aespa official Subscribe to aespa Official YouTube Channel!
https://www.youtube.com/c/aespa https://www.youtube.com/aespa?sub_confirmation=1
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 official
aespa 'Black Mamba' MV SM Entertainment"# 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] #[test]
fn to_html() { fn to_html() {
let richtext = RichText::from(TEXT_SOURCE.clone()); let richtext = RichText::from(TEXT_SOURCE.clone());
let html = richtext.to_html_yt_host("https://piped.kavin.rocks"); let html = richtext.to_html_yt_host("https://piped.kavin.rocks");
assert_eq!( assert_snapshot!(
html, html,
"🎧Listen and download aespa&#x27;s debut single &quot;Black Mamba&quot;: <a href=\"https://smarturl.it/aespa_BlackMamba\" target=\"_blank\" rel=\"noreferrer\">https://smarturl.it/aespa_BlackMamba</a><br>🐍The Debut Stage <a href=\"https://piped.kavin.rocks/watch?v=Ky5RT5oGg0w\">https://youtu.be/Ky5RT5oGg0w</a><br><br>🎟\u{fe0f} aespa Showcase SYNK in LA! Tickets now on sale: <a href=\"https://www.ticketmaster.com/event/0A005CCD9E871F6E\" target=\"_blank\" rel=\"noreferrer\">https://www.ticketmaster.com/event/0A...</a><br><br>Subscribe to aespa Official YouTube Channel!<br><a href=\"https://www.youtube.com/aespa?sub_confirmation=1\" target=\"_blank\" rel=\"noreferrer\">https://www.youtube.com/aespa?sub_con...</a><br><br>aespa official<br><a href=\"https://www.youtube.com/c/aespa\" target=\"_blank\" rel=\"noreferrer\">https://www.youtube.com/c/aespa</a><br><a href=\"https://www.instagram.com/aespa_official\" target=\"_blank\" rel=\"noreferrer\">https://www.instagram.com/aespa_official</a><br><a href=\"https://www.tiktok.com/@aespa_official\" target=\"_blank\" rel=\"noreferrer\">https://www.tiktok.com/@aespa_official</a><br><a href=\"https://twitter.com/aespa_Official\" target=\"_blank\" rel=\"noreferrer\">https://twitter.com/aespa_Official</a><br><a href=\"https://www.facebook.com/aespa.official\" target=\"_blank\" rel=\"noreferrer\">https://www.facebook.com/aespa.official</a><br><a href=\"https://weibo.com/aespa\" target=\"_blank\" rel=\"noreferrer\">https://weibo.com/aespa</a><br><br>#aespa #æspa #BlackMamba #블랙맘바 #에스파<br>aespa 에스파 &#x27;Black Mamba&#x27; MV ℗ SM Entertainment" @r###"🎧Listen and download aespa&#x27;s debut single &quot;Black Mamba&quot;: <a href="https://smarturl.it/aespa_BlackMamba" target="_blank" rel="noreferrer">https://smarturl.it/aespa_BlackMamba</a><br>🐍The Debut Stage <a href="https://piped.kavin.rocks/watch?v=Ky5RT5oGg0w">https://youtu.be/Ky5RT5oGg0w</a><br><br>🎟️ aespa Showcase SYNK in LA! Tickets now on sale: <a href="https://www.ticketmaster.com/event/0A005CCD9E871F6E" target="_blank" rel="noreferrer">https://www.ticketmaster.com/event/0A...</a><br><br>Subscribe to aespa Official YouTube Channel!<br><a href="https://www.youtube.com/aespa?sub_confirmation=1" target="_blank" rel="noreferrer">https://www.youtube.com/aespa?sub_con...</a><br><br>aespa official<br><a href="https://www.youtube.com/c/aespa" target="_blank" rel="noreferrer">https://www.youtube.com/c/aespa</a><br><a href="https://www.instagram.com/aespa_official" target="_blank" rel="noreferrer">https://www.instagram.com/aespa_official</a><br><a href="https://www.tiktok.com/@aespa_official" target="_blank" rel="noreferrer">https://www.tiktok.com/@aespa_official</a><br><a href="https://twitter.com/aespa_Official" target="_blank" rel="noreferrer">https://twitter.com/aespa_Official</a><br><a href="https://www.facebook.com/aespa.official" target="_blank" rel="noreferrer">https://www.facebook.com/aespa.official</a><br><a href="https://weibo.com/aespa" target="_blank" rel="noreferrer">https://weibo.com/aespa</a><br><br>#aespa #æspa #BlackMamba #블랙맘바 #에스파<br>aespa 에스파 &#x27;Black Mamba&#x27; MV ℗ SM Entertainment<br><br>Bold: <b>Awesome</b><br>Italic: <i>Great</i><br>Strikethrough: <s>Gone</s><br>Mixed: <b><i><s>Everything</b></i></s>"###
); );
} }
@ -258,9 +361,9 @@ aespa 에스파 'Black Mamba' MV ℗ SM Entertainment"#
let richtext = RichText::from(TEXT_SOURCE.clone()); let richtext = RichText::from(TEXT_SOURCE.clone());
let markdown = richtext.to_markdown_yt_host("https://piped.kavin.rocks"); let markdown = richtext.to_markdown_yt_host("https://piped.kavin.rocks");
println!("{markdown}"); println!("{markdown}");
assert_eq!( assert_snapshot!(
markdown, markdown,
r#"🎧Listen and download aespa's debut single "Black Mamba"\: [https\://smarturl.it/aespa\_BlackMamba](https://smarturl.it/aespa_BlackMamba)<br>🐍The Debut Stage [https\://youtu.be/Ky5RT5oGg0w](https://piped.kavin.rocks/watch?v=Ky5RT5oGg0w)<br><br>🎟️ aespa Showcase SYNK in LA! Tickets now on sale\: [https\://www.ticketmaster.com/event/0A...](https://www.ticketmaster.com/event/0A005CCD9E871F6E)<br><br>Subscribe to aespa Official YouTube Channel!<br>[https\://www.youtube.com/aespa?sub\_con...](https://www.youtube.com/aespa?sub_confirmation=1)<br><br>aespa official<br>[https\://www.youtube.com/c/aespa](https://www.youtube.com/c/aespa)<br>[https\://www.instagram.com/aespa\_official](https://www.instagram.com/aespa_official)<br>[https\://www.tiktok.com/@aespa\_official](https://www.tiktok.com/@aespa_official)<br>[https\://twitter.com/aespa\_Official](https://twitter.com/aespa_Official)<br>[https\://www.facebook.com/aespa.official](https://www.facebook.com/aespa.official)<br>[https\://weibo.com/aespa](https://weibo.com/aespa)<br><br>\#aespa \#æspa \#BlackMamba \#블랙맘바 \#에스파<br>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)<br>🐍The Debut Stage [https\://youtu.be/Ky5RT5oGg0w](https://piped.kavin.rocks/watch?v=Ky5RT5oGg0w)<br><br>🎟️ aespa Showcase SYNK in LA! Tickets now on sale\: [https\://www.ticketmaster.com/event/0A...](https://www.ticketmaster.com/event/0A005CCD9E871F6E)<br><br>Subscribe to aespa Official YouTube Channel!<br>[https\://www.youtube.com/aespa?sub\_con...](https://www.youtube.com/aespa?sub_confirmation=1)<br><br>aespa official<br>[https\://www.youtube.com/c/aespa](https://www.youtube.com/c/aespa)<br>[https\://www.instagram.com/aespa\_official](https://www.instagram.com/aespa_official)<br>[https\://www.tiktok.com/@aespa\_official](https://www.tiktok.com/@aespa_official)<br>[https\://twitter.com/aespa\_Official](https://twitter.com/aespa_Official)<br>[https\://www.facebook.com/aespa.official](https://www.facebook.com/aespa.official)<br>[https\://weibo.com/aespa](https://weibo.com/aespa)<br><br>\#aespa \#æspa \#BlackMamba \#블랙맘바 \#에스파<br>aespa 에스파 'Black Mamba' MV ℗ SM Entertainment<br><br>Bold\: **Awesome**<br>Italic\: *Great*<br>Strikethrough\: ~~Gone~~<br>Mixed\: ***~~Everything***~~"###
); );
} }
} }

View file

@ -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,
},
},
],
),
]

View file

@ -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,
},
},
],
),
}

View file

@ -7,6 +7,11 @@ SAttributed {
[ [
Text { Text {
text: "🎧Listen and download aespa's debut single \"Black Mamba\": ", text: "🎧Listen and download aespa's debut single \"Black Mamba\": ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
Web { Web {
text: "https://smarturl.it/aespa_BlackMamba", text: "https://smarturl.it/aespa_BlackMamba",
@ -14,6 +19,11 @@ SAttributed {
}, },
Text { Text {
text: "\n🐍The Debut Stage ", text: "\n🐍The Debut Stage ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
Video { Video {
text: "aespa 에스파 'Black ...", text: "aespa 에스파 'Black ...",
@ -23,6 +33,11 @@ SAttributed {
}, },
Text { Text {
text: "\n\n🎟\u{fe0f} aespa Showcase SYNK in LA! Tickets now on sale: ", text: "\n\n🎟\u{fe0f} aespa Showcase SYNK in LA! Tickets now on sale: ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
Web { Web {
text: "https://www.ticketmaster.com/event/0A...", text: "https://www.ticketmaster.com/event/0A...",
@ -30,6 +45,11 @@ SAttributed {
}, },
Text { Text {
text: "\n\nSubscribe to aespa Official YouTube Channel!\n", text: "\n\nSubscribe to aespa Official YouTube Channel!\n",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
Web { Web {
text: "https://www.youtube.com/aespa?sub_con...", text: "https://www.youtube.com/aespa?sub_con...",
@ -37,6 +57,11 @@ SAttributed {
}, },
Text { Text {
text: "\n\naespa official\n", text: "\n\naespa official\n",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
Web { Web {
text: "aespa", text: "aespa",
@ -44,6 +69,11 @@ SAttributed {
}, },
Text { Text {
text: "\n", text: "\n",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
Web { Web {
text: "https://www.instagram.com/aespa_official", text: "https://www.instagram.com/aespa_official",
@ -51,6 +81,11 @@ SAttributed {
}, },
Text { Text {
text: "\n", text: "\n",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
Web { Web {
text: "https://www.tiktok.com/@aespa_official", text: "https://www.tiktok.com/@aespa_official",
@ -58,6 +93,11 @@ SAttributed {
}, },
Text { Text {
text: "\n", text: "\n",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
Web { Web {
text: "https://twitter.com/aespa_Official", text: "https://twitter.com/aespa_Official",
@ -65,6 +105,11 @@ SAttributed {
}, },
Text { Text {
text: "\n", text: "\n",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
Web { Web {
text: "https://www.facebook.com/aespa.official", text: "https://www.facebook.com/aespa.official",
@ -72,6 +117,11 @@ SAttributed {
}, },
Text { Text {
text: "\n", text: "\n",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
Web { Web {
text: "https://weibo.com/aespa", text: "https://weibo.com/aespa",
@ -79,6 +129,11 @@ SAttributed {
}, },
Text { Text {
text: "\n\n", text: "\n\n",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
Browse { Browse {
text: "#aespa", text: "#aespa",
@ -87,6 +142,11 @@ SAttributed {
}, },
Text { Text {
text: " ", text: " ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
Browse { Browse {
text: "#æspa", text: "#æspa",
@ -95,6 +155,11 @@ SAttributed {
}, },
Text { Text {
text: " ", text: " ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
Browse { Browse {
text: "#BlackMamba", text: "#BlackMamba",
@ -103,6 +168,11 @@ SAttributed {
}, },
Text { Text {
text: " ", text: " ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
Browse { Browse {
text: "#블랙맘바", text: "#블랙맘바",
@ -111,6 +181,11 @@ SAttributed {
}, },
Text { Text {
text: " ", text: " ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
Browse { Browse {
text: "#에스파", text: "#에스파",
@ -119,6 +194,11 @@ SAttributed {
}, },
Text { Text {
text: "\naespa 에스파 'Black Mamba' MV ℗ SM Entertainment", text: "\naespa 에스파 'Black Mamba' MV ℗ SM Entertainment",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
], ],
), ),

View file

@ -9,7 +9,7 @@ use crate::{
client::response::url_endpoint::{ client::response::url_endpoint::{
MusicPage, MusicPageType, MusicVideoType, NavigationEndpoint, PageType, MusicPage, MusicPageType, MusicVideoType, NavigationEndpoint, PageType,
}, },
model::UrlTarget, model::{richtext::Style, UrlTarget},
util, util,
}; };
@ -110,6 +110,7 @@ pub(crate) enum TextComponent {
}, },
Text { Text {
text: String, text: String,
style: Style,
}, },
} }
@ -130,6 +131,12 @@ struct RichTextRun {
#[serde(default)] #[serde(default)]
#[serde_as(as = "DefaultOnError")] #[serde_as(as = "DefaultOnError")]
navigation_endpoint: Option<NavigationEndpoint>, navigation_endpoint: Option<NavigationEndpoint>,
#[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 /// This is a new rich text representation format that YouTube is A/B testing
@ -142,31 +149,117 @@ pub(crate) struct AttributedText {
content: String, content: String,
#[serde(default)] #[serde(default)]
#[serde_as(as = "VecSkipError<_>")] #[serde_as(as = "VecSkipError<_>")]
command_runs: Vec<AttributedTextRun>, command_runs: Vec<CommandRun>,
#[serde(default)]
#[serde_as(as = "VecSkipError<_>")]
style_runs: Vec<StyleRun>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct AttributedTextRun { struct CommandRun {
start_index: usize, start_index: usize,
length: usize, length: usize,
on_tap: AttributedTextOnTap, 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)] #[derive(Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
struct AttributedTextOnTap { struct AttributedTextOnTap {
innertube_command: NavigationEndpoint, innertube_command: NavigationEndpoint,
} }
struct AttributedTextRun {
start_index: usize,
length: usize,
content: AttributedTextRunContent,
}
enum AttributedTextRunContent {
Link(NavigationEndpoint),
Style(Style),
}
impl From<RichTextRun> for TextComponent { impl From<RichTextRun> for TextComponent {
fn from(run: RichTextRun) -> Self { 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<CommandRun> 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<AttributedTextRun> {
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 /// Map a single component of a rich text
fn map_text_component(text: String, nav: Option<NavigationEndpoint>) -> TextComponent { fn map_text_component(
text: String,
style: Style,
nav: Option<NavigationEndpoint>,
) -> TextComponent {
match nav { match nav {
Some(NavigationEndpoint::Watch { watch_endpoint }) => TextComponent::Video { Some(NavigationEndpoint::Watch { watch_endpoint }) => TextComponent::Video {
text, text,
@ -185,7 +278,7 @@ fn map_text_component(text: String, nav: Option<NavigationEndpoint>) -> TextComp
Some(bc) => bc.browse_endpoint_context_music_config.page_type, Some(bc) => bc.browse_endpoint_context_music_config.page_type,
None => match &command_metadata { None => match &command_metadata {
Some(cm) => cm.web_command_metadata.web_page_type, Some(cm) => cm.web_command_metadata.web_page_type,
None => return TextComponent::Text { text }, None => return TextComponent::Text { text, style },
}, },
}, },
text, text,
@ -202,7 +295,7 @@ fn map_text_component(text: String, nav: Option<NavigationEndpoint>) -> TextComp
page_type: PageType::Playlist, page_type: PageType::Playlist,
browse_id: watch_playlist_endpoint.playlist_id, 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 buf
}; };
let mut components = Vec::with_capacity(text.command_runs.len() + 1); let mut runs = text
text.command_runs.into_iter().for_each(|cmd| { .command_runs
let txt_before = take_chars(cmd.start_index); .into_iter()
let txt_link = take_chars(cmd.start_index + cmd.length); .map(AttributedTextRun::from)
.collect::<Vec<_>>();
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: let mut components = Vec::with_capacity(runs.len() + 1);
// 3xnbsp, (/ •), nbsp, Name, 2xnbsp for run in runs {
// Channel: `\u{a0}\u{a0}\u{a0}/\u{a0}aespa\u{a0}\u{a0}` let txt_before = take_chars(run.start_index);
// Video: `\u{a0}\u{a0}\u{a0}•\u{a0}aespa\u{a0}에스파\u{a0}'Black\u{a0}...\u{a0}\u{a0}` let txt_run = take_chars(run.start_index + run.length);
// 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<Regex> = Lazy::new(|| Regex::new("^[/•] *").unwrap());
let txt_link = LINK_PREFIX.replace(&txt_link, "");
if !txt_before.is_empty() { if !txt_before.is_empty() {
components.push(TextComponent::Text { text: txt_before }); components.push(TextComponent::new(txt_before));
} }
components.push(map_text_component( components.push(match run.content {
txt_link.to_string(), AttributedTextRunContent::Link(link) => {
Some(cmd.on_tap.innertube_command), // 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<Regex> = 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(); let end = chars.as_str();
if !end.is_empty() { if !end.is_empty() {
components.push(TextComponent::Text { components.push(TextComponent::new(end));
text: end.to_owned(),
});
} }
Ok(TextComponents(components)) Ok(TextComponents(components))
@ -376,7 +483,7 @@ impl From<TextComponent> for crate::model::ArtistId {
}, },
TextComponent::Video { text, .. } TextComponent::Video { text, .. }
| TextComponent::Web { text, .. } | TextComponent::Web { text, .. }
| TextComponent::Text { text } => Self { | TextComponent::Text { text, .. } => Self {
id: None, id: None,
name: text, name: text,
}, },
@ -405,13 +512,16 @@ impl From<TextComponent> for crate::model::richtext::TextComponent {
browse_id, browse_id,
} => match page_type.to_url_target(browse_id) { } => match page_type.to_url_target(browse_id) {
Some(target) => Self::YouTube { text, target }, Some(target) => Self::YouTube { text, target },
None => Self::Text { text }, None => Self::Text {
text,
style: Default::default(),
},
}, },
TextComponent::Web { text, url } => Self::Web { TextComponent::Web { text, url } => Self::Web {
text, text,
url: util::sanitize_yt_url(&url), 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<TextComponents> for crate::model::richtext::RichText {
} }
impl TextComponent { impl TextComponent {
pub fn new<S: Into<String>>(s: S) -> Self {
Self::Text {
text: s.into(),
style: Style::default(),
}
}
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
match self { match self {
TextComponent::Video { text, .. } TextComponent::Video { text, .. }
| TextComponent::Browse { text, .. } | TextComponent::Browse { text, .. }
| TextComponent::Web { text, .. } | TextComponent::Web { text, .. }
| TextComponent::Text { text } => text, | TextComponent::Text { text, .. } => text,
} }
} }
@ -456,7 +573,7 @@ impl From<TextComponent> for String {
TextComponent::Video { text, .. } TextComponent::Video { text, .. }
| TextComponent::Browse { text, .. } | TextComponent::Browse { text, .. }
| TextComponent::Web { text, .. } | TextComponent::Web { text, .. }
| TextComponent::Text { text } => text, | TextComponent::Text { text, .. } => text,
} }
} }
} }
@ -732,6 +849,11 @@ mod tests {
SLink { SLink {
ln: Text { ln: Text {
text: "Hello World", text: "Hello World",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
} }
"###); "###);
@ -823,6 +945,11 @@ mod tests {
}, },
Text { Text {
text: " & ", text: " & ",
style: Style {
bold: false,
italic: false,
strikethrough: false,
},
}, },
Browse { Browse {
text: "Maite Kelly", text: "Maite Kelly",
@ -851,57 +978,26 @@ mod tests {
insta::assert_debug_snapshot!(res); 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] #[test]
fn split_text_cmp() { fn split_text_cmp() {
let text = TextComponents(vec![ let text = TextComponents(vec![
TextComponent::Text { TextComponent::new("Hello"),
text: "Hello".to_owned(), TextComponent::new(" World"),
}, TextComponent::new(util::DOT_SEPARATOR),
TextComponent::Text { TextComponent::new("T2"),
text: " World".to_owned(), TextComponent::new(util::DOT_SEPARATOR),
}, TextComponent::new("T3"),
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(),
},
]); ]);
let split = text.split(util::DOT_SEPARATOR); let split = text.split(util::DOT_SEPARATOR);
insta::assert_debug_snapshot!(split, @r###" insta::assert_debug_snapshot!(split);
[
TextComponents(
[
Text {
text: "Hello",
},
Text {
text: " World",
},
],
),
TextComponents(
[
Text {
text: "T2",
},
],
),
TextComponents(
[
Text {
text: "T3",
},
],
),
]
"###);
} }
} }

View file

@ -327,7 +327,7 @@ impl<T> TryRemove<T> for Vec<T> {
/// Check if a channel name equals "YouTube Music" /// Check if a channel name equals "YouTube Music"
/// (the author of original YouTube music playlists) /// (the author of original YouTube music playlists)
pub(crate) fn is_ytm(text: &TextComponent) -> bool { pub(crate) fn is_ytm(text: &TextComponent) -> bool {
if let TextComponent::Text { text } = text { if let TextComponent::Text { text, .. } = text {
text.starts_with("YouTube") text.starts_with("YouTube")
} else { } else {
false false
@ -422,7 +422,11 @@ where
/// Replace all html control characters to make a string safe for inserting into HTML. /// Replace all html control characters to make a string safe for inserting into HTML.
pub fn escape_html(input: &str) -> String { pub fn escape_html(input: &str) -> String {
let mut buf = String::with_capacity(input.len()); 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() { for c in input.chars() {
match c { match c {
'<' => buf.push_str("&lt;"), '<' => buf.push_str("&lt;"),
@ -434,14 +438,17 @@ pub fn escape_html(input: &str) -> String {
_ => buf.push(c), _ => buf.push(c),
}; };
} }
buf
} }
/// Replace all markdown control characters to make a string safe for /// Replace all markdown control characters to make a string safe for
/// inserting into Markdown. /// inserting into Markdown.
pub fn escape_markdown(input: &str) -> String { pub fn escape_markdown(input: &str) -> String {
let mut buf = String::with_capacity(input.len()); 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() { for c in input.chars() {
match c { match c {
'<' => buf.push_str("&lt;"), '<' => buf.push_str("&lt;"),
@ -455,7 +462,6 @@ pub fn escape_markdown(input: &str) -> String {
_ => buf.push(c), _ => buf.push(c),
}; };
} }
buf
} }
pub fn video_id_from_thumbnail_url(url: &str) -> Option<String> { pub fn video_id_from_thumbnail_url(url: &str) -> Option<String> {

View file

@ -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"
}
]
}
}