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    /// The date the source gives for the update (Crossref's `updated`,
79    /// often the issue's), when the notice appeared earlier: `date` is then
80    /// the earliest known.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub deposited: Option<PartialDate>,
83}
84
85impl Update {
86    pub fn new(kind: UpdateKind) -> Self {
87        Update {
88            kind,
89            notice: None,
90            date: None,
91            source: None,
92            deposited: None,
93        }
94    }
95}
96
97/// Whether, and where, a work can be read free and legally (as with
98/// Unpaywall, whose data OpenAlex carries).
99#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
100#[non_exhaustive]
101pub struct OpenAccess {
102    /// Free to read somewhere.
103    pub is_oa: bool,
104    /// Unpaywall's classes: `gold`, `diamond`, `hybrid`, `bronze` (free on
105    /// the publisher's site without a licence), `green` (a repository
106    /// copy), `closed`.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub status: Option<String>,
109    /// The best free copy as a PDF.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub pdf_url: Option<String>,
112    /// The best free copy's page (HTML full text or a landing page).
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub url: Option<String>,
115    /// That copy's licence, e.g. `cc-by`; none for bronze.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub license: Option<String>,
118    /// `publishedVersion`, `acceptedVersion` or `submittedVersion`.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub version: Option<String>,
121    /// Who said so: "openalex", "europepmc", ….
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub source: Option<String>,
124}
125
126/// A notice's link to the work it updates (Crossref's `update-to`): the
127/// other direction of an [`Update`].
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
129#[non_exhaustive]
130pub struct UpdateTo {
131    pub kind: UpdateKind,
132    /// The updated work.
133    pub work: crate::id::WorkId,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub date: Option<PartialDate>,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub source: Option<String>,
138}
139
140impl UpdateTo {
141    pub fn new(kind: UpdateKind, work: crate::id::WorkId) -> Self {
142        UpdateTo {
143            kind,
144            work,
145            date: None,
146            source: None,
147        }
148    }
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Hash)]
152#[non_exhaustive]
153pub enum UpdateKind {
154    Retraction,
155    Withdrawal,
156    ExpressionOfConcern,
157    Correction,
158    /// A newer version or edition supersedes the work (Cochrane's
159    /// `.pub7` after `.pub6`), or a preprint's published version; the
160    /// update's `notice` is the newer work.
161    NewVersion,
162    Other(String),
163}
164
165impl UpdateKind {
166    pub fn as_str(&self) -> &str {
167        match self {
168            UpdateKind::Retraction => "retraction",
169            UpdateKind::Withdrawal => "withdrawal",
170            UpdateKind::ExpressionOfConcern => "expression_of_concern",
171            UpdateKind::Correction => "correction",
172            UpdateKind::NewVersion => "new_version",
173            UpdateKind::Other(s) => s,
174        }
175    }
176
177    /// From Crossref/Retraction Watch type names (`retraction`,
178    /// `expression_of_concern`, `erratum`, …); never fails.
179    pub fn parse(s: &str) -> Self {
180        match s.to_ascii_lowercase().replace('-', "_").as_str() {
181            "retraction" | "partial_retraction" => UpdateKind::Retraction,
182            "withdrawal" | "removal" => UpdateKind::Withdrawal,
183            "expression_of_concern" => UpdateKind::ExpressionOfConcern,
184            "correction" | "erratum" | "corrigendum" | "addendum" => UpdateKind::Correction,
185            "new_version" | "new_edition" => UpdateKind::NewVersion,
186            _ => UpdateKind::Other(s.to_owned()),
187        }
188    }
189
190    /// Whether citing the work is unsafe: retracted or withdrawn.
191    pub fn is_retraction(&self) -> bool {
192        matches!(self, UpdateKind::Retraction | UpdateKind::Withdrawal)
193    }
194}
195
196impl Serialize for UpdateKind {
197    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
198        s.serialize_str(self.as_str())
199    }
200}
201
202impl<'de> Deserialize<'de> for UpdateKind {
203    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
204        Ok(UpdateKind::parse(&String::deserialize(d)?))
205    }
206}
207
208impl Work {
209    /// The first retraction or withdrawal notice, if any.
210    pub fn retraction(&self) -> Option<&Update> {
211        self.updates.iter().find(|u| u.kind.is_retraction())
212    }
213
214    /// Types a work that is itself a notice (it has `update_to`) as one:
215    /// `retraction` for retractions and withdrawals, `erratum` for other
216    /// notices, as OpenAlex names them. Registries type notices as plain
217    /// articles. New versions are works in their own right and keep theirs.
218    pub fn type_as_notice(&mut self) {
219        let kinds = || self.update_to.iter().map(|t| &t.kind);
220        let name = if kinds().any(UpdateKind::is_retraction) {
221            "retraction"
222        } else if kinds().any(|k| *k != UpdateKind::NewVersion) {
223            "erratum"
224        } else {
225            return;
226        };
227        self.work_type = Some(WorkType::Other(name.into()));
228    }
229
230    /// The newer version superseding this work, if one is known.
231    pub fn newer_version(&self) -> Option<&crate::id::WorkId> {
232        self.updates
233            .iter()
234            .filter(|u| u.kind == UpdateKind::NewVersion)
235            .find_map(|u| u.notice.as_ref())
236    }
237}
238
239#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
240#[non_exhaustive]
241pub struct Person {
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub given: Option<String>,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub family: Option<String>,
246    /// A name that was not split into given/family, or an organisation.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub literal: Option<String>,
249    /// Bare ORCID iD, e.g. `0000-0002-1825-0097`.
250    #[serde(default, skip_serializing_if = "Option::is_none")]
251    pub orcid: Option<String>,
252}
253
254impl Person {
255    pub fn new(given: impl Into<String>, family: impl Into<String>) -> Self {
256        Person {
257            given: Some(given.into()),
258            family: Some(family.into()),
259            ..Person::default()
260        }
261    }
262
263    pub fn literal(name: impl Into<String>) -> Self {
264        Person {
265            literal: Some(name.into()),
266            ..Person::default()
267        }
268    }
269
270    /// The best single-string name: "Given Family", or the literal.
271    pub fn display_name(&self) -> Option<String> {
272        match (&self.given, &self.family, &self.literal) {
273            (Some(g), Some(f), _) => Some(format!("{g} {f}")),
274            (None, Some(f), _) => Some(f.clone()),
275            (_, _, Some(l)) => Some(l.clone()),
276            (Some(g), None, None) => Some(g.clone()),
277            (None, None, None) => None,
278        }
279    }
280}
281
282/// Removes an "Abstract" heading that some sources leave at the start of
283/// abstract text (JATS `<title>Abstract</title>`, kept as a word by
284/// OpenAlex). Only a standalone heading: "Abstraction is…" is untouched.
285pub fn tidy_abstract(text: &str) -> String {
286    let text = text.trim();
287    for heading in ["Abstract", "ABSTRACT"] {
288        if let Some(rest) = text.strip_prefix(heading)
289            && rest.starts_with(|c: char| c.is_whitespace() || matches!(c, ':' | '.' | '-' | '—'))
290        {
291            return rest
292                .trim_start_matches(|c: char| {
293                    c.is_whitespace() || matches!(c, ':' | '.' | '-' | '—')
294                })
295                .to_owned();
296        }
297    }
298    text.to_owned()
299}
300
301/// A date known to year, month or day precision. Serialises as `2017`,
302/// `2017-06` or `2017-06-12`.
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
304pub struct PartialDate {
305    year: i16,
306    month: Option<u8>,
307    day: Option<u8>,
308}
309
310#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
311#[error("invalid date {0:?}: expected YYYY, YYYY-MM or YYYY-MM-DD")]
312pub struct DateError(String);
313
314impl PartialDate {
315    pub fn from_year(year: i16) -> Self {
316        PartialDate {
317            year,
318            month: None,
319            day: None,
320        }
321    }
322
323    /// `None` if the month or day is out of range for the calendar.
324    pub fn new(year: i16, month: Option<u8>, day: Option<u8>) -> Option<Self> {
325        match (month, day) {
326            (None, Some(_)) => return None,
327            (Some(m), None) if !(1..=12).contains(&m) => return None,
328            (Some(m), Some(d)) => {
329                jiff::civil::Date::new(year, m as i8, d as i8).ok()?;
330            }
331            _ => {}
332        }
333        Some(PartialDate { year, month, day })
334    }
335
336    pub fn year(&self) -> i16 {
337        self.year
338    }
339
340    pub fn month(&self) -> Option<u8> {
341        self.month
342    }
343
344    pub fn day(&self) -> Option<u8> {
345        self.day
346    }
347}
348
349impl FromStr for PartialDate {
350    type Err = DateError;
351
352    fn from_str(s: &str) -> Result<Self, DateError> {
353        let err = || DateError(s.to_owned());
354        let mut parts = s.trim().splitn(3, '-');
355        let mut num = |len: usize| -> Result<Option<u16>, DateError> {
356            match parts.next() {
357                None => Ok(None),
358                Some(p) if p.len() == len && p.bytes().all(|b| b.is_ascii_digit()) => {
359                    Ok(Some(p.parse().map_err(|_| err())?))
360                }
361                Some(_) => Err(err()),
362            }
363        };
364        let year = num(4)?.ok_or_else(err)?;
365        let month = num(2)?;
366        let day = num(2)?;
367        PartialDate::new(year as i16, month.map(|m| m as u8), day.map(|d| d as u8)).ok_or_else(err)
368    }
369}
370
371impl fmt::Display for PartialDate {
372    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
373        write!(f, "{:04}", self.year)?;
374        if let Some(m) = self.month {
375            write!(f, "-{m:02}")?;
376        }
377        if let Some(d) = self.day {
378            write!(f, "-{d:02}")?;
379        }
380        Ok(())
381    }
382}
383
384/// Kind of work, as CSL types. Unknown upstream types are kept verbatim in
385/// `Other` rather than dropped.
386#[derive(Debug, Clone, PartialEq, Eq, Hash)]
387#[non_exhaustive]
388pub enum WorkType {
389    JournalArticle,
390    ConferencePaper,
391    /// CSL `article`: preprints and other unreviewed articles.
392    Preprint,
393    Book,
394    Chapter,
395    Thesis,
396    Report,
397    Dataset,
398    Software,
399    Review,
400    Other(String),
401}
402
403impl WorkType {
404    /// The CSL type name.
405    pub fn as_csl(&self) -> &str {
406        match self {
407            WorkType::JournalArticle => "article-journal",
408            WorkType::ConferencePaper => "paper-conference",
409            WorkType::Preprint => "article",
410            WorkType::Book => "book",
411            WorkType::Chapter => "chapter",
412            WorkType::Thesis => "thesis",
413            WorkType::Report => "report",
414            WorkType::Dataset => "dataset",
415            WorkType::Software => "software",
416            WorkType::Review => "review",
417            WorkType::Other(s) => s,
418        }
419    }
420
421    /// Inverse of [`WorkType::as_csl`]; never fails.
422    pub fn from_csl(s: &str) -> Self {
423        match s {
424            "article-journal" => WorkType::JournalArticle,
425            "paper-conference" => WorkType::ConferencePaper,
426            "article" => WorkType::Preprint,
427            "book" => WorkType::Book,
428            "chapter" => WorkType::Chapter,
429            "thesis" => WorkType::Thesis,
430            "report" => WorkType::Report,
431            "dataset" => WorkType::Dataset,
432            "software" => WorkType::Software,
433            "review" => WorkType::Review,
434            other => WorkType::Other(other.to_owned()),
435        }
436    }
437}
438
439impl Serialize for WorkType {
440    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
441        s.serialize_str(self.as_csl())
442    }
443}
444
445impl<'de> Deserialize<'de> for WorkType {
446    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
447        Ok(WorkType::from_csl(&String::deserialize(d)?))
448    }
449}
450
451impl Serialize for PartialDate {
452    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
453        s.collect_str(self)
454    }
455}
456
457impl<'de> Deserialize<'de> for PartialDate {
458    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
459        String::deserialize(d)?
460            .parse()
461            .map_err(serde::de::Error::custom)
462    }
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn partial_dates_parse_validate_and_round_trip() {
471        for s in ["2017", "2017-06", "2017-06-12", "2024-02-29"] {
472            assert_eq!(s.parse::<PartialDate>().unwrap().to_string(), s);
473        }
474        for s in [
475            "17",
476            "2017-6",
477            "2017-13",
478            "2017-06-31",
479            "2023-02-29",
480            "2017--01",
481            "x",
482        ] {
483            assert!(s.parse::<PartialDate>().is_err(), "{s:?}");
484        }
485        assert_eq!(PartialDate::new(2017, None, Some(3)), None);
486    }
487
488    #[test]
489    fn work_type_keeps_unknown_values() {
490        assert_eq!(
491            WorkType::from_csl("article-journal"),
492            WorkType::JournalArticle
493        );
494        let other = WorkType::from_csl("musical-score");
495        assert_eq!(other.as_csl(), "musical-score");
496    }
497
498    #[test]
499    fn work_serialises_sparsely_with_csl_names() {
500        let w = Work {
501            title: Some("Attention Is All You Need".into()),
502            issued: Some(PartialDate::from_year(2017)),
503            work_type: Some(WorkType::ConferencePaper),
504            authors: vec![Person::new("Ashish", "Vaswani")],
505            ..Work::default()
506        };
507        let json = serde_json::to_value(&w).unwrap();
508        assert_eq!(
509            json,
510            serde_json::json!({
511                "title": "Attention Is All You Need",
512                "authors": [{"given": "Ashish", "family": "Vaswani"}],
513                "issued": "2017",
514                "type": "paper-conference",
515            })
516        );
517        assert_eq!(serde_json::from_value::<Work>(json).unwrap(), w);
518    }
519
520    #[test]
521    fn tidy_abstract_drops_only_a_standalone_heading() {
522        assert_eq!(
523            tidy_abstract("Abstract Proteins are essential"),
524            "Proteins are essential"
525        );
526        assert_eq!(tidy_abstract("ABSTRACT: We show"), "We show");
527        assert_eq!(
528            tidy_abstract("  Abstraction is useful"),
529            "Abstraction is useful"
530        );
531        assert_eq!(tidy_abstract("We show"), "We show");
532    }
533
534    #[test]
535    fn person_display_name_prefers_split_names() {
536        assert_eq!(
537            Person::new("Ada", "Lovelace").display_name().unwrap(),
538            "Ada Lovelace"
539        );
540        assert_eq!(Person::literal("CERN").display_name().unwrap(), "CERN");
541        assert_eq!(Person::default().display_name(), None);
542    }
543}