1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
//! This crate also provides a very small wrapper cli for directly converting and parsing song files.
use cantara_songlib::exporter;
use cantara_songlib::exporter::slides::chapters_from_songs;
use cantara_songlib::exporter::text::{text_from_songs, TextFormat, TextSettings};
use cantara_songlib::exporter::abc::AbcSettings;
use cantara_songlib::exporter::lilypond::LilypondSettings;
use cantara_songlib::importer::import_song_from_file;
use cantara_songlib::slides::{
LanguageConfiguration, ShowMetaInformation, SlideElement, SlideSettings,
};
use cantara_songlib::templating::MetaTemplate;
use cantara_songlib::slides_from_file;
use clap::{Parser, Subcommand};
use std::path::PathBuf;
#[derive(Parser)]
#[command(version, about)]
#[command(subcommand_precedence_over_arg = true)]
struct Cli {
#[command(subcommand)]
command: Commands,
/// The song file(s) to read. The `text` and `presentation` commands accept
/// several; the music formats take exactly one.
#[arg(global = true)]
files: Vec<PathBuf>,
}
/// Where the meta information line may appear, as selectable on the command
/// line. Maps onto [`ShowMetaInformation`].
#[derive(Copy, Clone, PartialEq, Eq, clap::ValueEnum)]
enum MetaPosition {
/// On the song's title slide
Title,
/// On the first content slide
First,
/// On the last content slide
Last,
}
impl MetaPosition {
/// Fold the selected positions into the library's settings type.
fn to_settings(positions: &[MetaPosition]) -> ShowMetaInformation {
let mut show = ShowMetaInformation::none();
for position in positions {
match position {
MetaPosition::Title => show.title_slide = true,
MetaPosition::First => show.first_slide = true,
MetaPosition::Last => show.last_slide = true,
}
}
show
}
}
#[derive(Subcommand)]
enum Commands {
/// Generates a presentation with presentation slides
Presentation {
/// Use a specific language for single-language slides (e.g. "en", "de")
#[arg(short, long)]
language: Option<String>,
/// Enable multi-language slides. Optionally provide a comma-separated list
/// of language codes in display order (e.g. "en,de,fr").
/// If no languages are specified, all available languages are used.
#[arg(short, long, value_name = "LANGS")]
multi_language: Option<Option<String>>,
/// Complex layout: stack notation and any number of languages on each
/// slide, in the given order. Use "notation" (or "abc") for the melody
/// and a language code for lyrics, e.g. --show notation,en,de
#[arg(long, value_name = "ROWS", value_delimiter = ',')]
show: Vec<String>,
/// Maximum number of lyrics lines per slide; longer parts are wrapped
#[arg(long, value_name = "N")]
max_lines: Option<usize>,
/// Handlebars template for the meta information line, e.g.
/// "{{title}} ({{author}})". Available variables are the song's tags
/// plus "title". Empty means no meta information.
#[arg(long, value_name = "TEMPLATE", default_value = "")]
meta_syntax: String,
/// Where to show the meta information. Repeat the flag or separate the
/// values with a comma, e.g. --show-meta title,first.
#[arg(long, value_name = "WHERE", value_delimiter = ',', value_enum)]
show_meta: Vec<MetaPosition>,
/// Omit the separate title slide at the beginning of the song
#[arg(long)]
no_title_slide: bool,
/// Group the output into chapters, one per song. Implied when more than
/// one input file is given.
#[arg(long)]
chapters: bool,
},
/// Writes the lyrics as plain text or in a markup of your choosing
Text {
/// Output markup: plain, markdown or telegram
#[arg(short, long, default_value = "plain")]
format: TextFormat,
/// A Handlebars template to use instead of a built-in format. See the
/// documentation for the variables it can use.
#[arg(long, value_name = "TEMPLATE", conflicts_with = "format")]
template: Option<String>,
/// Which language to take the lyrics from (e.g. "en", "de")
#[arg(short, long)]
language: Option<String>,
/// What to put between songs when several are exported
#[arg(long, value_name = "TEXT")]
separator: Option<String>,
},
/// Generates a LilyPond (.ly) music sheet file
Lilypond {
/// Paper size for the output (default: "a4")
#[arg(short, long, default_value = "a4")]
paper_size: String,
/// Layout indent setting (default: "#0")
#[arg(short, long, default_value = "#0")]
indent: String,
},
/// Generates an ABC notation (.abc) music file
Abc {
/// Unit note length for the output (default: "1/4")
#[arg(short, long, default_value = "1/4")]
unit_note_length: String,
/// Include chord symbols above the staff
#[arg(long, default_value = "true", action = clap::ArgAction::Set)]
include_chords: bool,
/// Include all verses in the output (if false, only first verse is included)
#[arg(long, default_value = "true", action = clap::ArgAction::Set)]
all_verses: bool,
},
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
/// Check that every input exists before doing any work.
fn inputs(files: &[PathBuf]) -> Result<&[PathBuf], Box<dyn std::error::Error>> {
if files.is_empty() {
return Err("no input file was provided".into());
}
for file in files {
if !file.is_file() {
return Err(format!("'{}' is not a file", file.display()).into());
}
}
Ok(files)
}
/// The single input file, for the commands that take exactly one.
fn only(files: &[PathBuf]) -> Result<&PathBuf, Box<dyn std::error::Error>> {
match files {
[file] => Ok(file),
_ => Err(format!(
"this command takes exactly one input file, {} were given",
files.len()
)
.into()),
}
}
match &cli.command {
Commands::Presentation {
language,
multi_language,
chapters,
show,
max_lines,
meta_syntax,
show_meta,
no_title_slide,
} => {
let files = inputs(&cli.files)?;
let lang_config = if !show.is_empty() {
// --show wins: it is the most specific of the three.
LanguageConfiguration::Complex(
show.iter().map(|row| SlideElement::parse(row)).collect(),
)
} else if let Some(multi) = multi_language {
// --multi-language was passed
let langs: Vec<String> = match multi {
Some(s) => s.split(',').map(|l| l.trim().to_string()).collect(),
None => vec![],
};
LanguageConfiguration::MultiLanguage(langs)
} else {
LanguageConfiguration::SingleLanguage(language.clone())
};
// Fail early and clearly on a broken template rather than
// silently producing slides without any metadata.
MetaTemplate::parse(meta_syntax)
.map_err(|error| format!("invalid --meta-syntax template: {}", error))?;
let settings = SlideSettings {
language: lang_config,
max_lines: *max_lines,
title_slide: !no_title_slide,
meta_syntax: meta_syntax.clone(),
show_meta_information: MetaPosition::to_settings(show_meta),
..SlideSettings::default()
};
// Several songs — or an explicit request — are grouped into
// chapters so that a presentation can tell them apart.
if files.len() > 1 || *chapters {
let songs: Result<Vec<_>, _> = files.iter().map(import_song_from_file).collect();
let chapters = chapters_from_songs(&songs?, &settings);
println!("{}", serde_json::to_string_pretty(&chapters)?);
} else {
let slides = slides_from_file(only(files)?, &settings)?;
println!("{}", serde_json::to_string_pretty(&slides)?);
}
}
Commands::Text {
format,
template,
language,
separator,
} => {
let files = inputs(&cli.files)?;
let settings = TextSettings {
format: match template {
Some(template) => TextFormat::Custom(template.clone()),
None => format.clone(),
},
language: language.clone(),
song_separator: separator.clone(),
};
let songs: Result<Vec<_>, _> = files.iter().map(import_song_from_file).collect();
println!("{}", text_from_songs(&songs?, &settings)?);
}
Commands::Lilypond {
paper_size,
indent,
} => {
let song = import_song_from_file(only(inputs(&cli.files)?)?)?;
let settings = LilypondSettings {
paper_size: paper_size.clone(),
layout_indent: indent.clone(),
..LilypondSettings::default()
};
match exporter::lilypond::lilypond_from_song(&song, &settings) {
Ok(ly_output) => println!("{}", ly_output),
Err(e) => return Err(e.into()),
}
}
Commands::Abc {
unit_note_length,
include_chords,
all_verses,
} => {
let song = import_song_from_file(only(inputs(&cli.files)?)?)?;
let settings = AbcSettings {
unit_note_length: unit_note_length.clone(),
include_chords: *include_chords,
include_all_verses: *all_verses,
};
match exporter::abc::abc_from_song(&song, &settings) {
Ok(abc_output) => println!("{}", abc_output),
Err(e) => return Err(e.into()),
}
}
}
Ok(())
}