1use serde::Deserialize;
11
12use crate::id::{Doi, WorkId};
13use crate::work::{
14 PartialDate, Person, Update, UpdateKind, UpdateTo, Work, WorkType, tidy_abstract,
15};
16
17#[derive(Debug, Clone, Default, Deserialize)]
19#[serde(default, rename_all = "kebab-case")]
20#[non_exhaustive]
21pub struct CslItem {
22 #[serde(rename = "type")]
23 pub item_type: Option<String>,
24 pub title: Option<OneOrMany>,
25 pub container_title: Option<OneOrMany>,
26 pub author: Vec<CslName>,
27 pub issued: Option<CslDate>,
28 pub published: Option<CslDate>,
29 pub publisher: Option<String>,
30 pub volume: Option<Scalar>,
31 pub issue: Option<Scalar>,
32 pub page: Option<Scalar>,
33 #[serde(rename = "DOI")]
34 pub doi: Option<String>,
35 #[serde(rename = "URL")]
36 pub url: Option<String>,
37 #[serde(rename = "abstract")]
38 pub abstract_text: Option<String>,
39 pub is_referenced_by_count: Option<u64>,
40 pub updated_by: Vec<CrossrefUpdate>,
43 pub update_to: Vec<CrossrefUpdate>,
45}
46
47#[derive(Debug, Clone, Default, Deserialize)]
53#[serde(default)]
54pub struct CrossrefUpdate {
55 #[serde(rename = "DOI")]
56 pub doi: Option<String>,
57 #[serde(rename = "type")]
58 pub update_type: Option<String>,
59 pub source: Option<String>,
60 pub updated: Option<CslDate>,
61}
62
63impl CrossrefUpdate {
64 pub fn to_update(&self) -> Option<Update> {
65 let mut update = Update::new(UpdateKind::parse(self.update_type.as_deref()?));
66 update.notice = self
67 .doi
68 .as_deref()
69 .and_then(|d| d.parse::<Doi>().ok())
70 .map(WorkId::Doi);
71 update.date = self.updated.as_ref().and_then(CslDate::to_partial);
72 update.source = self.source.clone();
73 Some(update)
74 }
75
76 pub fn to_update_to(&self) -> Option<UpdateTo> {
77 let kind = UpdateKind::parse(self.update_type.as_deref()?);
78 let work = WorkId::Doi(self.doi.as_deref()?.parse::<Doi>().ok()?);
79 let mut update = UpdateTo::new(kind, work);
80 update.date = self.updated.as_ref().and_then(CslDate::to_partial);
81 update.source = self.source.clone();
82 Some(update)
83 }
84}
85
86#[derive(Debug, Clone, Deserialize)]
88#[serde(untagged)]
89pub enum OneOrMany {
90 One(String),
91 Many(Vec<String>),
92}
93
94impl OneOrMany {
95 pub fn first(&self) -> Option<&str> {
96 match self {
97 OneOrMany::One(s) => Some(s),
98 OneOrMany::Many(v) => v.first().map(String::as_str),
99 }
100 }
101}
102
103#[derive(Debug, Clone, Deserialize)]
105#[serde(untagged)]
106pub enum Scalar {
107 Text(String),
108 Number(i64),
109}
110
111impl Scalar {
112 fn text(&self) -> String {
113 match self {
114 Scalar::Text(s) => s.clone(),
115 Scalar::Number(n) => n.to_string(),
116 }
117 }
118}
119
120#[derive(Debug, Clone, Default, Deserialize)]
121#[serde(default)]
122pub struct CslName {
123 pub family: Option<String>,
124 pub given: Option<String>,
125 pub literal: Option<String>,
126 #[serde(rename = "ORCID")]
127 pub orcid: Option<String>,
128}
129
130#[derive(Debug, Clone, Default, Deserialize)]
132#[serde(default, rename_all = "kebab-case")]
133pub struct CslDate {
134 pub date_parts: Vec<Vec<Option<serde_json::Value>>>,
135}
136
137impl CslDate {
138 pub fn to_partial(&self) -> Option<PartialDate> {
139 let parts = self.date_parts.first()?;
140 let get = |i: usize| -> Option<i64> {
141 match parts.get(i)?.as_ref()? {
142 serde_json::Value::Number(n) => n.as_i64(),
143 serde_json::Value::String(s) => s.trim().parse().ok(),
144 _ => None,
145 }
146 };
147 let year = i16::try_from(get(0)?).ok()?;
148 let month = get(1).and_then(|m| u8::try_from(m).ok());
149 let day = month.and(get(2)).and_then(|d| u8::try_from(d).ok());
150 PartialDate::new(year, month, day)
151 .or_else(|| PartialDate::new(year, month, None))
152 .or(Some(PartialDate::from_year(year)))
153 }
154}
155
156impl CslName {
157 fn person(&self) -> Option<Person> {
158 let mut person = Person::default();
159 match (&self.family, &self.literal) {
160 (Some(family), _) => {
161 person.family = Some(family.clone());
162 person.given = self.given.clone();
163 }
164 (None, Some(literal)) => person.literal = Some(literal.clone()),
165 (None, None) => person.literal = Some(self.given.clone()?),
166 }
167 person.orcid = self.orcid.as_deref().map(|o| {
168 o.trim_start_matches("https://orcid.org/")
169 .trim_start_matches("http://orcid.org/")
170 .to_owned()
171 });
172 Some(person)
173 }
174}
175
176pub fn work_type(name: &str) -> WorkType {
178 match name {
179 "journal-article" => WorkType::JournalArticle,
180 "proceedings-article" => WorkType::ConferencePaper,
181 "posted-content" => WorkType::Preprint,
182 "book-chapter" | "book-section" | "book-part" => WorkType::Chapter,
183 "monograph" | "edited-book" | "reference-book" => WorkType::Book,
184 "dissertation" => WorkType::Thesis,
185 csl => WorkType::from_csl(csl),
186 }
187}
188
189pub fn strip_markup(text: &str) -> String {
191 let mut out = String::with_capacity(text.len());
192 let mut in_tag = false;
193 for c in text.chars() {
194 match c {
195 '<' => in_tag = true,
196 '>' if in_tag => {
197 in_tag = false;
198 out.push(' ');
199 }
200 _ if !in_tag => out.push(c),
201 _ => {}
202 }
203 }
204 let out = out
205 .replace("<", "<")
206 .replace(">", ">")
207 .replace(""", "\"")
208 .replace("'", "'")
209 .replace("&", "&");
210 out.split_whitespace().collect::<Vec<_>>().join(" ")
211}
212
213impl CslItem {
214 pub fn to_work(&self) -> Work {
215 let first = |t: &Option<OneOrMany>| t.as_ref().and_then(|t| t.first()).map(str::to_owned);
216 Work {
217 title: first(&self.title),
218 authors: self.author.iter().filter_map(CslName::person).collect(),
219 issued: self
220 .issued
221 .as_ref()
222 .and_then(CslDate::to_partial)
223 .or_else(|| self.published.as_ref().and_then(CslDate::to_partial)),
224 work_type: self.item_type.as_deref().map(work_type),
225 container_title: first(&self.container_title),
226 publisher: self.publisher.clone(),
227 volume: self.volume.as_ref().map(Scalar::text),
228 issue: self.issue.as_ref().map(Scalar::text),
229 page: self.page.as_ref().map(Scalar::text),
230 abstract_text: self
231 .abstract_text
232 .as_deref()
233 .map(|a| tidy_abstract(&strip_markup(a)))
234 .filter(|a| !a.is_empty()),
235 url: self.url.clone(),
236 cited_by_count: self.is_referenced_by_count,
237 updates: self
238 .updated_by
239 .iter()
240 .filter_map(CrossrefUpdate::to_update)
241 .collect(),
242 update_to: self
243 .update_to
244 .iter()
245 .filter_map(CrossrefUpdate::to_update_to)
246 .collect(),
247 other_years: Vec::new(),
248 open_access: None,
249 }
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 fn item(json: serde_json::Value) -> CslItem {
258 serde_json::from_value(json).unwrap()
259 }
260
261 #[test]
262 fn reads_crossref_flavoured_csl() {
263 let w = item(serde_json::json!({
264 "type": "journal-article",
265 "title": "Deep learning",
266 "container-title": "Nature",
267 "author": [{"given": "Yann", "family": "LeCun", "sequence": "first"}],
268 "issued": {"date-parts": [[2015, 5, 27]]},
269 "volume": "521", "page": "436-444",
270 "is-referenced-by-count": 79265
271 }))
272 .to_work();
273 assert_eq!(w.work_type, Some(WorkType::JournalArticle));
274 assert_eq!(w.issued.unwrap().to_string(), "2015-05-27");
275 assert_eq!(w.authors[0].display_name().as_deref(), Some("Yann LeCun"));
276 assert_eq!(w.cited_by_count, Some(79265));
277 }
278
279 #[test]
280 fn reads_retraction_watch_notices() {
281 let w = item(serde_json::json!({
282 "title": "RETRACTED: Ileal-lymphoid-nodular hyperplasia",
283 "updated-by": [
284 {"DOI": "10.1016/s0140-6736(04)15715-2", "type": "correction", "source": "retraction-watch",
285 "updated": {"date-parts": [[2004, 3, 6]]}},
286 {"DOI": "10.1016/s0140-6736(10)60175-4", "type": "retraction", "source": "retraction-watch",
287 "updated": {"date-parts": [[2010, 2, 6]]}}
288 ]
289 }))
290 .to_work();
291 assert_eq!(w.updates.len(), 2);
292 let r = w.retraction().unwrap();
293 assert_eq!(r.kind, UpdateKind::Retraction);
294 assert_eq!(r.date.unwrap().to_string(), "2010-02-06");
295 assert_eq!(
296 r.notice.as_ref().unwrap().to_string(),
297 "doi:10.1016/s0140-6736(10)60175-4"
298 );
299 assert_eq!(r.source.as_deref(), Some("retraction-watch"));
300 }
301
302 #[test]
303 fn a_notice_names_the_work_it_updates() {
304 let w = item(serde_json::json!({
305 "title": "Correction to: 2026 AHA/ACC/... Guideline for ... Acute Pulmonary Embolism",
306 "update-to": [{"DOI": "10.1161/cir.0000000000001415", "type": "correction",
307 "source": "publisher", "updated": {"date-parts": [[2026, 7, 13]]}}]
308 }))
309 .to_work();
310 assert!(w.updates.is_empty());
311 let [target] = &w.update_to[..] else { panic!() };
312 assert_eq!(target.kind, UpdateKind::Correction);
313 assert_eq!(target.work.to_string(), "doi:10.1161/cir.0000000000001415");
314 assert_eq!(target.date.unwrap().to_string(), "2026-07-13");
315 assert_eq!(target.source.as_deref(), Some("publisher"));
316 }
317
318 #[test]
319 fn tolerates_arrays_numbers_strings_and_markup() {
320 let w = item(serde_json::json!({
321 "type": "article",
322 "title": ["Attention Is All You Need", "ignored"],
323 "container-title": [],
324 "issued": {"date-parts": [["2017"]]},
325 "volume": 30,
326 "author": [{"literal": "Google Brain"}],
327 "abstract": "<jats:title>Abstract</jats:title><jats:p>The dominant & best.</jats:p>"
328 }))
329 .to_work();
330 assert_eq!(w.title.as_deref(), Some("Attention Is All You Need"));
331 assert_eq!(w.container_title, None);
332 assert_eq!(w.issued.unwrap().year(), 2017);
333 assert_eq!(w.volume.as_deref(), Some("30"));
334 assert_eq!(w.work_type, Some(WorkType::Preprint));
335 assert_eq!(w.abstract_text.as_deref(), Some("The dominant & best."));
336 }
337}