Skip to main content

aspero_json/
work.rs

1//! Works, candidates and usage.
2
3use aspero_core::{Person, ProviderId, SourceRecord, Usage};
4use aspero_engine::{Abstract, Candidate};
5use serde_json::{Map, Value, json};
6
7/// One work: lean by default, every known field with `full`.
8pub fn work(record: &SourceRecord, full: bool) -> Value {
9    let w = &record.work;
10    let mut out = Map::new();
11    out.insert("source".into(), json!(record.source));
12    out.insert("ids".into(), json!(record.ids));
13    // One key per work, whichever identifier found it: for deduping.
14    if let Some(id) = aspero_core::canonical(&record.ids) {
15        out.insert("canonical".into(), json!(id));
16    }
17    let mut put = |key: &str, value: Option<Value>| {
18        if let Some(v) = value {
19            out.insert(key.into(), v);
20        }
21    };
22    put("title", w.title.as_ref().map(|t| json!(t)));
23    if !w.authors.is_empty() {
24        let authors: Vec<Value> = w.authors.iter().filter_map(|a| author(a, full)).collect();
25        put("authors", Some(json!(authors)));
26    }
27    put("issued", w.issued.map(|d| json!(d.to_string())));
28    put("type", w.work_type.as_ref().map(|t| json!(t.as_csl())));
29    put("container", w.container_title.as_ref().map(|c| json!(c)));
30    put("url", w.url.as_ref().map(|u| json!(u)));
31    // Never lean enough to leave out: a retracted work is not citable.
32    put("updates", (!w.updates.is_empty()).then(|| json!(w.updates)));
33    put(
34        "update_to",
35        (!w.update_to.is_empty()).then(|| json!(w.update_to)),
36    );
37    // Whether a free copy can be fetched instead of asking a human.
38    put("open_access", w.open_access.as_ref().map(|oa| json!(oa)));
39    if full {
40        // Everything else, in the core `Work` shape (structured authors).
41        if let Ok(Value::Object(all)) = serde_json::to_value(w) {
42            for (k, v) in all {
43                out.entry(k).or_insert(v);
44            }
45        }
46        out.insert("fetched_at".into(), json!(record.fetched_at.to_string()));
47    }
48    Value::Object(out)
49}
50
51/// One author, the same shape lean or full: always a `name`, then the ORCID
52/// iD, and with `full` the name's parts.
53fn author(person: &Person, full: bool) -> Option<Value> {
54    let mut out = Map::new();
55    out.insert("name".into(), json!(person.display_name()?));
56    if full && let Ok(Value::Object(parts)) = serde_json::to_value(person) {
57        out.extend(parts);
58    } else if let Some(orcid) = &person.orcid {
59        out.insert("orcid".into(), json!(orcid));
60    }
61    Some(Value::Object(out))
62}
63
64pub fn usage(usage: &[(ProviderId, Usage)]) -> Value {
65    let map: Map<String, Value> = usage
66        .iter()
67        .map(|(p, u)| (p.to_string(), usage_one(u)))
68        .collect();
69    Value::Object(map)
70}
71
72pub fn usage_one(u: &Usage) -> Value {
73    let mut out = Map::new();
74    if let Some(c) = u.cost {
75        out.insert("cost".into(), json!(c));
76    }
77    if let Some(r) = u.remaining {
78        out.insert("remaining".into(), json!(r));
79    }
80    if let Some(t) = u.resets_at {
81        out.insert("resets_at".into(), json!(t.to_string()));
82    }
83    Value::Object(out)
84}
85
86/// Sets a work's `abstract` (null when none was found) and
87/// `abstract_source`.
88pub fn set_abstract(work: &mut Value, found: Option<&Abstract>) {
89    match found {
90        Some(a) => {
91            work["abstract"] = json!(a.text);
92            work["abstract_source"] = json!(a.source);
93        }
94        None => work["abstract"] = Value::Null,
95    }
96}
97
98/// A scored candidate: confidence, per-field checks, who found it, and the
99/// work (with every identifier merged records asserted).
100pub fn candidate(c: &Candidate, full: bool) -> Value {
101    let a = &c.assessment;
102    let checks = if full {
103        json!({
104            "title": a.title,
105            "authors": a.authors,
106            "year": a.year,
107            "container": a.container,
108            "volume": a.volume,
109            "page": a.page,
110            "ids": a.ids,
111        })
112    } else {
113        json!({
114            "title": a.title.agreement,
115            "authors": a.authors.agreement,
116            "year": a.year.agreement,
117            "container": a.container.agreement,
118            "volume": a.volume.agreement,
119            "page": a.page.agreement,
120            "ids": a.ids,
121        })
122    };
123    let found_by: Vec<Value> = c
124        .sightings
125        .iter()
126        .map(|s| json!({"provider": s.provider, "via": s.via.as_str(), "rank": s.rank, "score": s.score}))
127        .collect();
128    let mut work = work(&c.record, full);
129    work["ids"] = json!(c.ids);
130    work["canonical"] = json!(aspero_core::canonical(&c.ids));
131    if !c.updates.is_empty() {
132        work["updates"] = json!(c.updates);
133    }
134    json!({
135        "confidence": (a.confidence * 100.0).round() / 100.0,
136        "checks": checks,
137        "found_by": found_by,
138        "work": work,
139    })
140}
141
142#[cfg(test)]
143mod tests {
144    use aspero_core::{FieldSet, PartialDate, Raw, Work, WorkType};
145    use jiff::Timestamp;
146
147    use super::*;
148
149    fn record() -> SourceRecord {
150        let mut work = Work::default();
151        work.title = Some("Deep learning".into());
152        work.authors = [
153            "Yann LeCun",
154            "Yoshua Bengio",
155            "Geoffrey Hinton",
156            "A. N. Other",
157        ]
158        .map(Person::literal)
159        .to_vec();
160        work.issued = PartialDate::new(2015, Some(5), Some(27));
161        work.work_type = Some(WorkType::JournalArticle);
162        work.container_title = Some("Nature".into());
163        work.abstract_text = Some("Deep learning allows…".into());
164        let raw = Raw {
165            media_type: "application/json".into(),
166            body: "{}".into(),
167        };
168        SourceRecord::new(
169            ProviderId::from_static("crossref"),
170            vec!["10.1038/nature14539".parse().unwrap()],
171            work,
172            FieldSet::ALL,
173            raw,
174            Timestamp::UNIX_EPOCH,
175        )
176    }
177
178    #[test]
179    fn lean_json_identifies_and_cites_without_extras() {
180        assert_eq!(
181            work(&record(), false),
182            json!({
183                "source": "crossref",
184                "ids": ["doi:10.1038/nature14539"],
185                "canonical": "doi:10.1038/nature14539",
186                "title": "Deep learning",
187                "authors": [
188                    {"name": "Yann LeCun"},
189                    {"name": "Yoshua Bengio"},
190                    {"name": "Geoffrey Hinton"},
191                    {"name": "A. N. Other"},
192                ],
193                "issued": "2015-05-27",
194                "type": "article-journal",
195                "container": "Nature",
196            })
197        );
198    }
199
200    #[test]
201    fn full_json_adds_everything() {
202        let full = work(&record(), true);
203        assert_eq!(full["abstract"], "Deep learning allows…");
204        assert_eq!(full["authors"][0]["literal"], "Yann LeCun");
205        assert!(full.get("fetched_at").is_some());
206    }
207
208    #[test]
209    fn authors_have_one_shape_lean_or_full() {
210        let mut r = record();
211        let mut lecun = Person::new("Yann", "LeCun");
212        lecun.orcid = Some("0000-0000-0000-0001".into());
213        r.work.authors = vec![lecun];
214        assert_eq!(
215            work(&r, false)["authors"],
216            json!([{"name": "Yann LeCun", "orcid": "0000-0000-0000-0001"}])
217        );
218        assert_eq!(
219            work(&r, true)["authors"],
220            json!([{
221                "name": "Yann LeCun",
222                "given": "Yann",
223                "family": "LeCun",
224                "orcid": "0000-0000-0000-0001",
225            }])
226        );
227    }
228}