use crate::error::Error;
use crate::types::{Line, Part, Section, SimpleChord, Song};
mod iter_part;
mod iter_section;
mod iter_tab;
mod parse_header;
use iter_part::PartIterator;
use iter_section::SectionIterator;
use iter_tab::TabIterator;
#[cfg(feature = "html")]
fn get_nested_field<'a>(json: &'a serde_json::Value, keys: &[&str]) -> Option<&'a str> {
let mut current_value = json;
for key in keys {
current_value = current_value.get(key)?;
}
current_value.as_str()
}
#[cfg(feature = "html")]
pub fn load_html(html: &str) -> Result<Song, Error> {
if html.contains("Just a moment") && html.contains("cf_chl_opt") {
return Err(Error::Parse(
"Ultimate Guitar returned a challenge page (e.g. Cloudflare). \
The site may block simple HTTP clients. \
Try saving the tab page from a browser and pass the HTML, or use a different network."
.into(),
));
}
let html = scraper::Html::parse_document(html);
let selector = scraper::Selector::parse("div.js-store").unwrap();
let element = html.select(&selector).next().ok_or_else(|| {
Error::Parse("div.js-store not found (page may be a challenge or format changed)".into())
})?;
let json = element.value().attr("data-content").ok_or_else(|| {
Error::Parse("data-content not found in js-store (page structure may have changed)".into())
})?;
let json: serde_json::Value = serde_json::from_str(json)?;
let content = get_nested_field(
&json,
&["store", "page", "data", "tab_view", "wiki_tab", "content"],
)
.ok_or(Error::Parse("content not found".into()))?
.replace("\r\n", "\n");
let title = get_nested_field(&json, &["store", "page", "data", "tab", "song_name"])
.ok_or(Error::Parse("title not found".into()))?;
let artist = get_nested_field(&json, &["store", "page", "data", "tab", "artist_name"])
.ok_or(Error::Parse("artist not found".into()))?;
let key = get_nested_field(&json, &["store", "page", "data", "tab", "tonality_name"])
.ok_or(Error::Parse("key not found".into()))?;
load_string(&content, title, artist, key)
}
fn block_is_section(block: &str) -> bool {
let first = block.lines().next().map(str::trim).unwrap_or("");
first.starts_with('[')
&& first.ends_with(']')
&& !first.starts_with("[tab")
&& !first.starts_with("[/tab")
&& !first.starts_with("[ch")
&& !first.starts_with("[/ch")
}
fn parse_section_block(section: &str) -> Result<Section, Error> {
let index = section
.find('\n')
.ok_or_else(|| Error::Parse("section block has no newline".into()))?;
let section_title = section[1..index - 1].to_string();
let lines = TabIterator::new(§ion[index + 1..])
.map(|tab| {
let parts = PartIterator::new(tab).collect::<Result<Vec<Part>, Error>>()?;
Ok(Line::new(parts))
})
.collect::<Result<Vec<Line>, Error>>()?;
Ok(Section::new(section_title, lines))
}
pub fn load_string(content: &str, title: &str, artist: &str, key: &str) -> Result<Song, Error> {
let mut section_iter = SectionIterator::new(content);
let mut tempo = None;
let mut time = None;
let mut sections = Vec::new();
if let Some(first) = section_iter.next() {
(tempo, time) = parse_header::parse_header(first);
if block_is_section(first) {
sections.push(parse_section_block(first)?);
}
}
let rest: Vec<Section> = section_iter
.map(parse_section_block)
.collect::<Result<Vec<Section>, Error>>()?;
sections.extend(rest);
Ok(Song {
titles: vec![title.into()],
subtitle: None, copyright: None, key: Some(SimpleChord::guess_key(key)),
artists: vec![artist.into()],
languages: vec![],
tempo,
time,
tags: Default::default(),
sections,
}
.normalize()
.clone())
}
#[cfg(all(feature = "html", test))]
mod tests {
use super::*;
fn fixture_html_with_store() -> String {
let content = r#"Tempo: 120
[Verse 1]
[ch]C[/ch] Test lyrics"#;
let json = serde_json::json!({
"store": {
"page": {
"data": {
"tab_view": {
"wiki_tab": {
"content": content
}
},
"tab": {
"song_name": "Test Song",
"artist_name": "Test Artist",
"tonality_name": "C"
}
}
}
}
});
let json_str = json.to_string().replace('"', """);
format!(
r#"<!DOCTYPE html><html><body><div class="js-store" data-content="{}"></div></body></html>"#,
json_str
)
}
#[test]
fn load_html_parses_fixture_ug_page() {
let html = fixture_html_with_store();
let song = load_html(&html).expect("load_html should parse fixture");
assert_eq!(song.title(), "Test Song");
assert_eq!(song.artist(), "Test Artist");
assert_eq!(song.sections.len(), 1);
assert_eq!(song.sections[0].title.as_str(), "Verse 1");
assert_eq!(song.sections[0].lines.len(), 1);
}
#[test]
fn first_section_not_lost() {
let content = r#"[Intro]
[ch]C[/ch] [ch]G[/ch]
[Verse 1]
[ch]Am[/ch] lyrics
"#;
let song = load_string(content, "Song", "Artist", "C").expect("parse");
assert_eq!(song.sections.len(), 2, "Intro and Verse 1");
assert_eq!(song.sections[0].title.as_str(), "Intro");
assert_eq!(song.sections[1].title.as_str(), "Verse 1");
}
}