1use 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#[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 pub updated_by: Vec<CrossrefUpdate>,
45 pub update_to: Vec<CrossrefUpdate>,
47 pub relation: BTreeMap<String, Vec<CrossrefRelation>>,
50}
51
52#[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
60pub 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#[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#[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#[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#[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
207pub 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
220pub 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("<", "<")
237 .replace(">", ">")
238 .replace(""", "\"")
239 .replace("'", "'")
240 .replace("&", "&");
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 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 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 & 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}