Skip to main content

citum_schema_data/reference/
contributor.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6use crate::reference::types::{MultilingualString, Place};
7#[cfg(feature = "schema")]
8use schemars::JsonSchema;
9use serde::{Deserialize, Serialize};
10#[cfg(feature = "bindings")]
11use specta::Type;
12use std::fmt;
13
14/// Grammatical gender carried on contributor records for role-label agreement.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16#[cfg_attr(feature = "schema", derive(JsonSchema))]
17#[cfg_attr(feature = "bindings", derive(Type))]
18#[serde(rename_all = "kebab-case")]
19pub enum ContributorGender {
20    /// Masculine grammatical gender.
21    Masculine,
22    /// Feminine grammatical gender.
23    Feminine,
24    /// Neuter grammatical gender.
25    Neuter,
26    /// Common or shared grammatical gender.
27    Common,
28}
29
30/// A contributor can be a single string, a structured name, or a list of contributors.
31#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
32#[cfg_attr(feature = "schema", derive(JsonSchema))]
33#[cfg_attr(feature = "bindings", derive(Type))]
34#[serde(untagged)]
35pub enum Contributor {
36    SimpleName(SimpleName),
37    StructuredName(StructuredName),
38    Multilingual(MultilingualName),
39    ContributorList(ContributorList),
40}
41
42/// Holistic multilingual name representation.
43#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
44#[cfg_attr(feature = "schema", derive(JsonSchema))]
45#[cfg_attr(feature = "bindings", derive(Type))]
46#[serde(rename_all = "kebab-case")]
47pub struct MultilingualName {
48    /// The name in its original script.
49    pub original: StructuredName,
50    /// ISO 639/BCP 47 language code for the original name.
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub lang: Option<crate::reference::types::LangID>,
53    /// Hidden whole-name key used only for bibliography sorting.
54    #[serde(rename = "sort-as", skip_serializing_if = "Option::is_none")]
55    pub sort_as: Option<String>,
56    /// Transliterations/Transcriptions of the name.
57    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
58    pub transliterations: std::collections::HashMap<String, StructuredName>,
59    /// Translations of the name.
60    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
61    pub translations: std::collections::HashMap<crate::reference::types::LangID, StructuredName>,
62}
63
64/// A simple name is just a string, with an optional location.
65#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
66#[cfg_attr(feature = "schema", derive(JsonSchema))]
67#[cfg_attr(feature = "bindings", derive(Type))]
68pub struct SimpleName {
69    /// Institutional or organization name.
70    pub name: MultilingualString,
71    /// Geographic place associated with the name.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub location: Option<Place>,
74    /// Short form of the name (e.g., abbreviation or shortened form).
75    #[serde(rename = "short-name", skip_serializing_if = "Option::is_none")]
76    pub short_name: Option<String>,
77}
78
79/// A structured name is a name broken down into its constituent parts.
80#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq)]
81#[cfg_attr(feature = "schema", derive(JsonSchema))]
82#[cfg_attr(feature = "bindings", derive(Type))]
83#[serde(rename_all = "kebab-case")]
84pub struct StructuredName {
85    pub given: MultilingualString,
86    pub family: MultilingualString,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub suffix: Option<String>,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub dropping_particle: Option<String>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub non_dropping_particle: Option<String>,
93}
94
95/// A list of contributors.
96#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
97#[cfg_attr(feature = "schema", derive(JsonSchema))]
98#[cfg_attr(feature = "bindings", derive(Type))]
99pub struct ContributorList(pub Vec<Contributor>);
100
101impl Contributor {
102    pub fn to_names_vec(&self) -> Vec<FlatName> {
103        match self {
104            Contributor::SimpleName(n) => vec![FlatName {
105                literal: Some(n.name.to_string()),
106                short_name: n.short_name.clone(),
107                ..Default::default()
108            }],
109            Contributor::StructuredName(n) => vec![FlatName {
110                given: Some(n.given.to_string()),
111                family: Some(n.family.to_string()),
112                suffix: n.suffix.clone(),
113                dropping_particle: n.dropping_particle.clone(),
114                non_dropping_particle: n.non_dropping_particle.clone(),
115                ..Default::default()
116            }],
117            Contributor::Multilingual(m) => vec![FlatName {
118                given: Some(m.original.given.to_string()),
119                family: Some(m.original.family.to_string()),
120                suffix: m.original.suffix.clone(),
121                dropping_particle: m.original.dropping_particle.clone(),
122                non_dropping_particle: m.original.non_dropping_particle.clone(),
123                ..Default::default()
124            }],
125            Contributor::ContributorList(l) => l.0.iter().flat_map(|c| c.to_names_vec()).collect(),
126        }
127    }
128
129    pub fn name(&self) -> Option<String> {
130        match self {
131            Contributor::SimpleName(n) => Some(n.name.to_string()),
132            Contributor::Multilingual(m) => {
133                Some(format!("{} {}", m.original.given, m.original.family))
134            }
135            _ => None,
136        }
137    }
138
139    pub fn location(&self) -> Option<String> {
140        match self {
141            Contributor::SimpleName(n) => n.location.clone().map(Into::into),
142            _ => None,
143        }
144    }
145}
146
147/// A flattened name for internal processing.
148#[derive(Debug, Clone, Default, PartialEq, Eq)]
149pub struct FlatName {
150    pub family: Option<String>,
151    pub given: Option<String>,
152    pub suffix: Option<String>,
153    pub dropping_particle: Option<String>,
154    pub non_dropping_particle: Option<String>,
155    pub literal: Option<String>,
156    pub short_name: Option<String>,
157    /// Original-script display form of a multilingual name (e.g. `华林甫`),
158    /// carried alongside the selected transliteration so rendering can apply
159    /// native ordering and append the original script after the romanized
160    /// name when a name pattern requests both views.
161    pub original_script: Option<String>,
162}
163
164impl FlatName {
165    pub fn family_or_literal(&self) -> &str {
166        if let Some(ref f) = self.family {
167            f
168        } else if let Some(ref l) = self.literal {
169            l
170        } else {
171            ""
172        }
173    }
174}
175
176impl fmt::Display for Contributor {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        match self {
179            Contributor::SimpleName(n) => write!(f, "{}", n.name),
180            Contributor::StructuredName(n) => write!(f, "{} {}", n.given, n.family),
181            Contributor::Multilingual(m) => write!(f, "{} {}", m.original.given, m.original.family),
182            Contributor::ContributorList(l) => write!(f, "{}", l),
183        }
184    }
185}
186
187impl fmt::Display for ContributorList {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        let names: Vec<String> = self.0.iter().map(|c| c.to_string()).collect();
190        write!(f, "{}", names.join(", "))
191    }
192}
193
194crate::tolerant_enum! {
195    /// A contributor role for use in the unified contributors list.
196    #[derive(Debug, Clone, PartialEq, Eq, Hash)]
197    pub enum ContributorRole {
198        Author = "author",
199        Editor = "editor",
200        Translator = "translator",
201        Director = "director",
202        Performer = "performer",
203        Composer = "composer",
204        Illustrator = "illustrator",
205        Narrator = "narrator",
206        Host = "host",
207        Guest = "guest",
208        Interviewer = "interviewer",
209        Recipient = "recipient",
210        Compiler = "compiler",
211        Producer = "producer",
212        Writer = "writer",
213        /// Author of annotations accompanying the work (BibLaTeX `annotator`).
214        Annotator = "annotator",
215        /// Author of a commentary on the work (BibLaTeX `commentator`).
216        Commentator = "commentator",
217        /// Author of a foreword (BibLaTeX `foreword`).
218        ForewordAuthor = "foreword-author",
219        /// Author of an introduction (BibLaTeX `introduction`).
220        IntroductionAuthor = "introduction-author",
221        /// Author of an afterword (BibLaTeX `afterword`).
222        AfterwordAuthor = "afterword-author"
223    }
224}
225
226/// One or more distinct roles explicitly assigned to a contributor entry.
227#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
228#[cfg_attr(feature = "schema", derive(JsonSchema))]
229#[cfg_attr(feature = "bindings", derive(Type))]
230#[serde(transparent)]
231pub struct ContributorRoles(
232    #[cfg_attr(feature = "schema", schemars(length(min = 1)))] Vec<ContributorRole>,
233);
234
235impl ContributorRoles {
236    /// Return the roles in their authored order.
237    #[must_use]
238    pub fn as_slice(&self) -> &[ContributorRole] {
239        &self.0
240    }
241
242    /// Return whether this entry carries `role`.
243    #[must_use]
244    pub fn contains(&self, role: &ContributorRole) -> bool {
245        self.0.contains(role)
246    }
247
248    /// Add a role when it is not already present.
249    #[cfg(feature = "legacy-convert")]
250    pub(crate) fn insert(&mut self, role: ContributorRole) {
251        if !self.contains(&role) {
252            self.0.push(role);
253        }
254    }
255}
256
257impl From<ContributorRole> for ContributorRoles {
258    fn from(role: ContributorRole) -> Self {
259        Self(vec![role])
260    }
261}
262
263impl PartialEq<ContributorRole> for ContributorRoles {
264    fn eq(&self, other: &ContributorRole) -> bool {
265        self.as_slice() == std::slice::from_ref(other)
266    }
267}
268
269impl TryFrom<Vec<ContributorRole>> for ContributorRoles {
270    type Error = &'static str;
271
272    fn try_from(roles: Vec<ContributorRole>) -> Result<Self, Self::Error> {
273        if roles.is_empty() {
274            return Err("contributor roles must not be empty");
275        }
276        let mut distinct = Vec::with_capacity(roles.len());
277        for role in &roles {
278            if distinct.contains(role) {
279                return Err("contributor roles must be distinct");
280            }
281            distinct.push(role.clone());
282        }
283        Ok(Self(roles))
284    }
285}
286
287impl<'de> Deserialize<'de> for ContributorRoles {
288    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
289    where
290        D: serde::Deserializer<'de>,
291    {
292        #[derive(Deserialize)]
293        #[serde(untagged)]
294        enum AuthoredRoles {
295            Single(ContributorRole),
296            Multiple(Vec<ContributorRole>),
297        }
298
299        let roles = match AuthoredRoles::deserialize(deserializer)? {
300            AuthoredRoles::Single(role) => vec![role],
301            AuthoredRoles::Multiple(roles) => roles,
302        };
303        Self::try_from(roles).map_err(serde::de::Error::custom)
304    }
305}
306
307/// A single entry in a reference's contributors list.
308#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
309#[cfg_attr(feature = "schema", derive(JsonSchema))]
310#[cfg_attr(feature = "bindings", derive(Type))]
311#[serde(rename_all = "kebab-case")]
312pub struct ContributorEntry {
313    /// The explicit roles this contributor plays in relation to the work.
314    #[serde(rename = "roles", alias = "role")]
315    pub roles: ContributorRoles,
316    /// The contributor (name, organization, or list).
317    pub contributor: Contributor,
318    /// The grammatical gender used for role-label agreement.
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub gender: Option<ContributorGender>,
321}