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    }
214}
215
216/// One or more distinct roles explicitly assigned to a contributor entry.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
218#[cfg_attr(feature = "schema", derive(JsonSchema))]
219#[cfg_attr(feature = "bindings", derive(Type))]
220#[serde(transparent)]
221pub struct ContributorRoles(
222    #[cfg_attr(feature = "schema", schemars(length(min = 1)))] Vec<ContributorRole>,
223);
224
225impl ContributorRoles {
226    /// Return the roles in their authored order.
227    #[must_use]
228    pub fn as_slice(&self) -> &[ContributorRole] {
229        &self.0
230    }
231
232    /// Return whether this entry carries `role`.
233    #[must_use]
234    pub fn contains(&self, role: &ContributorRole) -> bool {
235        self.0.contains(role)
236    }
237
238    /// Add a role when it is not already present.
239    #[cfg(feature = "legacy-convert")]
240    pub(crate) fn insert(&mut self, role: ContributorRole) {
241        if !self.contains(&role) {
242            self.0.push(role);
243        }
244    }
245}
246
247impl From<ContributorRole> for ContributorRoles {
248    fn from(role: ContributorRole) -> Self {
249        Self(vec![role])
250    }
251}
252
253impl PartialEq<ContributorRole> for ContributorRoles {
254    fn eq(&self, other: &ContributorRole) -> bool {
255        self.as_slice() == std::slice::from_ref(other)
256    }
257}
258
259impl TryFrom<Vec<ContributorRole>> for ContributorRoles {
260    type Error = &'static str;
261
262    fn try_from(roles: Vec<ContributorRole>) -> Result<Self, Self::Error> {
263        if roles.is_empty() {
264            return Err("contributor roles must not be empty");
265        }
266        let mut distinct = Vec::with_capacity(roles.len());
267        for role in &roles {
268            if distinct.contains(role) {
269                return Err("contributor roles must be distinct");
270            }
271            distinct.push(role.clone());
272        }
273        Ok(Self(roles))
274    }
275}
276
277impl<'de> Deserialize<'de> for ContributorRoles {
278    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
279    where
280        D: serde::Deserializer<'de>,
281    {
282        #[derive(Deserialize)]
283        #[serde(untagged)]
284        enum AuthoredRoles {
285            Single(ContributorRole),
286            Multiple(Vec<ContributorRole>),
287        }
288
289        let roles = match AuthoredRoles::deserialize(deserializer)? {
290            AuthoredRoles::Single(role) => vec![role],
291            AuthoredRoles::Multiple(roles) => roles,
292        };
293        Self::try_from(roles).map_err(serde::de::Error::custom)
294    }
295}
296
297/// A single entry in a reference's contributors list.
298#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
299#[cfg_attr(feature = "schema", derive(JsonSchema))]
300#[cfg_attr(feature = "bindings", derive(Type))]
301#[serde(rename_all = "kebab-case")]
302pub struct ContributorEntry {
303    /// The explicit roles this contributor plays in relation to the work.
304    #[serde(rename = "roles", alias = "role")]
305    pub roles: ContributorRoles,
306    /// The contributor (name, organization, or list).
307    pub contributor: Contributor,
308    /// The grammatical gender used for role-label agreement.
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub gender: Option<ContributorGender>,
311}