Skip to main content

aspero_core/
csl.rs

1//! CSL-JSON items, as served by doi.org content negotiation and used for
2//! citation export/import.
3//!
4//! Reading is lenient, because real producers deviate from the spec:
5//! `title`/`container-title` arrive as a string or an array; Crossref's
6//! "CSL" uses its own type names (`journal-article`) rather than CSL's
7//! (`article-journal`); abstracts may be JATS XML. Unknown fields are
8//! ignored (the raw response is kept elsewhere).
9
10use std::collections::BTreeMap;
11
12use serde::Deserialize;
13
14use crate::id::{Doi, WorkId};
15use crate::work::{
16    PartialDate, Person, Update, UpdateKind, UpdateTo, Work, WorkType, tidy_abstract,
17};
18
19/// One CSL-JSON item.
20#[derive(Debug, Clone, Default, Deserialize)]
21#[serde(default, rename_all = "kebab-case")]
22#[non_exhaustive]
23pub struct CslItem {
24    #[serde(rename = "type")]
25    pub item_type: Option<String>,
26    pub title: Option<OneOrMany>,
27    pub container_title: Option<OneOrMany>,
28    pub author: Vec<CslName>,
29    pub issued: Option<CslDate>,
30    pub published: Option<CslDate>,
31    pub publisher: Option<String>,
32    pub volume: Option<Scalar>,
33    pub issue: Option<Scalar>,
34    pub page: Option<Scalar>,
35    #[serde(rename = "DOI")]
36    pub doi: Option<String>,
37    #[serde(rename = "URL")]
38    pub url: Option<String>,
39    #[serde(rename = "abstract")]
40    pub abstract_text: Option<String>,
41    pub is_referenced_by_count: Option<u64>,
42    /// Crossref-registered DOIs carry their notices here (see
43    /// [`CrossrefUpdate`]).
44    pub updated_by: Vec<CrossrefUpdate>,
45    /// And notices carry the works they update here.
46    pub update_to: Vec<CrossrefUpdate>,
47    /// Typed links to other works, e.g. `{"is-replaced-by": [{"id-type":
48    /// "doi", "id": "10.…"}]}`; see [`relation_updates`].
49    pub relation: BTreeMap<String, Vec<CrossrefRelation>>,
50}
51
52/// One target of a Crossref `relation`.
53#[derive(Debug, Clone, Default, Deserialize)]
54#[serde(default, rename_all = "kebab-case")]
55pub struct CrossrefRelation {
56    pub id_type: Option<String>,
57    pub id: Option<String>,
58}
59
60/// Newer versions from Crossref relations: a replacement
61/// (`is-replaced-by`) or a preprint's published article (`is-preprint-of`).
62pub fn relation_updates(relation: &BTreeMap<String, Vec<CrossrefRelation>>) -> Vec<Update> {
63    ["is-replaced-by", "is-preprint-of"]
64        .iter()
65        .filter_map(|kind| relation.get(*kind))
66        .flatten()
67        .filter(|r| r.id_type.as_deref() == Some("doi"))
68        .filter_map(|r| r.id.as_deref()?.parse::<Doi>().ok())
69        .map(|doi| {
70            let mut update = Update::new(UpdateKind::NewVersion);
71            update.notice = Some(WorkId::Doi(doi));
72            update.source = Some("publisher".into());
73            update
74        })
75        .collect()
76}
77
78/// One entry of Crossref's `updated-by` (or `update-to`, where the DOI is
79/// the updated work's): a notice about the work, e.g.
80/// `{"DOI": "10.1016/…", "type": "retraction", "source":
81/// "retraction-watch", "updated": {"date-parts": [[2010, 2, 6]]}}`. The
82/// same shape appears in Crossref's REST API and its CSL via doi.org.
83#[derive(Debug, Clone, Default, Deserialize)]
84#[serde(default)]
85pub struct CrossrefUpdate {
86    #[serde(rename = "DOI")]
87    pub doi: Option<String>,
88    #[serde(rename = "type")]
89    pub update_type: Option<String>,
90    pub source: Option<String>,
91    pub updated: Option<CslDate>,
92}
93
94impl CrossrefUpdate {
95    pub fn to_update(&self) -> Option<Update> {
96        let mut update = Update::new(UpdateKind::parse(self.update_type.as_deref()?));
97        update.notice = self
98            .doi
99            .as_deref()
100            .and_then(|d| d.parse::<Doi>().ok())
101            .map(WorkId::Doi);
102        update.date = self.updated.as_ref().and_then(CslDate::to_partial);
103        update.source = self.source.clone();
104        Some(update)
105    }
106
107    pub fn to_update_to(&self) -> Option<UpdateTo> {
108        let kind = UpdateKind::parse(self.update_type.as_deref()?);
109        let work = WorkId::Doi(self.doi.as_deref()?.parse::<Doi>().ok()?);
110        let mut update = UpdateTo::new(kind, work);
111        update.date = self.updated.as_ref().and_then(CslDate::to_partial);
112        update.source = self.source.clone();
113        Some(update)
114    }
115}
116
117/// A string, or an array of strings of which the first is used.
118#[derive(Debug, Clone, Deserialize)]
119#[serde(untagged)]
120pub enum OneOrMany {
121    One(String),
122    Many(Vec<String>),
123}
124
125impl OneOrMany {
126    pub fn first(&self) -> Option<&str> {
127        match self {
128            OneOrMany::One(s) => Some(s),
129            OneOrMany::Many(v) => v.first().map(String::as_str),
130        }
131    }
132}
133
134/// CSL allows numbers or strings for volume, issue and page.
135#[derive(Debug, Clone, Deserialize)]
136#[serde(untagged)]
137pub enum Scalar {
138    Text(String),
139    Number(i64),
140}
141
142impl Scalar {
143    fn text(&self) -> String {
144        match self {
145            Scalar::Text(s) => s.clone(),
146            Scalar::Number(n) => n.to_string(),
147        }
148    }
149}
150
151#[derive(Debug, Clone, Default, Deserialize)]
152#[serde(default)]
153pub struct CslName {
154    pub family: Option<String>,
155    pub given: Option<String>,
156    pub literal: Option<String>,
157    #[serde(rename = "ORCID")]
158    pub orcid: Option<String>,
159}
160
161/// `{"date-parts": [[2015, 5, 27]]}`; parts may be missing, null or strings.
162#[derive(Debug, Clone, Default, Deserialize)]
163#[serde(default, rename_all = "kebab-case")]
164pub struct CslDate {
165    pub date_parts: Vec<Vec<Option<serde_json::Value>>>,
166}
167
168impl CslDate {
169    pub fn to_partial(&self) -> Option<PartialDate> {
170        let parts = self.date_parts.first()?;
171        let get = |i: usize| -> Option<i64> {
172            match parts.get(i)?.as_ref()? {
173                serde_json::Value::Number(n) => n.as_i64(),
174                serde_json::Value::String(s) => s.trim().parse().ok(),
175                _ => None,
176            }
177        };
178        let year = i16::try_from(get(0)?).ok()?;
179        let month = get(1).and_then(|m| u8::try_from(m).ok());
180        let day = month.and(get(2)).and_then(|d| u8::try_from(d).ok());
181        PartialDate::new(year, month, day)
182            .or_else(|| PartialDate::new(year, month, None))
183            .or(Some(PartialDate::from_year(year)))
184    }
185}
186
187impl CslName {
188    fn person(&self) -> Option<Person> {
189        let mut person = Person::default();
190        match (&self.family, &self.literal) {
191            (Some(family), _) => {
192                person.family = Some(family.clone());
193                person.given = self.given.clone();
194            }
195            (None, Some(literal)) => person.literal = Some(literal.clone()),
196            (None, None) => person.literal = Some(self.given.clone()?),
197        }
198        person.orcid = self.orcid.as_deref().map(|o| {
199            o.trim_start_matches("https://orcid.org/")
200                .trim_start_matches("http://orcid.org/")
201                .to_owned()
202        });
203        Some(person)
204    }
205}
206
207/// CSL type names, plus Crossref's own vocabulary (which its "CSL" uses).
208pub fn work_type(name: &str) -> WorkType {
209    match name {
210        "journal-article" => WorkType::JournalArticle,
211        "proceedings-article" => WorkType::ConferencePaper,
212        "posted-content" => WorkType::Preprint,
213        "book-chapter" | "book-section" | "book-part" => WorkType::Chapter,
214        "monograph" | "edited-book" | "reference-book" => WorkType::Book,
215        "dissertation" => WorkType::Thesis,
216        csl => WorkType::from_csl(csl),
217    }
218}
219
220/// Strips tags from a JATS/HTML fragment and decodes the basic entities.
221pub fn strip_markup(text: &str) -> String {
222    let mut out = String::with_capacity(text.len());
223    let mut in_tag = false;
224    for c in text.chars() {
225        match c {
226            '<' => in_tag = true,
227            '>' if in_tag => {
228                in_tag = false;
229                out.push(' ');
230            }
231            _ if !in_tag => out.push(c),
232            _ => {}
233        }
234    }
235    let out = out
236        .replace("&lt;", "<")
237        .replace("&gt;", ">")
238        .replace("&quot;", "\"")
239        .replace("&#39;", "'")
240        .replace("&amp;", "&");
241    out.split_whitespace().collect::<Vec<_>>().join(" ")
242}
243
244impl CslItem {
245    pub fn to_work(&self) -> Work {
246        let first = |t: &Option<OneOrMany>| t.as_ref().and_then(|t| t.first()).map(str::to_owned);
247        Work {
248            title: first(&self.title),
249            authors: self.author.iter().filter_map(CslName::person).collect(),
250            issued: self
251                .issued
252                .as_ref()
253                .and_then(CslDate::to_partial)
254                .or_else(|| self.published.as_ref().and_then(CslDate::to_partial)),
255            work_type: self.item_type.as_deref().map(work_type),
256            container_title: first(&self.container_title),
257            publisher: self.publisher.clone(),
258            volume: self.volume.as_ref().map(Scalar::text),
259            issue: self.issue.as_ref().map(Scalar::text),
260            page: self.page.as_ref().map(Scalar::text),
261            abstract_text: self
262                .abstract_text
263                .as_deref()
264                .map(|a| tidy_abstract(&strip_markup(a)))
265                .filter(|a| !a.is_empty()),
266            url: self.url.clone(),
267            cited_by_count: self.is_referenced_by_count,
268            updates: self
269                .updated_by
270                .iter()
271                .filter_map(CrossrefUpdate::to_update)
272                .chain(relation_updates(&self.relation))
273                .collect(),
274            update_to: self
275                .update_to
276                .iter()
277                .filter_map(CrossrefUpdate::to_update_to)
278                .collect(),
279            other_years: Vec::new(),
280            open_access: None,
281        }
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    fn item(json: serde_json::Value) -> CslItem {
290        serde_json::from_value(json).unwrap()
291    }
292
293    #[test]
294    fn reads_crossref_flavoured_csl() {
295        let w = item(serde_json::json!({
296            "type": "journal-article",
297            "title": "Deep learning",
298            "container-title": "Nature",
299            "author": [{"given": "Yann", "family": "LeCun", "sequence": "first"}],
300            "issued": {"date-parts": [[2015, 5, 27]]},
301            "volume": "521", "page": "436-444",
302            "is-referenced-by-count": 79265
303        }))
304        .to_work();
305        assert_eq!(w.work_type, Some(WorkType::JournalArticle));
306        assert_eq!(w.issued.unwrap().to_string(), "2015-05-27");
307        assert_eq!(w.authors[0].display_name().as_deref(), Some("Yann LeCun"));
308        assert_eq!(w.cited_by_count, Some(79265));
309    }
310
311    #[test]
312    fn reads_retraction_watch_notices() {
313        let w = item(serde_json::json!({
314            "title": "RETRACTED: Ileal-lymphoid-nodular hyperplasia",
315            "updated-by": [
316                {"DOI": "10.1016/s0140-6736(04)15715-2", "type": "correction", "source": "retraction-watch",
317                 "updated": {"date-parts": [[2004, 3, 6]]}},
318                {"DOI": "10.1016/s0140-6736(10)60175-4", "type": "retraction", "source": "retraction-watch",
319                 "updated": {"date-parts": [[2010, 2, 6]]}}
320            ]
321        }))
322        .to_work();
323        assert_eq!(w.updates.len(), 2);
324        let r = w.retraction().unwrap();
325        assert_eq!(r.kind, UpdateKind::Retraction);
326        assert_eq!(r.date.unwrap().to_string(), "2010-02-06");
327        assert_eq!(
328            r.notice.as_ref().unwrap().to_string(),
329            "doi:10.1016/s0140-6736(10)60175-4"
330        );
331        assert_eq!(r.source.as_deref(), Some("retraction-watch"));
332    }
333
334    #[test]
335    fn newer_versions_from_notices_and_relations() {
336        // Cochrane deposits a new version as a notice on the old one.
337        let w = item(serde_json::json!({
338            "updated-by": [{"DOI": "10.1002/14651858.cd000567.pub7", "type": "new_version",
339                            "source": "publisher", "updated": {"date-parts": [[2018, 8, 3]]}}]
340        }))
341        .to_work();
342        assert_eq!(w.updates[0].kind, UpdateKind::NewVersion);
343        assert_eq!(
344            w.newer_version().unwrap().to_string(),
345            "doi:10.1002/14651858.cd000567.pub7"
346        );
347        // Preprint servers link the published article as a relation.
348        let w = item(serde_json::json!({
349            "relation": {
350                "is-preprint-of": [{"id-type": "doi", "id": "10.1038/s41586-021-03819-2", "asserted-by": "subject"}],
351                "has-review": [{"id-type": "doi", "id": "10.3410/f.1"}]
352            }
353        }))
354        .to_work();
355        assert_eq!(w.updates.len(), 1);
356        assert_eq!(
357            w.newer_version().unwrap().to_string(),
358            "doi:10.1038/s41586-021-03819-2"
359        );
360    }
361
362    #[test]
363    fn a_notice_names_the_work_it_updates() {
364        let w = item(serde_json::json!({
365            "title": "Correction to: 2026 AHA/ACC/... Guideline for ... Acute Pulmonary Embolism",
366            "update-to": [{"DOI": "10.1161/cir.0000000000001415", "type": "correction",
367                           "source": "publisher", "updated": {"date-parts": [[2026, 7, 13]]}}]
368        }))
369        .to_work();
370        assert!(w.updates.is_empty());
371        let [target] = &w.update_to[..] else { panic!() };
372        assert_eq!(target.kind, UpdateKind::Correction);
373        assert_eq!(target.work.to_string(), "doi:10.1161/cir.0000000000001415");
374        assert_eq!(target.date.unwrap().to_string(), "2026-07-13");
375        assert_eq!(target.source.as_deref(), Some("publisher"));
376    }
377
378    #[test]
379    fn tolerates_arrays_numbers_strings_and_markup() {
380        let w = item(serde_json::json!({
381            "type": "article",
382            "title": ["Attention Is All You Need", "ignored"],
383            "container-title": [],
384            "issued": {"date-parts": [["2017"]]},
385            "volume": 30,
386            "author": [{"literal": "Google Brain"}],
387            "abstract": "<jats:title>Abstract</jats:title><jats:p>The dominant &amp; best.</jats:p>"
388        }))
389        .to_work();
390        assert_eq!(w.title.as_deref(), Some("Attention Is All You Need"));
391        assert_eq!(w.container_title, None);
392        assert_eq!(w.issued.unwrap().year(), 2017);
393        assert_eq!(w.volume.as_deref(), Some("30"));
394        assert_eq!(w.work_type, Some(WorkType::Preprint));
395        assert_eq!(w.abstract_text.as_deref(), Some("The dominant & best."));
396    }
397}