1use std::fmt;
12use std::str::FromStr;
13
14use serde::{Deserialize, Serialize};
15
16use crate::id::is_digits;
17
18#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
19#[non_exhaustive]
20pub struct Work {
21 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub title: Option<String>,
23 #[serde(default, skip_serializing_if = "Vec::is_empty")]
25 pub authors: Vec<Person>,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub issued: Option<PartialDate>,
28 #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")]
29 pub work_type: Option<WorkType>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub container_title: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
34 pub volume: Option<String>,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub issue: Option<String>,
37 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub page: Option<String>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub publisher: Option<String>,
41 #[serde(default, rename = "abstract", skip_serializing_if = "Option::is_none")]
42 pub abstract_text: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
45 pub url: Option<String>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub cited_by_count: Option<u64>,
49 #[serde(default, skip_serializing_if = "Vec::is_empty")]
51 pub updates: Vec<Update>,
52 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub update_to: Vec<UpdateTo>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub open_access: Option<OpenAccess>,
59 #[serde(default, skip_serializing_if = "Vec::is_empty")]
63 pub other_years: Vec<i16>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69#[non_exhaustive]
70pub struct Update {
71 pub kind: UpdateKind,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub notice: Option<crate::id::WorkId>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub date: Option<PartialDate>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub source: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub deposited: Option<PartialDate>,
85}
86
87impl Update {
88 pub fn new(kind: UpdateKind) -> Self {
89 Update {
90 kind,
91 notice: None,
92 date: None,
93 source: None,
94 deposited: None,
95 }
96 }
97}
98
99#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
102#[non_exhaustive]
103pub struct OpenAccess {
104 pub is_oa: bool,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub status: Option<String>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub pdf_url: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub url: Option<String>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub license: Option<String>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub version: Option<String>,
123 #[serde(default, skip_serializing_if = "Option::is_none")]
125 pub source: Option<String>,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131#[non_exhaustive]
132pub struct UpdateTo {
133 pub kind: UpdateKind,
134 pub work: crate::id::WorkId,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub date: Option<PartialDate>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub source: Option<String>,
140}
141
142impl UpdateTo {
143 pub fn new(kind: UpdateKind, work: crate::id::WorkId) -> Self {
144 UpdateTo {
145 kind,
146 work,
147 date: None,
148 source: None,
149 }
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Hash)]
154#[non_exhaustive]
155pub enum UpdateKind {
156 Retraction,
157 Withdrawal,
158 ExpressionOfConcern,
159 Correction,
160 NewVersion,
164 Other(String),
165}
166
167impl UpdateKind {
168 pub fn as_str(&self) -> &str {
169 match self {
170 UpdateKind::Retraction => "retraction",
171 UpdateKind::Withdrawal => "withdrawal",
172 UpdateKind::ExpressionOfConcern => "expression_of_concern",
173 UpdateKind::Correction => "correction",
174 UpdateKind::NewVersion => "new_version",
175 UpdateKind::Other(s) => s,
176 }
177 }
178
179 pub fn parse(s: &str) -> Self {
182 match s.to_ascii_lowercase().replace('-', "_").as_str() {
183 "retraction" | "partial_retraction" => UpdateKind::Retraction,
184 "withdrawal" | "removal" => UpdateKind::Withdrawal,
185 "expression_of_concern" => UpdateKind::ExpressionOfConcern,
186 "correction" | "erratum" | "corrigendum" | "addendum" => UpdateKind::Correction,
187 "new_version" | "new_edition" => UpdateKind::NewVersion,
188 _ => UpdateKind::Other(s.to_owned()),
189 }
190 }
191
192 pub fn is_retraction(&self) -> bool {
194 matches!(self, UpdateKind::Retraction | UpdateKind::Withdrawal)
195 }
196}
197
198impl Serialize for UpdateKind {
199 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
200 s.serialize_str(self.as_str())
201 }
202}
203
204impl<'de> Deserialize<'de> for UpdateKind {
205 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
206 Ok(UpdateKind::parse(&String::deserialize(d)?))
207 }
208}
209
210impl Work {
211 pub fn retraction(&self) -> Option<&Update> {
213 self.updates.iter().find(|u| u.kind.is_retraction())
214 }
215
216 pub fn type_as_notice(&mut self) {
221 let kinds = || self.update_to.iter().map(|t| &t.kind);
222 let name = if kinds().any(UpdateKind::is_retraction) {
223 "retraction"
224 } else if kinds().any(|k| *k != UpdateKind::NewVersion) {
225 "erratum"
226 } else {
227 return;
228 };
229 self.work_type = Some(WorkType::Other(name.into()));
230 }
231
232 pub fn newer_version(&self) -> Option<&crate::id::WorkId> {
234 self.updates
235 .iter()
236 .filter(|u| u.kind == UpdateKind::NewVersion)
237 .find_map(|u| u.notice.as_ref())
238 }
239}
240
241#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
242#[non_exhaustive]
243pub struct Person {
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub given: Option<String>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub family: Option<String>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub literal: Option<String>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub orcid: Option<String>,
254}
255
256impl Person {
257 pub fn new(given: impl Into<String>, family: impl Into<String>) -> Self {
258 Person {
259 given: Some(given.into()),
260 family: Some(family.into()),
261 ..Person::default()
262 }
263 }
264
265 pub fn literal(name: impl Into<String>) -> Self {
266 Person {
267 literal: Some(name.into()),
268 ..Person::default()
269 }
270 }
271
272 pub fn parts(&self) -> Option<(String, Option<String>)> {
277 match (&self.family, &self.literal) {
278 (Some(family), _) => Some((family.clone(), self.given.clone())),
279 (None, Some(literal)) => split_literal(literal),
280 (None, None) => None,
281 }
282 }
283
284 pub fn initials(&self) -> Option<String> {
287 let (_, given) = self.parts()?;
288 let initials: String = given?
289 .split(|c: char| c.is_whitespace() || c == '-' || c == '.')
290 .filter_map(|part| part.chars().find(|c| c.is_alphabetic()))
291 .flat_map(char::to_uppercase)
292 .collect();
293 (!initials.is_empty()).then_some(initials)
294 }
295
296 pub fn short(&self) -> Option<String> {
299 match self.parts() {
300 Some((family, _)) => Some(match self.initials() {
301 Some(initials) => format!("{family} {initials}"),
302 None => family,
303 }),
304 None => self.display_name(),
305 }
306 }
307
308 pub fn display_name(&self) -> Option<String> {
310 match (&self.given, &self.family, &self.literal) {
311 (Some(g), Some(f), _) => Some(format!("{g} {f}")),
312 (None, Some(f), _) => Some(f.clone()),
313 (_, _, Some(l)) => Some(l.clone()),
314 (Some(g), None, None) => Some(g.clone()),
315 (None, None, None) => None,
316 }
317 }
318}
319
320const PARTICLES: [&str; 14] = [
322 "van", "von", "de", "der", "den", "da", "di", "du", "la", "le", "del", "dos", "ten", "ter",
323];
324
325const BODIES: [&str; 40] = [
327 "university",
328 "institute",
329 "institution",
330 "group",
331 "team",
332 "organization",
333 "organisation",
334 "consortium",
335 "committee",
336 "association",
337 "society",
338 "collaboration",
339 "network",
340 "centre",
341 "center",
342 "foundation",
343 "agency",
344 "council",
345 "department",
346 "ministry",
347 "office",
348 "board",
349 "federation",
350 "alliance",
351 "project",
352 "initiative",
353 "commission",
354 "programme",
355 "program",
356 "inc",
357 "ltd",
358 "corporation",
359 "company",
360 "trust",
361 "investigators",
362 "study",
363 "panel",
364 "laboratory",
365 "hospital",
366 "college",
367];
368
369fn split_literal(literal: &str) -> Option<(String, Option<String>)> {
373 let words: Vec<&str> = literal.split_whitespace().collect();
374 let personal = (2..=5).contains(&words.len())
375 && words.iter().all(|w| {
376 let lower = w.trim_end_matches(['.', ',']).to_lowercase();
377 let capital = w.chars().next().is_some_and(char::is_uppercase);
378 let acronym = w.chars().count() > 2 && w.chars().all(|c| !c.is_lowercase());
379 (capital || PARTICLES.contains(&lower.as_str()))
380 && !acronym
381 && !BODIES.contains(&lower.as_str())
382 && !w.chars().any(|c| c.is_ascii_digit())
383 });
384 if !personal {
385 return None;
386 }
387 let mut start = words.len() - 1;
388 while start > 1 && PARTICLES.contains(&words[start - 1]) {
389 start -= 1;
390 }
391 Some((words[start..].join(" "), Some(words[..start].join(" "))))
392}
393
394pub fn tidy_abstract(text: &str) -> String {
398 let text = text.trim();
399 for heading in ["Abstract", "ABSTRACT"] {
400 if let Some(rest) = text.strip_prefix(heading)
401 && rest.starts_with(|c: char| c.is_whitespace() || matches!(c, ':' | '.' | '-' | '—'))
402 {
403 return rest
404 .trim_start_matches(|c: char| {
405 c.is_whitespace() || matches!(c, ':' | '.' | '-' | '—')
406 })
407 .to_owned();
408 }
409 }
410 text.to_owned()
411}
412
413pub(crate) const PLAUSIBLE_YEARS: std::ops::RangeInclusive<i16> = 1600..=2100;
416
417#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
420pub struct PartialDate {
421 year: i16,
422 month: Option<u8>,
423 day: Option<u8>,
424}
425
426#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
427#[error("invalid date {0:?}: expected YYYY, YYYY-MM or YYYY-MM-DD")]
428pub struct DateError(String);
429
430impl PartialDate {
431 pub fn from_year(year: i16) -> Self {
432 PartialDate {
433 year,
434 month: None,
435 day: None,
436 }
437 }
438
439 pub fn new(year: i16, month: Option<u8>, day: Option<u8>) -> Option<Self> {
441 match (month, day) {
442 (None, Some(_)) => return None,
443 (Some(m), None) if !(1..=12).contains(&m) => return None,
444 (Some(m), Some(d)) => {
445 jiff::civil::Date::new(year, i8::try_from(m).ok()?, i8::try_from(d).ok()?).ok()?;
446 }
447 _ => {}
448 }
449 Some(PartialDate { year, month, day })
450 }
451
452 pub fn year(&self) -> i16 {
453 self.year
454 }
455
456 pub fn month(&self) -> Option<u8> {
457 self.month
458 }
459
460 pub fn day(&self) -> Option<u8> {
461 self.day
462 }
463}
464
465impl FromStr for PartialDate {
466 type Err = DateError;
467
468 fn from_str(s: &str) -> Result<Self, DateError> {
469 let err = || DateError(s.to_owned());
470 let mut parts = s.trim().splitn(3, '-');
471 let mut num = |len: usize| match parts.next() {
473 None => Ok(None),
474 Some(p) if p.len() == len && is_digits(p) => Ok(Some(p)),
475 Some(_) => Err(err()),
476 };
477 let year = num(4)?.ok_or_else(err)?;
478 let month = num(2)?;
479 let day = num(2)?;
480 let two = |d: &str| d.parse().expect("two digits");
482 PartialDate::new(
483 year.parse().expect("four digits"),
484 month.map(two),
485 day.map(two),
486 )
487 .ok_or_else(err)
488 }
489}
490
491impl fmt::Display for PartialDate {
492 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
493 write!(f, "{:04}", self.year)?;
494 if let Some(m) = self.month {
495 write!(f, "-{m:02}")?;
496 }
497 if let Some(d) = self.day {
498 write!(f, "-{d:02}")?;
499 }
500 Ok(())
501 }
502}
503
504#[derive(Debug, Clone, PartialEq, Eq, Hash)]
507#[non_exhaustive]
508pub enum WorkType {
509 JournalArticle,
510 ConferencePaper,
511 Preprint,
513 Book,
514 Chapter,
515 Thesis,
516 Report,
517 Dataset,
518 Software,
519 Review,
520 Other(String),
521}
522
523impl WorkType {
524 pub fn as_csl(&self) -> &str {
526 match self {
527 WorkType::JournalArticle => "article-journal",
528 WorkType::ConferencePaper => "paper-conference",
529 WorkType::Preprint => "article",
530 WorkType::Book => "book",
531 WorkType::Chapter => "chapter",
532 WorkType::Thesis => "thesis",
533 WorkType::Report => "report",
534 WorkType::Dataset => "dataset",
535 WorkType::Software => "software",
536 WorkType::Review => "review",
537 WorkType::Other(s) => s,
538 }
539 }
540
541 pub fn from_csl(s: &str) -> Self {
543 match s {
544 "article-journal" => WorkType::JournalArticle,
545 "paper-conference" => WorkType::ConferencePaper,
546 "article" => WorkType::Preprint,
547 "book" => WorkType::Book,
548 "chapter" => WorkType::Chapter,
549 "thesis" => WorkType::Thesis,
550 "report" => WorkType::Report,
551 "dataset" => WorkType::Dataset,
552 "software" => WorkType::Software,
553 "review" => WorkType::Review,
554 other => WorkType::Other(other.to_owned()),
555 }
556 }
557}
558
559impl Serialize for WorkType {
560 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
561 s.serialize_str(self.as_csl())
562 }
563}
564
565impl<'de> Deserialize<'de> for WorkType {
566 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
567 Ok(WorkType::from_csl(&String::deserialize(d)?))
568 }
569}
570
571impl Serialize for PartialDate {
572 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
573 s.collect_str(self)
574 }
575}
576
577impl<'de> Deserialize<'de> for PartialDate {
578 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
579 String::deserialize(d)?
580 .parse()
581 .map_err(serde::de::Error::custom)
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn names_split_and_shorten_as_vancouver_writes_them() {
591 let short = |p: Person| p.short().unwrap();
592 assert_eq!(short(Person::new("Dong V.", "Laan")), "Laan DV");
593 assert_eq!(short(Person::new("Thi Diem Ngoc", "Vu")), "Vu TDN");
594 assert_eq!(short(Person::new("Jean-Pierre", "Dupont")), "Dupont JP");
595 assert_eq!(short(Person::literal("Yann LeCun")), "LeCun Y");
596 assert_eq!(short(Person::literal("Vincent van Gogh")), "van Gogh V");
597 assert_eq!(short(Person::literal("WHO")), "WHO");
598 assert_eq!(
599 short(Person::literal("GBD 2019 Collaborators")),
600 "GBD 2019 Collaborators"
601 );
602 assert_eq!(
603 Person::literal("Vincent van Gogh").parts(),
604 Some(("van Gogh".into(), Some("Vincent".into())))
605 );
606 assert_eq!(Person::literal("World Health Organization").parts(), None);
607 }
608
609 #[test]
610 fn partial_dates_parse_validate_and_round_trip() {
611 for s in ["2017", "2017-06", "2017-06-12", "2024-02-29"] {
612 assert_eq!(s.parse::<PartialDate>().unwrap().to_string(), s);
613 }
614 for s in [
615 "17",
616 "2017-6",
617 "2017-13",
618 "2017-06-31",
619 "2023-02-29",
620 "2017--01",
621 "x",
622 ] {
623 assert!(s.parse::<PartialDate>().is_err(), "{s:?}");
624 }
625 assert_eq!(PartialDate::new(2017, None, Some(3)), None);
626 }
627
628 #[test]
629 fn work_type_keeps_unknown_values() {
630 assert_eq!(
631 WorkType::from_csl("article-journal"),
632 WorkType::JournalArticle
633 );
634 let other = WorkType::from_csl("musical-score");
635 assert_eq!(other.as_csl(), "musical-score");
636 }
637
638 #[test]
639 fn work_serialises_sparsely_with_csl_names() {
640 let w = Work {
641 title: Some("Attention Is All You Need".into()),
642 issued: Some(PartialDate::from_year(2017)),
643 work_type: Some(WorkType::ConferencePaper),
644 authors: vec![Person::new("Ashish", "Vaswani")],
645 ..Work::default()
646 };
647 let json = serde_json::to_value(&w).unwrap();
648 assert_eq!(
649 json,
650 serde_json::json!({
651 "title": "Attention Is All You Need",
652 "authors": [{"given": "Ashish", "family": "Vaswani"}],
653 "issued": "2017",
654 "type": "paper-conference",
655 })
656 );
657 assert_eq!(serde_json::from_value::<Work>(json).unwrap(), w);
658 }
659
660 #[test]
661 fn tidy_abstract_drops_only_a_standalone_heading() {
662 assert_eq!(
663 tidy_abstract("Abstract Proteins are essential"),
664 "Proteins are essential"
665 );
666 assert_eq!(tidy_abstract("ABSTRACT: We show"), "We show");
667 assert_eq!(
668 tidy_abstract(" Abstraction is useful"),
669 "Abstraction is useful"
670 );
671 assert_eq!(tidy_abstract("We show"), "We show");
672 }
673
674 #[test]
675 fn person_display_name_prefers_split_names() {
676 assert_eq!(
677 Person::new("Ada", "Lovelace").display_name().unwrap(),
678 "Ada Lovelace"
679 );
680 assert_eq!(Person::literal("CERN").display_name().unwrap(), "CERN");
681 assert_eq!(Person::default().display_name(), None);
682 }
683}