feat: add text formatting (bold/italic/strikethrough)
This commit is contained in:
parent
449fc0128e
commit
b8825f9199
10 changed files with 858 additions and 145 deletions
|
|
@ -156,7 +156,7 @@ impl MapResponse<Playlist> 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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: `<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.
|
||||
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#"<a href="{}" target="_blank" rel="noreferrer">{}</a>"#,
|
||||
|
|
@ -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<text::TextComponents> = 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": <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 에스파 'Black Mamba' MV ℗ SM Entertainment"
|
||||
@r###"🎧Listen and download aespa's debut single "Black Mamba": <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 에스파 'Black Mamba' 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 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)<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***~~"###
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
|
|
@ -142,31 +149,117 @@ pub(crate) struct AttributedText {
|
|||
content: String,
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
command_runs: Vec<AttributedTextRun>,
|
||||
command_runs: Vec<CommandRun>,
|
||||
#[serde(default)]
|
||||
#[serde_as(as = "VecSkipError<_>")]
|
||||
style_runs: Vec<StyleRun>,
|
||||
}
|
||||
|
||||
#[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<RichTextRun> 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<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
|
||||
fn map_text_component(text: String, nav: Option<NavigationEndpoint>) -> TextComponent {
|
||||
fn map_text_component(
|
||||
text: String,
|
||||
style: Style,
|
||||
nav: Option<NavigationEndpoint>,
|
||||
) -> TextComponent {
|
||||
match nav {
|
||||
Some(NavigationEndpoint::Watch { watch_endpoint }) => TextComponent::Video {
|
||||
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,
|
||||
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<NavigationEndpoint>) -> 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::<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:
|
||||
// 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<Regex> = 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<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();
|
||||
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<TextComponent> 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<TextComponent> 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<TextComponents> for crate::model::richtext::RichText {
|
|||
}
|
||||
|
||||
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 {
|
||||
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<TextComponent> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -327,7 +327,7 @@ impl<T> TryRemove<T> for Vec<T> {
|
|||
/// 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<String> {
|
||||
|
|
|
|||
31
testfiles/text/styled_comment.json
Normal file
31
testfiles/text/styled_comment.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in a new issue