Skip to main content

aspero_core/
work.rs

1//! The minimal, provider-neutral description of a scholarly work.
2//!
3//! Shaped after CSL-JSON so export and citation formatting stay cheap, but it
4//! is not CSL-JSON itself: identifiers live on [`SourceRecord`](crate::SourceRecord),
5//! and anything source-specific stays in the provider's raw response.
6//!
7//! Every field is optional, because no provider fills everything. The structs
8//! are `#[non_exhaustive]`: build them from `Default` and assign fields, so
9//! adding a field later is not a breaking change.
10
11use std::fmt;
12use std::str::FromStr;
13
14use serde::{Deserialize, Serialize};
15
16#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
17#[non_exhaustive]
18pub struct Work {
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub title: Option<String>,
21    /// In byline order. Empty means "not provided", not "anonymous".
22    #[serde(default, skip_serializing_if = "Vec::is_empty")]
23    pub authors: Vec<Person>,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub issued: Option<PartialDate>,
26    #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
27    pub work_type: Option<WorkType>,
28    /// Journal, proceedings or book title.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub container_title: Option<String>,
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub volume: Option<String>,
33    #[serde(default, skip_serializing_if = "Option::is_none")]
34    pub issue: Option<String>,
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub page: Option<String>,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub publisher: Option<String>,
39    #[serde(default, rename = "abstract", skip_serializing_if = "Option::is_none")]
40    pub abstract_text: Option<String>,
41    /// Landing page for the work.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub url: Option<String>,
44    /// As counted by the providing source; counts differ between sources.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub cited_by_count: Option<u64>,
47    /// Retractions, corrections and other editorial notices about the work.
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub updates: Vec<Update>,
50    /// When the work is itself a notice, the works it updates: what an
51    /// erratum corrects or a retraction notice retracts.
52    #[serde(default, skip_serializing_if = "Vec::is_empty")]
53    pub update_to: Vec<UpdateTo>,
54    /// Whether, and where, the work can be read free and legally.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub open_access: Option<OpenAccess>,
57    /// Years of other editions or versions of the same work, when the
58    /// provider knows them (a book's later editions). A citation of any of
59    /// them is a citation of this work.
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    pub other_years: Vec<i16>,
62}
63
64/// An editorial notice about a work: retraction, correction, expression of
65/// concern, withdrawal.
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[non_exhaustive]
68pub struct Update {
69    pub kind: UpdateKind,
70    /// The notice's own identifier (usually a DOI), if known.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub notice: Option<crate::id::WorkId>,
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub date: Option<PartialDate>,
75    /// Who reported it: "retraction-watch", "publisher", "openalex", ….
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub source: Option<String>,
78}
79
80impl Update {
81    pub fn new(kind: UpdateKind) -> Self {
82        Update {
83            kind,
84            notice: None,
85            date: None,
86            source: None,
87        }
88    }
89}
90
91/// Whether, and where, a work can be read free and legally (as with
92/// Unpaywall, whose data OpenAlex carries).
93#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
94#[non_exhaustive]
95pub struct OpenAccess {
96    /// Free to read somewhere.
97    pub is_oa: bool,
98    /// Unpaywall's classes: `gold`, `diamond`, `hybrid`, `bronze` (free on
99    /// the publisher's site without a licence), `green` (a repository
100    /// copy), `closed`.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub status: Option<String>,
103    /// The best free copy as a PDF.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub pdf_url: Option<String>,
106    /// The best free copy's page (HTML full text or a landing page).
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub url: Option<String>,
109    /// That copy's licence, e.g. `cc-by`; none for bronze.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub license: Option<String>,
112    /// `publishedVersion`, `acceptedVersion` or `submittedVersion`.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub version: Option<String>,
115    /// Who said so: "openalex", "europepmc", ….
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub source: Option<String>,
118}
119
120/// A notice's link to the work it updates (Crossref's `update-to`): the
121/// other direction of an [`Update`].
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123#[non_exhaustive]
124pub struct UpdateTo {
125    pub kind: UpdateKind,
126    /// The updated work.
127    pub work: crate::id::WorkId,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub date: Option<PartialDate>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub source: Option<String>,
132}
133
134impl UpdateTo {
135    pub fn new(kind: UpdateKind, work: crate::id::WorkId) -> Self {
136        UpdateTo {
137            kind,
138            work,
139            date: None,
140            source: None,
141        }
142    }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Hash)]
146#[non_exhaustive]
147pub enum UpdateKind {
148    Retraction,
149    Withdrawal,
150    ExpressionOfConcern,
151    Correction,
152    Other(String),
153}
154
155impl UpdateKind {
156    pub fn as_str(&self) -> &str {
157        match self {
158            UpdateKind::Retraction => "retraction",
159            UpdateKind::Withdrawal => "withdrawal",
160            UpdateKind::ExpressionOfConcern => "expression_of_concern",
161            UpdateKind::Correction => "correction",
162            UpdateKind::Other(s) => s,
163        }
164    }
165
166    /// From Crossref/Retraction Watch type names (`retraction`,
167    /// `expression_of_concern`, `erratum`, …); never fails.
168    pub fn parse(s: &str) -> Self {
169        match s.to_ascii_lowercase().replace('-', "_").as_str() {
170            "retraction" | "partial_retraction" => UpdateKind::Retraction,
171            "withdrawal" | "removal" => UpdateKind::Withdrawal,
172            "expression_of_concern" => UpdateKind::ExpressionOfConcern,
173            "correction" | "erratum" | "corrigendum" | "addendum" => UpdateKind::Correction,
174            _ => UpdateKind::Other(s.to_owned()),
175        }
176    }
177
178    /// Whether citing the work is unsafe: retracted or withdrawn.
179    pub fn is_retraction(&self) -> bool {
180        matches!(self, UpdateKind::Retraction | UpdateKind::Withdrawal)
181    }
182}
183
184impl Serialize for UpdateKind {
185    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
186        s.serialize_str(self.as_str())
187    }
188}
189
190impl<'de> Deserialize<'de> for UpdateKind {
191    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
192        Ok(UpdateKind::parse(&String::deserialize(d)?))
193    }
194}
195
196impl Work {
197    /// The first retraction or withdrawal notice, if any.
198    pub fn retraction(&self) -> Option<&Update> {
199        self.updates.iter().find(|u| u.kind.is_retraction())
200    }
201}
202
203#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
204#[non_exhaustive]
205pub struct Person {
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub given: Option<String>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub family: Option<String>,
210    /// A name that was not split into given/family, or an organisation.
211    #[serde(default, skip_serializing_if = "Option::is_none")]
212    pub literal: Option<String>,
213    /// Bare ORCID iD, e.g. `0000-0002-1825-0097`.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub orcid: Option<String>,
216}
217
218impl Person {
219    pub fn new(given: impl Into<String>, family: impl Into<String>) -> Self {
220        Person {
221            given: Some(given.into()),
222            family: Some(family.into()),
223            ..Person::default()
224        }
225    }
226
227    pub fn literal(name: impl Into<String>) -> Self {
228        Person {
229            literal: Some(name.into()),
230            ..Person::default()
231        }
232    }
233
234    /// The best single-string name: "Given Family", or the literal.
235    pub fn display_name(&self) -> Option<String> {
236        match (&self.given, &self.family, &self.literal) {
237            (Some(g), Some(f), _) => Some(format!("{g} {f}")),
238            (None, Some(f), _) => Some(f.clone()),
239            (_, _, Some(l)) => Some(l.clone()),
240            (Some(g), None, None) => Some(g.clone()),
241            (None, None, None) => None,
242        }
243    }
244}
245
246/// Removes an "Abstract" heading that some sources leave at the start of
247/// abstract text (JATS `<title>Abstract</title>`, kept as a word by
248/// OpenAlex). Only a standalone heading: "Abstraction is…" is untouched.
249pub fn tidy_abstract(text: &str) -> String {
250    let text = text.trim();
251    for heading in ["Abstract", "ABSTRACT"] {
252        if let Some(rest) = text.strip_prefix(heading)
253            && rest.starts_with(|c: char| c.is_whitespace() || matches!(c, ':' | '.' | '-' | '—'))
254        {
255            return rest
256                .trim_start_matches(|c: char| {
257                    c.is_whitespace() || matches!(c, ':' | '.' | '-' | '—')
258                })
259                .to_owned();
260        }
261    }
262    text.to_owned()
263}
264
265/// A date known to year, month or day precision. Serialises as `2017`,
266/// `2017-06` or `2017-06-12`.
267#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
268pub struct PartialDate {
269    year: i16,
270    month: Option<u8>,
271    day: Option<u8>,
272}
273
274#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
275#[error("invalid date {0:?}: expected YYYY, YYYY-MM or YYYY-MM-DD")]
276pub struct DateError(String);
277
278impl PartialDate {
279    pub fn from_year(year: i16) -> Self {
280        PartialDate {
281            year,
282            month: None,
283            day: None,
284        }
285    }
286
287    /// `None` if the month or day is out of range for the calendar.
288    pub fn new(year: i16, month: Option<u8>, day: Option<u8>) -> Option<Self> {
289        match (month, day) {
290            (None, Some(_)) => return None,
291            (Some(m), None) if !(1..=12).contains(&m) => return None,
292            (Some(m), Some(d)) => {
293                jiff::civil::Date::new(year, m as i8, d as i8).ok()?;
294            }
295            _ => {}
296        }
297        Some(PartialDate { year, month, day })
298    }
299
300    pub fn year(&self) -> i16 {
301        self.year
302    }
303
304    pub fn month(&self) -> Option<u8> {
305        self.month
306    }
307
308    pub fn day(&self) -> Option<u8> {
309        self.day
310    }
311}
312
313impl FromStr for PartialDate {
314    type Err = DateError;
315
316    fn from_str(s: &str) -> Result<Self, DateError> {
317        let err = || DateError(s.to_owned());
318        let mut parts = s.trim().splitn(3, '-');
319        let mut num = |len: usize| -> Result<Option<u16>, DateError> {
320            match parts.next() {
321                None => Ok(None),
322                Some(p) if p.len() == len && p.bytes().all(|b| b.is_ascii_digit()) => {
323                    Ok(Some(p.parse().map_err(|_| err())?))
324                }
325                Some(_) => Err(err()),
326            }
327        };
328        let year = num(4)?.ok_or_else(err)?;
329        let month = num(2)?;
330        let day = num(2)?;
331        PartialDate::new(year as i16, month.map(|m| m as u8), day.map(|d| d as u8)).ok_or_else(err)
332    }
333}
334
335impl fmt::Display for PartialDate {
336    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
337        write!(f, "{:04}", self.year)?;
338        if let Some(m) = self.month {
339            write!(f, "-{m:02}")?;
340        }
341        if let Some(d) = self.day {
342            write!(f, "-{d:02}")?;
343        }
344        Ok(())
345    }
346}
347
348/// Kind of work, as CSL types. Unknown upstream types are kept verbatim in
349/// `Other` rather than dropped.
350#[derive(Debug, Clone, PartialEq, Eq, Hash)]
351#[non_exhaustive]
352pub enum WorkType {
353    JournalArticle,
354    ConferencePaper,
355    /// CSL `article`: preprints and other unreviewed articles.
356    Preprint,
357    Book,
358    Chapter,
359    Thesis,
360    Report,
361    Dataset,
362    Software,
363    Review,
364    Other(String),
365}
366
367impl WorkType {
368    /// The CSL type name.
369    pub fn as_csl(&self) -> &str {
370        match self {
371            WorkType::JournalArticle => "article-journal",
372            WorkType::ConferencePaper => "paper-conference",
373            WorkType::Preprint => "article",
374            WorkType::Book => "book",
375            WorkType::Chapter => "chapter",
376            WorkType::Thesis => "thesis",
377            WorkType::Report => "report",
378            WorkType::Dataset => "dataset",
379            WorkType::Software => "software",
380            WorkType::Review => "review",
381            WorkType::Other(s) => s,
382        }
383    }
384
385    /// Inverse of [`WorkType::as_csl`]; never fails.
386    pub fn from_csl(s: &str) -> Self {
387        match s {
388            "article-journal" => WorkType::JournalArticle,
389            "paper-conference" => WorkType::ConferencePaper,
390            "article" => WorkType::Preprint,
391            "book" => WorkType::Book,
392            "chapter" => WorkType::Chapter,
393            "thesis" => WorkType::Thesis,
394            "report" => WorkType::Report,
395            "dataset" => WorkType::Dataset,
396            "software" => WorkType::Software,
397            "review" => WorkType::Review,
398            other => WorkType::Other(other.to_owned()),
399        }
400    }
401}
402
403impl Serialize for WorkType {
404    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
405        s.serialize_str(self.as_csl())
406    }
407}
408
409impl<'de> Deserialize<'de> for WorkType {
410    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
411        Ok(WorkType::from_csl(&String::deserialize(d)?))
412    }
413}
414
415impl Serialize for PartialDate {
416    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
417        s.collect_str(self)
418    }
419}
420
421impl<'de> Deserialize<'de> for PartialDate {
422    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
423        String::deserialize(d)?
424            .parse()
425            .map_err(serde::de::Error::custom)
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn partial_dates_parse_validate_and_round_trip() {
435        for s in ["2017", "2017-06", "2017-06-12", "2024-02-29"] {
436            assert_eq!(s.parse::<PartialDate>().unwrap().to_string(), s);
437        }
438        for s in [
439            "17",
440            "2017-6",
441            "2017-13",
442            "2017-06-31",
443            "2023-02-29",
444            "2017--01",
445            "x",
446        ] {
447            assert!(s.parse::<PartialDate>().is_err(), "{s:?}");
448        }
449        assert_eq!(PartialDate::new(2017, None, Some(3)), None);
450    }
451
452    #[test]
453    fn work_type_keeps_unknown_values() {
454        assert_eq!(
455            WorkType::from_csl("article-journal"),
456            WorkType::JournalArticle
457        );
458        let other = WorkType::from_csl("musical-score");
459        assert_eq!(other.as_csl(), "musical-score");
460    }
461
462    #[test]
463    fn work_serialises_sparsely_with_csl_names() {
464        let w = Work {
465            title: Some("Attention Is All You Need".into()),
466            issued: Some(PartialDate::from_year(2017)),
467            work_type: Some(WorkType::ConferencePaper),
468            authors: vec![Person::new("Ashish", "Vaswani")],
469            ..Work::default()
470        };
471        let json = serde_json::to_value(&w).unwrap();
472        assert_eq!(
473            json,
474            serde_json::json!({
475                "title": "Attention Is All You Need",
476                "authors": [{"given": "Ashish", "family": "Vaswani"}],
477                "issued": "2017",
478                "type": "paper-conference",
479            })
480        );
481        assert_eq!(serde_json::from_value::<Work>(json).unwrap(), w);
482    }
483
484    #[test]
485    fn tidy_abstract_drops_only_a_standalone_heading() {
486        assert_eq!(
487            tidy_abstract("Abstract Proteins are essential"),
488            "Proteins are essential"
489        );
490        assert_eq!(tidy_abstract("ABSTRACT: We show"), "We show");
491        assert_eq!(
492            tidy_abstract("  Abstraction is useful"),
493            "Abstraction is useful"
494        );
495        assert_eq!(tidy_abstract("We show"), "We show");
496    }
497
498    #[test]
499    fn person_display_name_prefers_split_names() {
500        assert_eq!(
501            Person::new("Ada", "Lovelace").display_name().unwrap(),
502            "Ada Lovelace"
503        );
504        assert_eq!(Person::literal("CERN").display_name().unwrap(), "CERN");
505        assert_eq!(Person::default().display_name(), None);
506    }
507}