use handlebars::{Handlebars, TemplateError};
use serde_json::{json, Map, Value};
use crate::song::Song;
use crate::templating::variables_for_song;
#[derive(Clone, PartialEq, Eq, Debug)]
#[derive(Default)]
pub enum TextFormat {
#[default]
Plain,
Markdown,
Telegram,
Custom(String),
}
impl TextFormat {
pub fn template(&self) -> &str {
match self {
TextFormat::Plain => PLAIN_TEMPLATE,
TextFormat::Markdown => MARKDOWN_TEMPLATE,
TextFormat::Telegram => TELEGRAM_TEMPLATE,
TextFormat::Custom(template) => template,
}
}
pub fn parse(name: &str) -> Option<TextFormat> {
match name.trim().to_lowercase().as_str() {
"plain" | "text" | "txt" => Some(TextFormat::Plain),
"markdown" | "md" => Some(TextFormat::Markdown),
"telegram" => Some(TextFormat::Telegram),
_ => None,
}
}
}
impl std::str::FromStr for TextFormat {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
TextFormat::parse(s).ok_or_else(|| {
format!("unknown text format '{}' (expected plain, markdown or telegram)", s)
})
}
}
const PLAIN_TEMPLATE: &str = "\
{{title}}
{{#each parts}}
{{text}}
{{/each}}";
const MARKDOWN_TEMPLATE: &str = "\
# {{title}}
{{#if author}}
*{{author}}*
{{/if}}
{{#each parts}}
{{text}}
{{/each}}";
const TELEGRAM_TEMPLATE: &str = "\
**{{title}}**
{{#each parts}}
{{text}}
{{/each}}";
#[derive(Clone, PartialEq, Eq, Debug, Default)]
pub struct TextSettings {
pub format: TextFormat,
pub language: Option<String>,
pub song_separator: Option<String>,
}
impl TextSettings {
pub fn with_format(format: TextFormat) -> TextSettings {
TextSettings {
format,
..TextSettings::default()
}
}
fn separator(&self) -> &str {
self.song_separator.as_deref().unwrap_or("\n")
}
}
pub fn text_from_song(song: &Song, settings: &TextSettings) -> Result<String, TemplateError> {
let registry = registry(settings)?;
Ok(render(®istry, &context_for_song(song, settings)))
}
pub fn text_from_songs(songs: &[Song], settings: &TextSettings) -> Result<String, TemplateError> {
let registry = registry(settings)?;
let rendered: Vec<String> = songs
.iter()
.map(|song| render(®istry, &context_for_song(song, settings)))
.filter(|text| !text.is_empty())
.collect();
Ok(rendered.join(&format!("\n{}\n", settings.separator())))
}
fn registry(settings: &TextSettings) -> Result<Handlebars<'static>, TemplateError> {
let mut registry = Handlebars::new();
registry.register_escape_fn(handlebars::no_escape);
registry.set_strict_mode(false);
registry.register_template_string(TEMPLATE_NAME, settings.format.template())?;
Ok(registry)
}
const TEMPLATE_NAME: &str = "song";
fn render(registry: &Handlebars, context: &Value) -> String {
registry
.render(TEMPLATE_NAME, context)
.map(|text| text.trim_end().to_string())
.unwrap_or_default()
}
fn context_for_song(song: &Song, settings: &TextSettings) -> Value {
let mut context = Map::new();
for (key, value) in variables_for_song(song) {
context.insert(key, Value::String(value));
}
let mut parts: Vec<Value> = Vec::new();
for part in song.ordered_parts() {
let Some(content) =
part.lyrics_for(settings.language.as_deref(), song.default_language.as_deref())
else {
continue;
};
parts.push(json!({
"id": part.id().to_string(),
"label": part.display_label(),
"kind": part.part_type.to_string(),
"number": part.number,
"language": content_language(content),
"text": crate::exporter::slides::lyrics_for_reading(&content.content),
}));
}
let total = parts.len();
for (index, part) in parts.iter_mut().enumerate() {
let object = part.as_object_mut().expect("built as an object above");
object.insert("position".to_string(), json!(index + 1));
object.insert("first".to_string(), json!(index == 0));
object.insert("last".to_string(), json!(index + 1 == total));
}
let language = parts
.first()
.and_then(|part| part.get("language"))
.cloned()
.unwrap_or(Value::Null);
context.insert("language".to_string(), language);
context.insert("parts".to_string(), Value::Array(parts));
Value::Object(context)
}
fn content_language(content: &crate::song::SongPartContent) -> Value {
match &content.content_type {
crate::song::SongPartContentType::Lyrics { language } => match language.code() {
Some(code) => Value::String(code.to_string()),
None => Value::Null,
},
_ => Value::Null,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::importer::{import_song_from_file, song_yml};
fn amazing_grace() -> Song {
import_song_from_file("tests/data/Amazing Grace.song.yml").unwrap()
}
#[test]
fn test_plain_text_is_title_then_verses() {
let text = text_from_song(&amazing_grace(), &TextSettings::default()).unwrap();
assert_eq!(
text,
"Amazing Grace\n\
\n\
Amazing grace, How sweet the sound\n\
That saved a wretch like me.\n\
I once was lost, but now I'm found,\n\
Was blind, but now I see.\n\
\n\
Twas grace that taught my heart to fear,\n\
And grace my fears relieved.\n\
How precious did that grace appear\n\
The hour I first believed.\n\
\n\
Through many dangers, toils and snares\n\
I have already come,\n\
'Tis grace has brought me safe thus far\n\
And grace will lead me home."
);
}
#[test]
fn test_lilypond_markup_is_stripped() {
let song = import_song_from_file("tests/data/Sei nicht stolz auf das, was du bist.song.yml")
.unwrap();
let text = text_from_song(&song, &TextSettings::default()).unwrap();
assert!(text.contains("denn nur Gott gut und heilig ist."));
assert!(!text.contains("hei -- lig"));
assert!(!text.contains("\\set"));
}
#[test]
fn test_singing_order_is_used() {
let song = import_song_from_file("tests/data/Sei nicht stolz auf das, was du bist.song.yml")
.unwrap();
let text = text_from_song(&song, &TextSettings::default()).unwrap();
assert_eq!(
text.matches("Denn wer sich rühmen will,").count(),
3,
"the refrain should follow each of the three verses:\n{}",
text
);
let first_verse = text.find("Sei nicht stolz").unwrap();
let first_refrain = text.find("Denn wer sich").unwrap();
let second_verse = text.find("Menschen suchen").unwrap();
assert!(first_verse < first_refrain && first_refrain < second_verse);
}
#[test]
fn test_markdown() {
let text =
text_from_song(&amazing_grace(), &TextSettings::with_format(TextFormat::Markdown))
.unwrap();
assert!(text.starts_with("# Amazing Grace\n*John Newton*\n"));
assert!(text.contains("Amazing grace, How sweet the sound"));
}
#[test]
fn test_markdown_without_an_author() {
let mut song = amazing_grace();
song.remove_tag("author");
let text =
text_from_song(&song, &TextSettings::with_format(TextFormat::Markdown)).unwrap();
assert!(text.starts_with("# Amazing Grace\n\nAmazing grace"), "{}", text);
assert!(!text.contains("**"));
}
#[test]
fn test_telegram() {
let text =
text_from_song(&amazing_grace(), &TextSettings::with_format(TextFormat::Telegram))
.unwrap();
assert!(text.starts_with("**Amazing Grace**\n\nAmazing grace"), "{}", text);
}
#[test]
fn test_custom_template() {
let settings = TextSettings::with_format(TextFormat::Custom(
"{{title}} [{{language}}]\n{{#each parts}}{{position}}. {{label}}: {{text}}\n{{/each}}"
.to_string(),
));
let text = text_from_song(&amazing_grace(), &settings).unwrap();
assert!(text.starts_with("Amazing Grace [en]"));
assert!(text.contains("1. verse.1: Amazing grace"));
assert!(text.contains("2. verse.2: Twas grace"));
}
#[test]
fn test_malformed_custom_template_is_reported() {
let settings = TextSettings::with_format(TextFormat::Custom(
"{{#if title}}never closed".to_string(),
));
assert!(text_from_song(&amazing_grace(), &settings).is_err());
}
#[test]
fn test_format_parsing() {
assert_eq!(TextFormat::parse("markdown"), Some(TextFormat::Markdown));
assert_eq!(TextFormat::parse("MD"), Some(TextFormat::Markdown));
assert_eq!(TextFormat::parse(" telegram "), Some(TextFormat::Telegram));
assert_eq!(TextFormat::parse("plain"), Some(TextFormat::Plain));
assert_eq!(TextFormat::parse("nope"), None);
}
#[test]
fn test_the_requested_language_is_used() {
let yml = r#"
version: 0.1
title: Two Languages
default_language: en
parts:
- type: stanza
contents:
- type: lyrics
number: 1
language: en
content: Hello world
- type: lyrics
number: 1
language: de
content: Hallo Welt
"#;
let song = song_yml::import_from_yml_string(yml).unwrap();
let text = text_from_song(&song, &TextSettings::default()).unwrap();
assert!(text.contains("Hello world"), "{}", text);
let german = TextSettings {
language: Some("de".to_string()),
..TextSettings::default()
};
let text = text_from_song(&song, &german).unwrap();
assert!(text.contains("Hallo Welt"), "{}", text);
assert!(!text.contains("Hello world"));
}
#[test]
fn test_song_without_language_information() {
let song = import_song_from_file("tests/data/Amazing Grace.song").unwrap();
let text = text_from_song(&song, &TextSettings::default()).unwrap();
assert!(text.starts_with("Amazing Grace\n\nAmazing grace"), "{}", text);
}
#[test]
fn test_several_songs_in_one_document() {
let songs = [
amazing_grace(),
import_song_from_file("tests/data/Weiß ich den Weg auch nicht.ccli").unwrap(),
];
let text =
text_from_songs(&songs, &TextSettings::with_format(TextFormat::Markdown)).unwrap();
assert!(text.contains("# Amazing Grace"));
assert!(text.contains("# Weiß ich den Weg auch nicht (Pax Dei)"));
assert!(text.find("# Amazing Grace") < text.find("# Weiß ich"));
}
#[test]
fn test_custom_song_separator() {
let songs = [amazing_grace(), amazing_grace()];
let settings = TextSettings {
song_separator: Some("---".to_string()),
..TextSettings::default()
};
let text = text_from_songs(&songs, &settings).unwrap();
assert_eq!(text.matches("\n---\n").count(), 1);
}
#[test]
fn test_no_songs_gives_no_output() {
assert_eq!(text_from_songs(&[], &TextSettings::default()).unwrap(), "");
}
}