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

This commit is contained in:
ThetaDev 2024-04-03 03:28:13 +02:00
parent 449fc0128e
commit b8825f9199
No known key found for this signature in database
GPG key ID: E319D3C5148D65B6
10 changed files with 858 additions and 145 deletions

View file

@ -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

View file

@ -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 McDonalds dont 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(

View file

@ -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",

View file

@ -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&#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 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***~~"###
);
}
}

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: "🎧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,
},
},
],
),

View file

@ -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);
}
}

View file

@ -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("&lt;"),
@ -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("&lt;"),
@ -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> {

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