From d3984bbfdc0ea79794bb9b9e85723cd0d7ab32b7 Mon Sep 17 00:00:00 2001 From: Javier Arias Date: Wed, 21 Feb 2024 20:34:37 +0000 Subject: [PATCH] Fix #564 --- .../src/bibtex/bibtex_thoth.rs | 385 ++++++++++++------ 1 file changed, 258 insertions(+), 127 deletions(-) diff --git a/thoth-export-server/src/bibtex/bibtex_thoth.rs b/thoth-export-server/src/bibtex/bibtex_thoth.rs index 461235e2..4dd1b3a8 100644 --- a/thoth-export-server/src/bibtex/bibtex_thoth.rs +++ b/thoth-export-server/src/bibtex/bibtex_thoth.rs @@ -1,7 +1,9 @@ use std::convert::TryFrom; use std::fmt; use std::io::Write; -use thoth_client::{ContributionType, PublicationType, RelationType, Work, WorkType}; +use thoth_client::{ + ContributionType, PublicationType, RelationType, Work, WorkContributions, WorkType, +}; use thoth_errors::{ThothError, ThothResult}; use super::{BibtexEntry, BibtexSpecification}; @@ -144,106 +146,118 @@ impl TryFrom for BibtexThothEntry { "Missing Publication Date".to_string(), )); } - let mut author_list = vec![]; - let mut editor_list = vec![]; + let mut contributions = work.contributions; // WorkQuery should already have retrieved these sorted by ordinal, but sort again for safety contributions.sort_by(|a, b| a.contribution_ordinal.cmp(&b.contribution_ordinal)); - for contribution in contributions { - if contribution.main_contribution { - if work.work_type == WorkType::EDITED_BOOK { - if contribution.contribution_type == ContributionType::EDITOR { - editor_list.push(contribution.full_name); - } - } else if contribution.contribution_type == ContributionType::AUTHOR { - author_list.push(contribution.full_name); - } + let (author, mut editor) = extract_authors_and_editors(contributions)?; + + let shorttitle = work.subtitle.as_ref().map(|_| work.title.clone()); + let mut booktitle = None; + let mut chapter = None; + let mut pages = None; + let mut entry_type = "book".to_string(); + if work.work_type == WorkType::BOOK_CHAPTER { + entry_type = "inbook".to_string(); + // Inbook type does not allow both author and editor – unset editor if both are present + if author.is_some() && editor.is_some() { + editor = None; } - } - // BibTeX book/chapter records must contain either author or editor - if author_list.is_empty() && editor_list.is_empty() { - Err(ThothError::IncompleteMetadataRecord( - BIBTEX_ERROR.to_string(), - "Missing Author/Editor Details".to_string(), - )) - } else { - let author = match author_list.is_empty() { - true => None, - false => Some(author_list.join(" and ")), - }; - let editor = match editor_list.is_empty() { - true => None, - false => Some(editor_list.join(" and ")), - }; - let mut shorttitle = None; - if work.subtitle.is_some() { - shorttitle = Some(work.title); + if let Some(parent_relation) = work + .relations + .iter() + .find(|r| r.relation_type == RelationType::IS_CHILD_OF) + { + booktitle = Some(parent_relation.related_work.full_title.clone()); + chapter = Some(parent_relation.relation_ordinal); } - let mut booktitle = None; - let mut chapter = None; - let mut pages = None; - let mut entry_type = "book".to_string(); - if work.work_type == WorkType::BOOK_CHAPTER { - entry_type = "inbook".to_string(); - if let Some(parent_relation) = work - .relations - .iter() - .find(|r| r.relation_type == RelationType::IS_CHILD_OF) - { - booktitle = Some(parent_relation.related_work.full_title.clone()); - chapter = Some(parent_relation.relation_ordinal); + // BibTeX page ranges require a double dash between the page numbers + pages = work.page_interval.map(|p| p.replace('–', "--")); + } else if work.work_type == WorkType::BOOK_SET { + // None of the standard BibTeX entry types are suitable for Book Sets + entry_type = "misc".to_string(); + } + Ok(BibtexThothEntry { + entry_type, + title: work.full_title, + shorttitle, + author, + editor, + year: work + .publication_date + .map(|date| chrono::Datelike::year(&date).into()) + .unwrap(), + month: work + .publication_date + .map(|date| chrono::Datelike::month(&date).into()) + .unwrap(), + day: work + .publication_date + .map(|date| chrono::Datelike::day(&date).into()) + .unwrap(), + publisher: work.imprint.publisher.publisher_name, + address: work.place, + series: work + .issues + .first() + .map(|i| i.series.series_name.to_string()), + volume: work.issues.first().map(|i| i.issue_ordinal), + booktitle, + chapter, + pages, + doi: work.doi.map(|d| d.to_string()), + // Take digital ISBN/ISSN as canonical + isbn: work + .publications + .iter() + .find(|p| p.publication_type.eq(&PublicationType::PDF)) + .and_then(|p| p.isbn.as_ref().map(|i| i.to_string())), + issn: work + .issues + .first() + .map(|i| i.series.issn_digital.to_string()), + url: work.landing_page, + copyright: work.license, + long_abstract: work.long_abstract, + }) + } +} + +/// Returns a list of authors and a list of editors concatenated by " and " +/// +/// BibTeX book/chapter records must contain either author or editor +fn extract_authors_and_editors( + contributions: Vec, +) -> ThothResult<(Option, Option)> { + let (authors, editors): (Vec, Vec) = contributions.into_iter().fold( + (Vec::new(), Vec::new()), + |(mut authors, mut editors), contribution| { + if contribution.main_contribution { + match contribution.contribution_type { + ContributionType::AUTHOR => authors.push(contribution.full_name), + ContributionType::EDITOR => editors.push(contribution.full_name), + _ => (), } - // BibTeX page ranges require a double dash between the page numbers - pages = work.page_interval.map(|p| p.replace('–', "--")); - } else if work.work_type == WorkType::BOOK_SET { - // None of the standard BibTeX entry types are suitable for Book Sets - entry_type = "misc".to_string(); } - Ok(BibtexThothEntry { - entry_type, - title: work.full_title, - shorttitle, - author, - editor, - year: work - .publication_date - .map(|date| chrono::Datelike::year(&date).into()) - .unwrap(), - month: work - .publication_date - .map(|date| chrono::Datelike::month(&date).into()) - .unwrap(), - day: work - .publication_date - .map(|date| chrono::Datelike::day(&date).into()) - .unwrap(), - publisher: work.imprint.publisher.publisher_name, - address: work.place, - series: work - .issues - .first() - .map(|i| i.series.series_name.to_string()), - volume: work.issues.first().map(|i| i.issue_ordinal), - booktitle, - chapter, - pages, - doi: work.doi.map(|d| d.to_string()), - // Take digital ISBN/ISSN as canonical - isbn: work - .publications - .iter() - .find(|p| p.publication_type.eq(&PublicationType::PDF)) - .and_then(|p| p.isbn.as_ref().map(|i| i.to_string())), - issn: work - .issues - .first() - .map(|i| i.series.issn_digital.to_string()), - url: work.landing_page, - copyright: work.license, - long_abstract: work.long_abstract, - }) - } + (authors, editors) + }, + ); + + if authors.is_empty() && editors.is_empty() { + return Err(ThothError::IncompleteMetadataRecord( + BIBTEX_ERROR.to_string(), + "Missing Author/Editor Details".to_string(), + )); } + + let format = |v: Vec| { + if !v.is_empty() { + Some(v.join(" and ")) + } else { + None + } + }; + Ok((format(authors), format(editors))) } #[cfg(test)] @@ -266,6 +280,7 @@ mod tests { \ttitle\t\t= {Work Title: Work Subtitle}, \tshorttitle\t= {Work Title}, \tauthor\t\t= {Author 1 and Author 2 and Author 3}, +\teditor\t\t= {Editor 1 and Editor 2}, \tyear\t\t= 1999, \tmonth\t\t= 12, \tday\t\t\t= 31, @@ -282,9 +297,8 @@ mod tests { } "; - #[test] - fn test_bibtex_thoth() { - let mut test_work: Work = Work { + fn test_work() -> Work { + Work { work_id: Uuid::from_str("00000000-0000-0000-AAAA-000000000001").unwrap(), work_status: WorkStatus::ACTIVE, full_title: "Work Title: Work Subtitle".to_string(), @@ -398,9 +412,9 @@ mod tests { WorkContributions { contribution_type: ContributionType::EDITOR, first_name: Some("Editor".to_string()), - last_name: "1".to_string(), + last_name: "2".to_string(), full_name: "Editor 2".to_string(), - main_contribution: false, + main_contribution: true, biography: None, contribution_ordinal: 5, contributor: WorkContributionsContributor { @@ -409,6 +423,34 @@ mod tests { }, affiliations: vec![], }, + WorkContributions { + contribution_type: ContributionType::EDITOR, + first_name: Some("Editor".to_string()), + last_name: "2".to_string(), + full_name: "Editor 3".to_string(), + main_contribution: false, + biography: None, + contribution_ordinal: 6, + contributor: WorkContributionsContributor { + orcid: None, + website: None, + }, + affiliations: vec![], + }, + WorkContributions { + contribution_type: ContributionType::TRANSLATOR, + first_name: Some("Translator".to_string()), + last_name: "1".to_string(), + full_name: "Translator 2".to_string(), + main_contribution: true, + biography: None, + contribution_ordinal: 7, + contributor: WorkContributionsContributor { + orcid: None, + website: None, + }, + affiliations: vec![], + }, ], languages: vec![], publications: vec![ @@ -510,11 +552,18 @@ mod tests { }, }], references: vec![] - }; + } + } - let to_test = BibtexThoth.generate(&[test_work.clone()]); + #[test] + fn test_generate_record() { + let to_test = BibtexThoth.generate(&[test_work().clone()]); assert_eq!(to_test, Ok(TEST_RESULT.to_string())); + } + #[test] + fn test_generate_record_book_set() { + let mut test_work = test_work(); // Change work type to Book Set: entry type becomes "misc" test_work.work_type = WorkType::BOOK_SET; let to_test = BibtexThoth.generate(&[test_work.clone()]); @@ -522,21 +571,44 @@ mod tests { to_test, Ok(TEST_RESULT.to_string().replace("@book", "@misc")) ); + } - // Change work type to Edited Book: author field replaced by editor field + #[test] + fn test_generate_record_edited_book() { + let mut test_work = test_work(); + // Change work type to Edited Book: should have no effect test_work.work_type = WorkType::EDITED_BOOK; let to_test = BibtexThoth.generate(&[test_work.clone()]); + assert_eq!(to_test, Ok(TEST_RESULT.to_string())); + } + + #[test] + fn test_bibtex_thoth_textbook() { + let mut test_work = test_work(); + // Change work type to textbook: should have no effect + test_work.work_type = WorkType::TEXTBOOK; + let to_test = BibtexThoth.generate(&[test_work.clone()]); + assert_eq!(to_test, Ok(TEST_RESULT.to_string())); + } + + #[test] + fn test_publication_date_as_cite_key() { + let mut test_work = test_work(); + // Remove PDF ISBN field: isbn is removed, cite key becomes publication date + test_work.publications[1].isbn = None; + let to_test = BibtexThoth.generate(&[test_work.clone()]); assert_eq!( to_test, - Ok(TEST_RESULT.to_string().replace( - "\tauthor\t\t= {Author 1 and Author 2 and Author 3},", - "\teditor\t\t= {Editor 1}," - )) + Ok(TEST_RESULT + .to_string() + .replace("@book{978-1-56619-909-4,", "@book{1999-12-31,") + .replace("\tisbn\t\t= {978-1-56619-909-4},\n", "")) ); + } - test_work.work_type = WorkType::MONOGRAPH; - // Remove PDF ISBN field: isbn is removed, cite key becomes publication date - test_work.publications[1].isbn = None; + #[test] + fn test_work_without_subtitle() { + let mut test_work = test_work(); // Remove subtitle field: shorttitle is removed (as it would duplicate title) test_work.subtitle = None; // We need to manually update the full title to remove the subtitle @@ -545,17 +617,19 @@ mod tests { let to_test = BibtexThoth.generate(&[test_work.clone()]); assert_eq!( to_test, - Ok(TEST_RESULT - .to_string() - .replace("@book{978-1-56619-909-4,", "@book{1999-12-31,") - .replace("\tisbn\t\t= {978-1-56619-909-4},\n", "") - .replace( - "\ttitle\t\t= {Work Title: Work Subtitle},\n\tshorttitle\t= {Work Title},", - "\ttitle\t\t= {Work Title}," - )) + Ok(TEST_RESULT.to_string().replace( + "\ttitle\t\t= {Work Title: Work Subtitle},\n\tshorttitle\t= {Work Title},", + "\ttitle\t\t= {Work Title}," + )) ); + } - // Remove all other optional fields: corresponding fields will be removed + #[test] + fn test_bibtex_thoth_chapter() { + let mut test_work = test_work(); + test_work.subtitle = None; + test_work.full_title = "Work Title".to_string(); + test_work.publications[1].isbn = None; test_work.place = None; test_work.doi = None; test_work.landing_page = None; @@ -583,28 +657,85 @@ mod tests { " .to_string(); assert_eq!(to_test, Ok(test_result)); + } - // Remove publication date: BibTeX fails to generate + #[test] + fn test_missing_publication_date_error() { + let mut test_work = test_work(); test_work.publication_date = None; let to_test = BibtexThoth.generate(&[test_work.clone()]); assert_eq!( - to_test, - Err(ThothError::IncompleteMetadataRecord( + to_test.unwrap_err(), + ThothError::IncompleteMetadataRecord( BIBTEX_ERROR.to_string(), "Missing Publication Date".to_string(), - )) + ) ); + } - // Reinstate publication date but remove author/editor details: ditto - test_work.publication_date = chrono::NaiveDate::from_ymd_opt(1999, 12, 31); + #[test] + fn test_missing_author_details_error() { + let mut test_work = test_work(); test_work.contributions.clear(); let to_test = BibtexThoth.generate(&[test_work.clone()]); assert_eq!( - to_test, - Err(ThothError::IncompleteMetadataRecord( + to_test.unwrap_err(), + ThothError::IncompleteMetadataRecord( BIBTEX_ERROR.to_string(), - "Missing Author/Editor Details".to_string(), - )) + "Missing Author/Editor Details".to_string() + ) + ); + } + + #[test] + fn test_extract_authors_and_editors_both_present() { + let test_work = test_work(); + let to_test = extract_authors_and_editors(test_work.contributions); + assert_eq!( + to_test.unwrap(), + ( + Some("Author 1 and Author 2 and Author 3".to_string()), + Some("Editor 1 and Editor 2".to_string()) + ) + ); + } + + #[test] + fn test_extract_authors_and_editors_only_authors() { + let test_work = test_work(); + let to_test = extract_authors_and_editors(vec![ + test_work.contributions[0].clone(), + test_work.contributions[1].clone(), + ]); + assert_eq!( + to_test.unwrap(), + (Some("Author 1 and Author 2".to_string()), None) + ); + } + + #[test] + fn test_extract_authors_and_editors_only_editors() { + let test_work = test_work(); + let to_test = extract_authors_and_editors(vec![ + test_work.contributions[3].clone(), + test_work.contributions[4].clone(), + ]); + assert_eq!( + to_test.unwrap(), + (None, Some("Editor 1 and Editor 2".to_string())) + ); + } + + #[test] + fn test_extract_authors_and_editors_missing_details() { + let test_work = test_work(); + let to_test = extract_authors_and_editors(vec![test_work.contributions[5].clone()]); + assert_eq!( + to_test.unwrap_err(), + ThothError::IncompleteMetadataRecord( + BIBTEX_ERROR.to_string(), + "Missing Author/Editor Details".to_string() + ) ); } }