use std::collections::HashMap;
use async_trait::async_trait;
use chrono::prelude::*;
use crossref::{response::work::PartialDate, Crossref};
use self::identifiers::{GenericWorkIdentifier, GenericWorkType, IdProp};
use crate::{
adapter_helpers::{get_external_identifier_from_item, wb_time_from_partial},
scientific_publication_adapter::{crossref_work_type_to_q, ScientificPublicationAdapter},
*,
};
pub struct Crossref2Wikidata {
author_cache: HashMap<String, String>,
work_cache: HashMap<String, crossref::Work>,
client: Crossref,
}
impl Default for Crossref2Wikidata {
fn default() -> Self {
let client = Crossref::builder()
.http_client(crate::http_client::http_client().clone())
.build()
.expect("Crossref2Wikidata::default: Crossref::builder().build() failed");
Self::new_with_client(client)
}
}
impl Crossref2Wikidata {
pub fn new() -> Self {
Self::default()
}
pub fn new_with_client(client: Crossref) -> Self {
Crossref2Wikidata {
author_cache: HashMap::new(),
work_cache: HashMap::new(),
client,
}
}
pub fn get_cached_publication_from_id(&self, publication_id: &str) -> Option<&crossref::Work> {
self.work_cache.get(publication_id)
}
fn should_add_string(&self, s: &str) -> bool {
if s == "n/a" || s == "n/a-n/a" {
return false;
}
true
}
}
fn parse_crossref_date(issued: &PartialDate) -> Option<(u32, Option<u8>, Option<u8>)> {
let j = json!(issued);
let dp = j["date-parts"][0].as_array()?;
if dp.is_empty() {
return None;
}
let year = dp[0].as_u64()? as u32;
let month = if dp.len() >= 2 {
dp[1].as_u64().map(|x| x as u8)
} else {
None
};
let day = if dp.len() >= 3 {
dp[2].as_u64().map(|x| x as u8)
} else {
None
};
Some((year, month, day))
}
#[async_trait(?Send)]
impl ScientificPublicationAdapter for Crossref2Wikidata {
fn name(&self) -> &str {
"Crossref2Wikidata"
}
fn get_work_type(&self, publication_id: &str) -> Option<String> {
let work = self.get_cached_publication_from_id(publication_id)?;
crossref_work_type_to_q(&work.type_).map(|s| s.to_string())
}
fn get_work_issn(&self, publication_id: &str) -> Option<String> {
self.get_cached_publication_from_id(publication_id)?
.issn
.as_ref()
.and_then(|a| a.first().cloned())
}
fn author_cache(&self) -> &HashMap<String, String> {
&self.author_cache
}
fn author_cache_mut(&mut self) -> &mut HashMap<String, String> {
&mut self.author_cache
}
fn has_cached_publication(&self, publication_id: &str) -> bool {
self.get_cached_publication_from_id(publication_id).is_some()
}
fn extract_extra_ids(&self, publication_id: &str) -> Vec<GenericWorkIdentifier> {
let Some(work) = self.get_cached_publication_from_id(publication_id) else {
return vec![];
};
if work.doi.is_empty() {
return vec![];
}
vec![GenericWorkIdentifier::new_prop(IdProp::DOI, &work.doi)]
}
async fn get_identifier_list(
&mut self,
ids: &[GenericWorkIdentifier],
) -> Vec<GenericWorkIdentifier> {
let dois: Vec<String> = ids
.iter()
.filter_map(|id| {
if let GenericWorkType::Property(prop) = &id.work_type() {
if *prop == IdProp::DOI {
return Some(id.id().to_string());
}
}
None
})
.collect();
let futures: Vec<_> = dois
.iter()
.map(|doi| {
let client = self.client.clone();
let doi = doi.clone();
async move { client.work(&doi).await.ok() }
})
.collect();
for work in futures::future::join_all(futures).await.into_iter().flatten() {
self.work_cache.insert(work.doi.clone(), work);
}
let mut ret = vec![];
for doi in &dois {
self.add_identifiers_from_cached_publication(doi, &mut ret);
}
ret
}
async fn publication_id_from_item(&mut self, item: &Entity) -> Option<String> {
let doi = get_external_identifier_from_item(item, &IdProp::DOI)?;
let work = match self.client.work(&doi).await {
Ok(w) => w,
_ => return None, };
let publication_id = doi;
self.work_cache.insert(publication_id.clone(), work);
Some(publication_id)
}
fn reference(&self) -> Vec<Reference> {
let now = Utc::now().format("+%Y-%m-%dT00:00:00Z").to_string();
vec![Reference::new(vec![Snak::new_time("P813", &now, 11)])]
}
fn get_work_titles(&self, publication_id: &str) -> Vec<LocaleString> {
match self.get_cached_publication_from_id(publication_id) {
Some(work) => work.title.iter().map(|t| LocaleString::new("en", t)).collect(),
None => vec![],
}
}
fn get_publication_date(&self, publication_id: &str) -> Option<(u32, Option<u8>, Option<u8>)> {
let work = self.get_cached_publication_from_id(publication_id)?;
parse_crossref_date(&work.issued)
}
async fn update_statements_for_publication_id(&self, publication_id: &str, item: &mut Entity) {
let work = match self.get_cached_publication_from_id(publication_id) {
Some(w) => w,
None => return,
};
if !item.has_claims_with_property("P577") {
if let Some((year, month, day)) = self.get_publication_date(publication_id) {
let statement = wb_time_from_partial("P577", year, month, day, self.reference());
item.add_claim(statement);
}
}
let string_options =
vec![("P433", &work.issue), ("P478", &work.volume), ("P304", &work.page)];
for option in string_options {
if !item.has_claims_with_property(option.0) {
if let Some(v) = option.1 {
if self.should_add_string(v) {
item.add_claim(Statement::new_normal(
Snak::new_string(option.0, v),
vec![],
self.reference(),
));
}
}
}
}
if let Some(subjects) = &work.subject {
for _subject in subjects {
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scientific_publication_adapter::crossref_work_type_to_q;
use crossref::response::work::DateParts;
fn partial_date(parts: Vec<Option<u32>>) -> PartialDate {
PartialDate { date_parts: DateParts(vec![parts]) }
}
#[test]
fn parse_crossref_date_full_ymd() {
let d = partial_date(vec![Some(2023), Some(6), Some(15)]);
assert_eq!(parse_crossref_date(&d), Some((2023, Some(6), Some(15))));
}
#[test]
fn parse_crossref_date_year_and_month() {
let d = partial_date(vec![Some(2023), Some(6)]);
assert_eq!(parse_crossref_date(&d), Some((2023, Some(6), None)));
}
#[test]
fn parse_crossref_date_year_only() {
let d = partial_date(vec![Some(2023)]);
assert_eq!(parse_crossref_date(&d), Some((2023, None, None)));
}
#[test]
fn parse_crossref_date_empty_array_returns_none() {
let d = partial_date(vec![]);
assert_eq!(parse_crossref_date(&d), None);
}
#[test]
fn parse_crossref_date_null_year_returns_none() {
let d = partial_date(vec![None, Some(6), Some(15)]);
assert_eq!(parse_crossref_date(&d), None);
}
#[test]
fn parse_crossref_date_null_month_keeps_year() {
let d = partial_date(vec![Some(2023), None]);
assert_eq!(parse_crossref_date(&d), Some((2023, None, None)));
}
#[test]
fn parse_crossref_date_null_day_keeps_year_month() {
let d = partial_date(vec![Some(2023), Some(6), None]);
assert_eq!(parse_crossref_date(&d), Some((2023, Some(6), None)));
}
#[test]
fn parse_crossref_date_extra_parts_ignored() {
let d = partial_date(vec![Some(2023), Some(6), Some(15), Some(99)]);
assert_eq!(parse_crossref_date(&d), Some((2023, Some(6), Some(15))));
}
#[test]
fn test_crossref_type_to_q_journal_article() {
assert_eq!(crossref_work_type_to_q("journal-article"), Some("Q13442814"));
}
#[test]
fn test_crossref_type_to_q_book() {
assert_eq!(crossref_work_type_to_q("book"), Some("Q571"));
assert_eq!(crossref_work_type_to_q("edited-book"), Some("Q571"));
assert_eq!(crossref_work_type_to_q("reference-book"), Some("Q571"));
}
#[test]
fn test_crossref_type_to_q_monograph() {
assert_eq!(crossref_work_type_to_q("monograph"), Some("Q193495"));
}
#[test]
fn test_crossref_type_to_q_chapter() {
assert_eq!(crossref_work_type_to_q("book-chapter"), Some("Q1980247"));
assert_eq!(crossref_work_type_to_q("book-section"), Some("Q1980247"));
}
#[test]
fn test_crossref_type_to_q_unknown_returns_none() {
assert_eq!(crossref_work_type_to_q("unknown-type"), None);
}
#[test]
fn should_add_string_rejects_na() {
let c = Crossref2Wikidata::new();
assert!(!c.should_add_string("n/a"));
assert!(!c.should_add_string("n/a-n/a"));
}
#[test]
fn should_add_string_accepts_valid_strings() {
let c = Crossref2Wikidata::new();
assert!(c.should_add_string("1"));
assert!(c.should_add_string("some-value"));
assert!(c.should_add_string("N/A")); }
#[test]
fn should_add_string_accepts_empty_string() {
let c = Crossref2Wikidata::new();
assert!(c.should_add_string(""));
}
#[test]
fn test_crossref_type_to_q_proceedings() {
assert_eq!(crossref_work_type_to_q("proceedings-article"), Some("Q23927052"));
assert_eq!(crossref_work_type_to_q("proceedings"), Some("Q1143604"));
}
use wiremock::matchers::method as wm_method;
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn publication_id_from_item_routes_through_injected_base_url() {
let server = MockServer::start().await;
Mock::given(wm_method("GET"))
.respond_with(ResponseTemplate::new(500))
.expect(1..)
.mount(&server)
.await;
let sdk = Crossref::builder()
.base_url(server.uri())
.build()
.expect("Crossref::builder().base_url(mock).build()");
let mut adapter = Crossref2Wikidata::new_with_client(sdk);
let mut item = wikibase::Entity::new_empty_item();
item.add_claim(wikibase::Statement::new_normal(
wikibase::Snak::new_external_id("P356", "10.1234/test"),
vec![],
vec![],
));
let pub_id = adapter.publication_id_from_item(&item).await;
assert!(pub_id.is_none(), "expected None on SDK 500, got {pub_id:?}");
}
}