Skip to main content

music21_rs/
metadata.rs

1//! A score's metadata: music21's `metadata.Metadata`.
2//!
3//! music21 keeps metadata as named values, each name a *unique name* from a
4//! fixed vocabulary drawn from Dublin Core, the MARC relator terms and
5//! Humdrum's reference records, or one a caller made up. A name may carry
6//! several values, and the names keep the order they were first given in,
7//! which is the order a MusicXML writer puts them out in.
8//!
9//! A contributor is a value with a role: the composer, an arranger, a
10//! lyricist. Its unique name is its role where the role is in the
11//! vocabulary, and `otherContributor` where it is not.
12
13use std::borrow::Cow;
14
15/// One value of a metadata name: its text and, for a contributor or a
16/// copyright, the role it was given.
17#[derive(Clone, Debug, PartialEq, Eq)]
18#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
19pub struct MetadataValue {
20    text: String,
21    role: Option<String>,
22}
23
24impl MetadataValue {
25    /// A value that is only text.
26    pub fn new(text: impl Into<String>) -> Self {
27        Self {
28            text: text.into(),
29            role: None,
30        }
31    }
32
33    /// A value with a role, as a contributor or a copyright has.
34    pub fn with_role(text: impl Into<String>, role: impl Into<String>) -> Self {
35        Self {
36            text: text.into(),
37            role: Some(role.into()),
38        }
39    }
40
41    /// The value as text: music21's `str(value)`.
42    pub fn text(&self) -> &str {
43        &self.text
44    }
45
46    /// The role, where the value has one.
47    pub fn role(&self) -> Option<&str> {
48        self.role.as_deref()
49    }
50}
51
52/// A score's metadata: named values in the order the names were first given.
53///
54/// ```
55/// use music21_rs::metadata::Metadata;
56///
57/// let mut md = Metadata::new();
58/// md.add_text("title", "Wer nur den lieben Gott");
59/// md.add_contributor("composer", "J. S. Bach");
60/// assert_eq!(md.first_text("title"), Some("Wer nur den lieben Gott"));
61/// assert_eq!(md.contributors().next().map(|(_, value)| value.text()), Some("J. S. Bach"));
62/// ```
63#[derive(Clone, Debug, Default, PartialEq, Eq)]
64#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
65#[must_use]
66pub struct Metadata {
67    entries: Vec<(String, Vec<MetadataValue>)>,
68}
69
70impl Metadata {
71    /// Metadata with nothing in it.
72    ///
73    /// music21's own constructor starts with its version under `software`;
74    /// this one starts empty, and a caller who wants a software line adds it.
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    /// Adds a value under a unique name, after any it already has.
80    pub fn add(&mut self, unique_name: impl Into<String>, value: MetadataValue) {
81        let unique_name = unique_name.into();
82        match self
83            .entries
84            .iter_mut()
85            .find(|(name, _)| *name == unique_name)
86        {
87            Some((_, values)) => values.push(value),
88            None => self.entries.push((unique_name, vec![value])),
89        }
90    }
91
92    /// Adds a value that is only text.
93    pub fn add_text(&mut self, unique_name: impl Into<String>, text: impl Into<String>) {
94        self.add(unique_name, MetadataValue::new(text));
95    }
96
97    /// Adds a contributor in a role: under the role itself where it is one
98    /// of the vocabulary's contributor names, and under `otherContributor`
99    /// where it is not, as music21's `addContributor` files one.
100    pub fn add_contributor(&mut self, role: impl Into<String>, name: impl Into<String>) {
101        let role = role.into();
102        let unique_name = if is_contributor_unique_name(&role) {
103            role.clone()
104        } else {
105            "otherContributor".to_string()
106        };
107        self.add(unique_name, MetadataValue::with_role(name, role));
108    }
109
110    /// Replaces every value under a unique name.
111    pub fn set(&mut self, unique_name: impl Into<String>, values: Vec<MetadataValue>) {
112        let unique_name = unique_name.into();
113        self.entries.retain(|(name, _)| *name != unique_name);
114        if !values.is_empty() {
115            self.entries.push((unique_name, values));
116        }
117    }
118
119    /// Every value under a unique name, in the order they were added.
120    pub fn get(&self, unique_name: &str) -> &[MetadataValue] {
121        self.entries
122            .iter()
123            .find(|(name, _)| name == unique_name)
124            .map_or(&[], |(_, values)| values.as_slice())
125    }
126
127    /// The text of the first value under a unique name.
128    pub fn first_text(&self, unique_name: &str) -> Option<&str> {
129        self.get(unique_name).first().map(MetadataValue::text)
130    }
131
132    /// Every name and value in order, names in the order they were first
133    /// given and each name's values together: music21's `all` with
134    /// `returnSorted=False`.
135    pub fn all(&self) -> impl Iterator<Item = (&str, &MetadataValue)> {
136        self.entries
137            .iter()
138            .flat_map(|(name, values)| values.iter().map(move |value| (name.as_str(), value)))
139    }
140
141    /// The contributors alone, in the same order.
142    pub fn contributors(&self) -> impl Iterator<Item = (&str, &MetadataValue)> {
143        self.all()
144            .filter(|(name, _)| is_contributor_unique_name(name))
145    }
146
147    /// Every value under a unique name as one piece of text, as music21's
148    /// attribute of that name reads it -- `title`, `composer`, `movementName`:
149    /// one contributor by name, two joined with `and`, more as the first
150    /// `and` how many others; a title's values each with an article written
151    /// after a comma put back in front; anything else the values joined with
152    /// commas. Nothing for a name outside the vocabulary, or with no value.
153    pub fn string_value(&self, unique_name: &str) -> Option<String> {
154        namespace_name(unique_name)?;
155        let values = self.get(unique_name);
156        if values.is_empty() {
157            return None;
158        }
159        if is_contributor_unique_name(unique_name) {
160            return Some(match values {
161                [only] => only.text().to_string(),
162                [first, second] => format!("{} and {}", first.text(), second.text()),
163                [first, rest @ ..] => format!("{} and {} others", first.text(), rest.len()),
164                [] => String::new(),
165            });
166        }
167        let texts: Vec<String> = if ARTICLED_TITLES.contains(&unique_name) {
168            values
169                .iter()
170                .map(|value| {
171                    crate::text::prepend_article(value.text(), None)
172                        .unwrap_or_else(|_| value.text().to_string())
173                })
174                .collect()
175        } else {
176            values
177                .iter()
178                .map(|value| value.text().to_string())
179                .collect()
180        };
181        Some(texts.join(", "))
182    }
183
184    /// The title, as [`Self::string_value`] reads it: music21's `title`.
185    pub fn title(&self) -> Option<String> {
186        self.string_value("title")
187    }
188
189    /// The title, or the next best thing the score says: music21's
190    /// `bestTitle`, which takes the first of `title`, `popularTitle`,
191    /// `alternativeTitle` and `movementName` that says anything.
192    ///
193    /// ```
194    /// use music21_rs::metadata::Metadata;
195    ///
196    /// let mut said = Metadata::new();
197    /// said.add_text("popularTitle", "Eroica");
198    /// assert_eq!(said.best_title().as_deref(), Some("Eroica"));
199    /// said.add_text("title", "Third Symphony");
200    /// assert_eq!(said.best_title().as_deref(), Some("Third Symphony"));
201    /// assert_eq!(Metadata::new().best_title(), None);
202    /// ```
203    pub fn best_title(&self) -> Option<String> {
204        ["title", "popularTitle", "alternativeTitle", "movementName"]
205            .into_iter()
206            .find_map(|name| self.string_value(name).filter(|text| !text.is_empty()))
207    }
208
209    /// The contributors in a role, or the ones with no role of their own
210    /// where none is asked for: music21's `getContributorsByRole`, which a
211    /// MusicXML file needs because some of them name a contributor and no
212    /// role.
213    pub fn contributors_by_role(&self, role: Option<&str>) -> Vec<&MetadataValue> {
214        self.contributors()
215            .filter(|(_, value)| value.role() == role)
216            .map(|(_, value)| value)
217            .collect()
218    }
219
220    /// Whether nothing is in it.
221    pub fn is_empty(&self) -> bool {
222        self.entries.is_empty()
223    }
224
225    /// Every name and value sorted by name and then by text, as music21's
226    /// `all` reads them by default and [`Self::search`] walks them.
227    pub fn all_sorted(&self) -> Vec<(&str, &MetadataValue)> {
228        let mut all: Vec<(&str, &MetadataValue)> = self.all().collect();
229        all.sort_by(|(left, left_value), (right, right_value)| {
230            left.cmp(right)
231                .then_with(|| left_value.text().cmp(right_value.text()))
232        });
233        all
234    }
235
236    /// The first value a query matches, and the field it was found under:
237    /// music21's `search`.
238    ///
239    /// With no `field`, every standard name is searched, sorted by name,
240    /// and then every contributor in the order they were added, under its
241    /// role rather than its unique name so that a made-up role can be
242    /// searched for.
243    ///
244    /// A `field` names where to look, and need not be the whole name:
245    /// music21 takes a unique name, one of its four grandfathered work ids
246    /// (`date`, `volume`, `dedication`, `commission`), one of its twenty
247    /// Humdrum abbreviations (`otl`, `ode`), or any piece of a name --
248    /// `compos` finds `composer`. A field naming nothing finds nothing.
249    ///
250    /// ```
251    /// use music21_rs::metadata::{Metadata, MetadataQuery};
252    ///
253    /// let mut md = Metadata::new();
254    /// md.add_contributor("composer", "Joplin, Scott");
255    /// md.add_text("title", "Maple Leaf Rag");
256    ///
257    /// let joplin = MetadataQuery::Text("joplin");
258    /// assert_eq!(
259    ///     md.search(&joplin, Some("composer")).unwrap().field(),
260    ///     Some("composer")
261    /// );
262    /// assert_eq!(
263    ///     md.search(&joplin, Some("compos")).unwrap().field(),
264    ///     Some("composer")
265    /// );
266    /// assert!(md.search(&joplin, Some("title")).is_none());
267    ///
268    /// let found = md.search(&MetadataQuery::Text("leaf"), None).unwrap();
269    /// assert_eq!((found.field(), found.text()), (Some("title"), "Maple Leaf Rag"));
270    /// ```
271    pub fn search(
272        &self,
273        query: &MetadataQuery<'_>,
274        field: Option<&str>,
275    ) -> Option<MetadataMatch<'_>> {
276        let field = field.map(str::to_lowercase);
277        self.search_fields(field.as_deref())
278            .into_iter()
279            .find(|(text, _)| query.matches(text))
280            .map(|(text, field)| MetadataMatch { field, text })
281    }
282
283    /// The text a search looks through and the field each piece answers
284    /// under, in the order music21 builds them.
285    fn search_fields(&self, field: Option<&str>) -> Vec<(Cow<'_, str>, Option<&str>)> {
286        let mut pairs: Vec<(Cow<'_, str>, Option<&str>)> = Vec::new();
287        match field {
288            Some(field) => {
289                let named = unique_name_of_field(field);
290                let texts =
291                    named.map_or_else(Vec::new, |(unique_name, _)| self.field_texts(unique_name));
292                match (texts.is_empty(), named) {
293                    // music21 answers under the field as it was asked for,
294                    // not under the unique name that field stands for.
295                    (false, Some((_, asked))) => {
296                        pairs.extend(texts.into_iter().map(|text| (text, Some(asked))));
297                    }
298                    _ => pairs.extend(self.partial_field_match(field)),
299                }
300            }
301            None => pairs.extend(
302                self.standard_non_contributors()
303                    .map(|(name, value)| (Cow::Borrowed(value.text()), Some(name))),
304            ),
305        }
306        pairs.extend(
307            self.contributors()
308                .filter(|(_, value)| contributor_answers_to(value.role(), field))
309                .map(|(_, value)| (Cow::Borrowed(value.text()), value.role())),
310        );
311        pairs
312    }
313
314    /// A name's values as a search of that name alone reads them: a
315    /// title-like name's article written back in front, which music21's
316    /// `_getStringValuesByNamespaceName` does and the rest of a search
317    /// does not, so that a title stored as `Grassy Turf, The` is searched
318    /// as `The Grassy Turf` under `title` and as itself under every field.
319    fn field_texts(&self, unique_name: &str) -> Vec<Cow<'_, str>> {
320        self.get(unique_name)
321            .iter()
322            .map(|value| {
323                if ARTICLED_TITLES.contains(&unique_name) {
324                    Cow::Owned(
325                        crate::text::prepend_article(value.text(), None)
326                            .unwrap_or_else(|_| value.text().to_string()),
327                    )
328                } else {
329                    Cow::Borrowed(value.text())
330                }
331            })
332            .collect()
333    }
334
335    /// The vocabulary's names that are not contributors, sorted, with their
336    /// values: music21's `all(skipContributors=True)` without the names a
337    /// caller made up, which it does not search.
338    fn standard_non_contributors(&self) -> impl Iterator<Item = (&str, &MetadataValue)> {
339        self.all_sorted()
340            .into_iter()
341            .filter(|(name, _)| !is_contributor_unique_name(name) && namespace_name(name).is_some())
342    }
343
344    /// The first standard name holding `field` anywhere in it, with the
345    /// first of that name's values: music21's partial field match, which
346    /// stops at the name it finds and searches that one value alone.
347    fn partial_field_match(&self, field: &str) -> Option<(Cow<'_, str>, Option<&str>)> {
348        self.standard_non_contributors().find_map(|(name, value)| {
349            if name.to_lowercase().contains(field) {
350                return Some((Cow::Borrowed(value.text()), Some(name)));
351            }
352            let work_id = old_work_id(name)?;
353            work_id
354                .to_lowercase()
355                .contains(field)
356                .then_some((Cow::Borrowed(value.text()), Some(work_id)))
357        })
358    }
359}
360
361/// Whether a contributor answers to a search field: every contributor where
362/// none was asked for, one whose role holds the field, and a contributor
363/// with no role only under `contributor` itself.
364fn contributor_answers_to(role: Option<&str>, field: Option<&str>) -> bool {
365    let Some(field) = field else {
366        return true;
367    };
368    match role {
369        None => field == "contributor",
370        Some(role) => role.to_lowercase().contains(field),
371    }
372}
373
374/// The unique name an attribute name stands for: the name itself, one of
375/// music21's four grandfathered work ids, or one of its twenty Humdrum
376/// abbreviations. Nothing for a name outside the vocabulary.
377///
378/// Spelling counts, as it does for music21's own attribute access: `otl`
379/// and `title` both name the title, `OTL` names nothing.
380///
381/// ```
382/// use music21_rs::metadata::unique_name_of;
383///
384/// assert_eq!(unique_name_of("title"), Some("title"));
385/// assert_eq!(unique_name_of("otl"), Some("title"));
386/// assert_eq!(unique_name_of("dedication"), Some("dedicatedTo"));
387/// assert_eq!(unique_name_of("OTL"), None);
388/// assert_eq!(unique_name_of("madeUp"), None);
389/// ```
390pub fn unique_name_of(name: &str) -> Option<&'static str> {
391    if let Some((found, _, _)) = STANDARD_PROPERTIES
392        .iter()
393        .find(|(unique, _, _)| *unique == name)
394    {
395        return Some(found);
396    }
397    OLD_MUSIC21_NAMES
398        .iter()
399        .find(|(_, abbreviation, work_id)| *abbreviation == Some(name) || *work_id == Some(name))
400        .map(|(unique, _, _)| *unique)
401}
402
403/// The unique name a search field stands for where it names one outright,
404/// with the spelling it was asked for: the name itself, one of music21's
405/// grandfathered work ids, or one of its Humdrum abbreviations. The field
406/// reaching here is already lowercase, so a name written with a capital in
407/// it is only found by [`Metadata::partial_field_match`], as in music21.
408fn unique_name_of_field(field: &str) -> Option<(&'static str, &'static str)> {
409    if let Some((name, _, _)) = STANDARD_PROPERTIES
410        .iter()
411        .find(|(name, _, _)| *name == field)
412    {
413        return Some((name, name));
414    }
415    OLD_MUSIC21_NAMES
416        .iter()
417        .find_map(
418            |(name, abbreviation, work_id)| match (abbreviation, work_id) {
419                (Some(asked), _) if *asked == field => Some((*name, *asked)),
420                (_, Some(asked)) if *asked == field => Some((*name, *asked)),
421                _ => None,
422            },
423        )
424}
425
426/// The work id a unique name was written as before music21 v8, where it had
427/// one: music21's `UNIQUE_NAME_TO_MUSIC21_WORK_ID`.
428fn old_work_id(unique_name: &str) -> Option<&'static str> {
429    OLD_MUSIC21_NAMES
430        .iter()
431        .find(|(name, _, _)| *name == unique_name)
432        .and_then(|(_, _, work_id)| *work_id)
433}
434
435/// What a [`Metadata::search`] asks of one value's text.
436///
437/// music21 reads a regular expression out of a plain string where it holds
438/// any of `*.|+?{}`, and matches the rest by containment. Nothing here
439/// compiles a pattern -- the crate carries no regular expression engine --
440/// so [`Self::Text`] is music21's plain-string reading alone, and a caller
441/// who wants its regular expression hands over a [`Self::Matcher`] built
442/// from whichever engine they already have.
443pub enum MetadataQuery<'a> {
444    /// A value matches where its text holds this text, either case
445    /// ignored: music21's string query.
446    Text(&'a str),
447    /// A value matches where this answers `true` to its text: music21's
448    /// callable query.
449    Matcher(&'a dyn Fn(&str) -> bool),
450}
451
452impl MetadataQuery<'_> {
453    /// Whether one value's text matches.
454    fn matches(&self, text: &str) -> bool {
455        match self {
456            Self::Text(query) => text.to_lowercase().contains(&query.to_lowercase()),
457            Self::Matcher(matcher) => matcher(text),
458        }
459    }
460}
461
462impl std::fmt::Debug for MetadataQuery<'_> {
463    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
464        match self {
465            Self::Text(query) => formatter.debug_tuple("Text").field(query).finish(),
466            Self::Matcher(_) => formatter.write_str("Matcher(..)"),
467        }
468    }
469}
470
471/// What a [`Metadata::search`] found: the text that matched and the field it
472/// answers under. A contributor with a made-up role answers under that
473/// role, and one with no role at all under no field, which is music21's
474/// `(True, None)`.
475#[derive(Clone, Debug, PartialEq, Eq)]
476pub struct MetadataMatch<'a> {
477    field: Option<&'a str>,
478    text: Cow<'a, str>,
479}
480
481impl<'a> MetadataMatch<'a> {
482    /// The field the value was found under.
483    pub fn field(&self) -> Option<&'a str> {
484        self.field
485    }
486
487    /// The text that matched.
488    pub fn text(&self) -> &str {
489        &self.text
490    }
491}
492
493/// The unique names music21 kept an older spelling of: its
494/// `oldMusic21Abbrev` and `oldMusic21WorkId`, both of which
495/// [`Metadata::search`] takes as a field name. Each row is the unique
496/// name, its Humdrum abbreviation and the work id it was written as
497/// before music21 v8.
498pub const OLD_MUSIC21_NAMES: [(&str, Option<&str>, Option<&str>); 21] = [
499    ("alternativeTitle", Some("ota"), None),
500    ("dateCreated", None, Some("date")),
501    ("title", Some("otl"), None),
502    ("textOriginalLanguage", Some("txo"), None),
503    ("textLanguage", Some("txl"), None),
504    ("popularTitle", Some("otp"), None),
505    ("parentTitle", Some("opr"), None),
506    ("actNumber", Some("oac"), None),
507    ("sceneNumber", Some("osc"), None),
508    ("movementNumber", Some("omv"), None),
509    ("movementName", Some("omd"), None),
510    ("opusNumber", Some("ops"), None),
511    ("number", Some("onm"), None),
512    ("volumeNumber", Some("ovm"), Some("volume")),
513    ("dedicatedTo", Some("ode"), Some("dedication")),
514    ("commissionedBy", Some("oco"), Some("commission")),
515    ("countryOfComposition", Some("ocy"), None),
516    ("localeOfComposition", Some("opc"), None),
517    ("groupTitle", Some("gtl"), None),
518    ("associatedWork", Some("gaw"), None),
519    ("collectionDesignation", Some("gco"), None),
520];
521
522/// The names whose values are titles, an article at their end moved to the
523/// front when read as text: music21's `needsArticleNormalization`.
524const ARTICLED_TITLES: [&str; 6] = [
525    "title",
526    "alternativeTitle",
527    "popularTitle",
528    "parentTitle",
529    "movementName",
530    "groupTitle",
531];
532
533/// Whether a unique name is one of the vocabulary's contributor names:
534/// music21's `_isContributorUniqueName`.
535pub fn is_contributor_unique_name(unique_name: &str) -> bool {
536    STANDARD_PROPERTIES
537        .iter()
538        .any(|(name, _, contributor)| *contributor && *name == unique_name)
539}
540
541/// The namespaced name a unique name stands for, `humdrum:OPC` for
542/// `localeOfComposition`: music21's `uniqueNameToNamespaceName`. A name
543/// outside the vocabulary has none.
544pub fn namespace_name(unique_name: &str) -> Option<&'static str> {
545    STANDARD_PROPERTIES
546        .iter()
547        .find(|(name, _, _)| *name == unique_name)
548        .map(|(_, namespaced, _)| *namespaced)
549}
550
551/// music21's `STANDARD_PROPERTY_DESCRIPTIONS`: each property's unique name,
552/// its namespaced name, and whether it names a contributor.
553pub const STANDARD_PROPERTIES: [(&str, &str, bool); 131] = [
554    ("abstract", "dcterms:abstract", false),
555    ("accessRights", "dcterms:accessRights", false),
556    ("alternativeTitle", "dcterms:alternative", false),
557    ("audience", "dcterms:audience", false),
558    ("dateAvailable", "dcterms:available", false),
559    (
560        "bibliographicCitation",
561        "dcterms:bibliographicCitation",
562        false,
563    ),
564    ("conformsTo", "dcterms:conformsTo", false),
565    ("dateCreated", "dcterms:created", false),
566    ("otherDate", "dcterms:date", false),
567    ("dateAccepted", "dcterms:dateAccepted", false),
568    ("dateCopyrighted", "dcterms:dateCopyrighted", false),
569    ("dateSubmitted", "dcterms:dateSubmitted", false),
570    ("description", "dcterms:description", false),
571    ("educationLevel", "dcterms:educationLevel", false),
572    ("extent", "dcterms:extent", false),
573    ("format", "dcterms:format", false),
574    ("hasFormat", "dcterms:hasFormat", false),
575    ("hasPart", "dcterms:hasPart", false),
576    ("hasVersion", "dcterms:hasVersion", false),
577    ("identifier", "dcterms:identifier", false),
578    ("instructionalMethod", "dcterms:instructionalMethod", false),
579    ("isFormatOf", "dcterms:isFormatOf", false),
580    ("isPartOf", "dcterms:isPartOf", false),
581    ("isReferencedBy", "dcterms:isReferencedBy", false),
582    ("isReplacedBy", "dcterms:isReplacedBy", false),
583    ("isRequiredBy", "dcterms:isRequiredBy", false),
584    ("dateIssued", "dcterms:issued", false),
585    ("isVersionOf", "dcterms:isVersionOf", false),
586    ("language", "dcterms:language", false),
587    ("license", "dcterms:license", false),
588    ("medium", "dcterms:medium", false),
589    ("dateModified", "dcterms:modified", false),
590    ("provenance", "dcterms:provenance", false),
591    ("publisher", "dcterms:publisher", true),
592    ("references", "dcterms:references", false),
593    ("relation", "dcterms:relation", false),
594    ("replaces", "dcterms:replaces", false),
595    ("requires", "dcterms:requires", false),
596    ("copyright", "dcterms:rights", false),
597    ("rightsHolder", "dcterms:rightsHolder", true),
598    ("source", "dcterms:source", false),
599    ("subject", "dcterms:subject", false),
600    ("tableOfContents", "dcterms:tableOfContents", false),
601    ("title", "dcterms:title", false),
602    ("type", "dcterms:type", false),
603    ("dateValid", "dcterms:valid", false),
604    ("adapter", "marcrel:ADP", true),
605    ("analyst", "marcrel:ANL", true),
606    ("annotator", "marcrel:ANN", true),
607    ("arranger", "marcrel:ARR", true),
608    ("quotationsAuthor", "marcrel:AQT", true),
609    ("afterwordAuthor", "marcrel:AFT", true),
610    ("dialogAuthor", "marcrel:AUD", true),
611    ("introductionAuthor", "marcrel:AUI", true),
612    ("calligrapher", "marcrel:CLL", true),
613    ("collaborator", "marcrel:CLB", true),
614    ("collotyper", "marcrel:CLT", true),
615    ("commentaryAuthor", "marcrel:CWT", true),
616    ("compiler", "marcrel:COM", true),
617    ("composer", "marcrel:CMP", true),
618    ("conceptor", "marcrel:CCP", true),
619    ("conductor", "marcrel:CND", true),
620    ("otherContributor", "marcrel:CTB", true),
621    ("editor", "marcrel:EDT", true),
622    ("engraver", "marcrel:EGR", true),
623    ("etcher", "marcrel:ETR", true),
624    ("illuminator", "marcrel:ILU", true),
625    ("illustrator", "marcrel:ILL", true),
626    ("instrumentalist", "marcrel:ITR", true),
627    ("librettist", "marcrel:LBT", true),
628    ("lithographer", "marcrel:LTG", true),
629    ("lyricist", "marcrel:LYR", true),
630    ("metalEngraver", "marcrel:MTE", true),
631    ("musician", "marcrel:MUS", true),
632    ("proofreader", "marcrel:PFR", true),
633    ("platemaker", "marcrel:PLT", true),
634    ("printmaker", "marcrel:PRM", true),
635    ("producer", "marcrel:PRO", true),
636    ("responsibleParty", "marcrel:RPY", true),
637    ("scribe", "marcrel:SCR", true),
638    ("singer", "marcrel:SNG", true),
639    ("transcriber", "marcrel:TRC", true),
640    ("translator", "marcrel:TRL", true),
641    ("woodEngraver", "marcrel:WDE", true),
642    ("woodCutter", "marcrel:WDC", true),
643    ("accompanyingMaterialWriter", "marcrel:WAM", true),
644    ("distributor", "marcrel:DST", true),
645    ("software", "musicxml:software", false),
646    ("textOriginalLanguage", "humdrum:TXO", false),
647    ("textLanguage", "humdrum:TXL", false),
648    ("popularTitle", "humdrum:OTP", false),
649    ("parentTitle", "humdrum:OPR", false),
650    ("actNumber", "humdrum:OAC", false),
651    ("sceneNumber", "humdrum:OSC", false),
652    ("movementNumber", "humdrum:OMV", false),
653    ("movementName", "humdrum:OMD", false),
654    ("opusNumber", "humdrum:OPS", false),
655    ("number", "humdrum:ONM", false),
656    ("volumeNumber", "humdrum:OVM", false),
657    ("dedicatedTo", "humdrum:ODE", false),
658    ("commissionedBy", "humdrum:OCO", false),
659    ("countryOfComposition", "humdrum:OCY", false),
660    ("localeOfComposition", "humdrum:OPC", false),
661    ("groupTitle", "humdrum:GTL", false),
662    ("associatedWork", "humdrum:GAW", false),
663    ("collectionDesignation", "humdrum:GCO", false),
664    ("attributedComposer", "humdrum:COA", true),
665    ("suspectedComposer", "humdrum:COS", true),
666    ("composerAlias", "humdrum:COL", true),
667    ("composerCorporate", "humdrum:COC", true),
668    ("orchestrator", "humdrum:LOR", true),
669    ("firstPublisher", "humdrum:PPR", true),
670    ("dateFirstPublished", "humdrum:PDT", false),
671    ("publicationTitle", "humdrum:PTL", false),
672    ("placeFirstPublished", "humdrum:PPP", false),
673    ("publishersCatalogNumber", "humdrum:PC#", false),
674    ("scholarlyCatalogName", "humdrum:SCA", false),
675    ("scholarlyCatalogAbbreviation", "humdrum:SCT", false),
676    ("manuscriptSourceName", "humdrum:SMS", false),
677    ("manuscriptLocation", "humdrum:SML", false),
678    ("manuscriptAccessAcknowledgement", "humdrum:SMA", false),
679    ("originalDocumentOwner", "humdrum:YOO", true),
680    ("originalEditor", "humdrum:YOE", true),
681    ("electronicEditor", "humdrum:EED", true),
682    ("electronicEncoder", "humdrum:ENC", true),
683    ("electronicPublisher", "humdrum:YEP", true),
684    ("electronicReleaseDate", "humdrum:YER", false),
685    ("fileFormat", "m21FileInfo:fileFormat", false),
686    ("filePath", "m21FileInfo:filePath", false),
687    ("corpusFilePath", "m21FileInfo:corpusFilePath", false),
688    ("fileNumber", "m21FileInfo:fileNumber", false),
689];
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    /// Every answer here is music21's own for the same metadata.
696    #[test]
697    fn the_best_title_is_the_first_one_said() {
698        let title = |name: &str, text: &str| {
699            let mut said = Metadata::new();
700            said.add_text(name, text);
701            said.best_title()
702        };
703        assert_eq!(
704            title("title", "Third Symphony").as_deref(),
705            Some("Third Symphony")
706        );
707        assert_eq!(title("popularTitle", "Eroica").as_deref(), Some("Eroica"));
708        assert_eq!(
709            title("alternativeTitle", "Bonaparte").as_deref(),
710            Some("Bonaparte")
711        );
712        assert_eq!(title("movementName", "Allegro").as_deref(), Some("Allegro"));
713        assert_eq!(Metadata::new().best_title(), None);
714
715        // The title itself wins over the one it is popularly called.
716        let mut both = Metadata::new();
717        both.add_text("popularTitle", "Eroica");
718        both.add_text("title", "Third Symphony");
719        assert_eq!(both.best_title().as_deref(), Some("Third Symphony"));
720    }
721
722    /// Every answer here is music21's own for the same contributors.
723    #[test]
724    fn contributors_are_found_by_their_role() {
725        let mut said = Metadata::new();
726        said.add_text("title", "Violin Concerto");
727        said.add_contributor("composer", "Price, Florence");
728        // A contributor with no role of its own, as a MusicXML file may
729        // name one: music21 files it under `otherContributor`.
730        said.add("otherContributor", MetadataValue::new("Baron van Swieten"));
731
732        let names = |role: Option<&str>| -> Vec<String> {
733            said.contributors_by_role(role)
734                .iter()
735                .map(|value| value.text().to_string())
736                .collect()
737        };
738        assert_eq!(names(Some("composer")), ["Price, Florence"]);
739        assert_eq!(names(None), ["Baron van Swieten"]);
740        assert!(names(Some("lyricist")).is_empty());
741
742        let mut two = Metadata::new();
743        two.add_contributor("composer", "A");
744        two.add_contributor("composer", "B");
745        assert_eq!(
746            two.contributors_by_role(Some("composer"))
747                .iter()
748                .map(|value| value.text())
749                .collect::<Vec<&str>>(),
750            ["A", "B"]
751        );
752    }
753
754    #[test]
755    fn names_keep_the_order_they_were_first_given() {
756        let mut md = Metadata::new();
757        md.add_text("software", "Finale");
758        md.add_text("movementName", "Grave");
759        md.add_text("software", "Dolet");
760        let all: Vec<(&str, &str)> = md.all().map(|(name, value)| (name, value.text())).collect();
761        assert_eq!(
762            all,
763            [
764                ("software", "Finale"),
765                ("software", "Dolet"),
766                ("movementName", "Grave")
767            ]
768        );
769    }
770
771    #[test]
772    fn a_role_outside_the_vocabulary_is_another_contributor() {
773        let mut md = Metadata::new();
774        md.add_contributor("composer", "Caccini");
775        md.add_contributor("tuba player", "Somebody");
776        let names: Vec<&str> = md.contributors().map(|(name, _)| name).collect();
777        assert_eq!(names, ["composer", "otherContributor"]);
778        assert_eq!(md.get("otherContributor")[0].role(), Some("tuba player"));
779    }
780
781    #[test]
782    fn a_search_of_one_name_reads_a_title_with_its_article_in_front() {
783        let mut md = Metadata::new();
784        md.add_text("title", "Grassy Turf, The");
785        // music21 normalizes the article only where the field is named
786        // outright, and leaves the stored text alone everywhere else.
787        let query = MetadataQuery::Text("The Grassy");
788        assert_eq!(
789            md.search(&query, Some("title"))
790                .map(|found| found.text().to_string()),
791            Some("The Grassy Turf".to_string())
792        );
793        assert!(md.search(&query, None).is_none());
794        assert!(md.search(&query, Some("titl")).is_none());
795    }
796
797    #[test]
798    fn a_contributor_answers_under_the_role_it_was_given() {
799        let mut md = Metadata::new();
800        md.add_contributor("tuba player", "Somebody");
801        let query = MetadataQuery::Text("somebody");
802        assert_eq!(
803            md.search(&query, None).and_then(|found| found.field()),
804            Some("tuba player")
805        );
806        assert_eq!(
807            md.search(&query, Some("tuba"))
808                .and_then(|found| found.field()),
809            Some("tuba player")
810        );
811        // A role of its own is not reached under `contributor`, as in
812        // music21, which only asks that of a contributor with no role.
813        assert!(md.search(&query, Some("contributor")).is_none());
814    }
815
816    #[test]
817    fn a_matcher_searches_where_music21_would_read_a_pattern() {
818        let mut md = Metadata::new();
819        md.add_text("title", "Maple Leaf Rag");
820        md.add_contributor("composer", "Joplin, Scott");
821        let ends_in_rag = |text: &str| text.ends_with("Rag");
822        let found = md
823            .search(&MetadataQuery::Matcher(&ends_in_rag), None)
824            .expect("the title");
825        assert_eq!(
826            (found.field(), found.text()),
827            (Some("title"), "Maple Leaf Rag")
828        );
829        assert!(
830            md.search(&MetadataQuery::Matcher(&ends_in_rag), Some("composer"))
831                .is_none()
832        );
833    }
834
835    #[test]
836    fn the_vocabulary_names_its_namespaces() {
837        assert_eq!(namespace_name("localeOfComposition"), Some("humdrum:OPC"));
838        assert_eq!(namespace_name("dateCreated"), Some("dcterms:created"));
839        assert_eq!(namespace_name("composer"), Some("marcrel:CMP"));
840        assert_eq!(namespace_name("madeUp"), None);
841        assert!(is_contributor_unique_name("lyricist"));
842        assert!(!is_contributor_unique_name("title"));
843    }
844}