use std::{
collections::{HashMap, HashSet},
sync::Arc,
};
use anyhow::{anyhow, Context, Result};
use rayon::prelude::*;
use tokio::sync::RwLock;
use wikibase::mediawiki::api::Api;
use self::{
identifiers::{GenericWorkIdentifier, GenericWorkType},
wikidata_interaction::WikidataInteraction,
};
use crate::{
generic_author_info::GenericAuthorInfo,
scientific_publication_adapter::ScientificPublicationAdapter,
wikidata_string_cache::WikidataStringCache, *,
};
pub type Spas = Box<dyn ScientificPublicationAdapter + Sync>;
lazy_static! {
static ref SNAK_REMOVE_STATEMENT: Snak = Snak::new_no_value("P2093", SnakDataType::String);
}
pub(crate) const MAX_ID_DISCOVERY_PASSES: usize = 8;
pub struct EditResult {
q: String,
edited: bool,
}
impl EditResult {
pub fn q(&self) -> &str {
&self.q
}
pub fn edited(&self) -> bool {
self.edited
}
}
pub struct WikidataPapers {
adapters: Vec<Spas>,
cache: Arc<WikidataStringCache>,
edit_summary: Option<String>,
testing: bool,
entities: entity_container::EntityContainer,
}
impl WikidataInteraction for WikidataPapers {}
impl WikidataPapers {
pub fn new(cache: Arc<WikidataStringCache>) -> WikidataPapers {
let mut entities = entity_container::EntityContainer::new();
entities.allow_special_entity_data(false);
WikidataPapers {
adapters: vec![],
cache,
edit_summary: None,
testing: false,
entities,
}
}
pub fn with_default_adapters(cache: Arc<WikidataStringCache>) -> WikidataPapers {
use crate::{
arxiv2wikidata::Arxiv2Wikidata,
crossref2wikidata::Crossref2Wikidata,
datacite2wikidata::DataCite2Wikidata,
europepmc2wikidata::EuropePMC2Wikidata,
http_client::{HttpJsonFetcher, JsonFetcher},
openalex2wikidata::OpenAlex2Wikidata,
orcid2wikidata::Orcid2Wikidata,
pmc2wikidata::PMC2Wikidata,
pubmed2wikidata::Pubmed2Wikidata,
semanticscholar2wikidata::Semanticscholar2Wikidata,
};
let fetcher: Arc<dyn JsonFetcher> = Arc::new(HttpJsonFetcher::default());
let mut wdp = Self::new(cache);
wdp.add_adapter(Box::new(PMC2Wikidata::new(fetcher.clone())));
wdp.add_adapter(Box::new(Pubmed2Wikidata::new()));
wdp.add_adapter(Box::new(Crossref2Wikidata::new()));
wdp.add_adapter(Box::new(Semanticscholar2Wikidata::new()));
wdp.add_adapter(Box::new(Orcid2Wikidata::new()));
wdp.add_adapter(Box::new(Arxiv2Wikidata::new()));
wdp.add_adapter(Box::new(OpenAlex2Wikidata::new(fetcher.clone())));
wdp.add_adapter(Box::new(DataCite2Wikidata::new(fetcher.clone())));
wdp.add_adapter(Box::new(EuropePMC2Wikidata::new(fetcher)));
wdp
}
pub fn adapters_mut(&mut self) -> &mut Vec<Spas> {
&mut self.adapters
}
pub fn add_adapter(&mut self, adapter_box: Spas) {
self.adapters.push(adapter_box);
}
pub fn set_testing(&mut self, testing: bool) {
self.testing = testing;
}
pub fn edit_summary(&self) -> &Option<String> {
&self.edit_summary
}
pub fn set_edit_summary(&mut self, edit_summary: Option<String>) {
self.edit_summary = edit_summary;
}
fn create_author_statements(&mut self, authors: &Vec<GenericAuthorInfo>, item: &mut Entity) {
let mut seen_q_items: HashSet<String> = HashSet::new();
let mut seen_names: HashSet<String> = HashSet::new();
for author in authors {
if let Some(q) = author.wikidata_item() {
if !seen_q_items.insert(q.to_string()) {
continue;
}
}
if author.wikidata_item().is_none() {
if let Some(name) = author.name() {
let key = GenericAuthorInfo::simplify_name(&name.to_lowercase());
if !key.is_empty() && !seen_names.insert(key) {
continue;
}
}
}
author.create_author_statement_in_paper_item(item);
}
}
pub fn update_author_name_statement(
&self,
asn: &str,
new_author: &GenericAuthorInfo,
item: &mut Entity,
) {
let author_q = match new_author.wikidata_item() {
Some(q) => q,
None => return,
};
if Self::get_p50s_from_item(item).contains(&format!("Q{}", author_q)) {
return; }
item.claims_mut()
.par_iter_mut()
.filter(|statement| statement.property() == "P2093")
.filter_map(|statement| {
let author = GenericAuthorInfo::new_from_statement(statement)?;
Some((author, statement))
})
.filter(|(author, _statement)| author.name() == Some(asn))
.for_each(|(_author, p2093_statement)| {
let p50_statement = match &new_author.generate_author_statement() {
Some(p50_statement) => p50_statement.to_owned(),
None => return,
};
Self::replace_p2093_with_p50_in_place(&p50_statement, p2093_statement);
});
Self::remove_statements_with_no_value(item);
}
fn update_author_statements(&self, authors: &[GenericAuthorInfo], item: &mut Entity) {
let p50 = Self::get_p50s_from_item(item);
let mut used_candidates: HashSet<usize> = HashSet::new();
for statement in item.claims_mut().iter_mut() {
if statement.property() != "P2093" {
continue;
}
let author = match GenericAuthorInfo::new_from_statement(statement) {
Some(a) => a,
None => continue,
};
let (candidate, _points) = match author.find_best_match(authors) {
Some(m) => m,
None => continue,
};
let q = match authors[candidate].wikidata_item() {
Some(q) => q,
None => continue,
};
if p50.iter().any(|s| s == q) || used_candidates.contains(&candidate) {
Self::remove_p2093_statement(statement);
} else if let Some(p50_statement) = &authors[candidate].generate_author_statement() {
Self::replace_p2093_with_p50_in_place(p50_statement, statement);
used_candidates.insert(candidate);
}
}
Self::remove_statements_with_no_value(item);
}
pub fn create_or_update_author_statements(
&mut self,
item: &mut Entity,
authors: &Vec<GenericAuthorInfo>,
) {
if !item.has_claims_with_property("P50") && !item.has_claims_with_property("P2093") {
self.create_author_statements(authors, item);
} else {
self.update_author_statements(authors, item);
}
}
#[allow(clippy::ptr_arg)]
fn merge_authors(
&self,
authors: &mut Vec<GenericAuthorInfo>,
authors2: &Vec<GenericAuthorInfo>,
) {
if authors.is_empty() {
*authors = authors2.clone();
return;
}
for author in authors2.iter() {
match author.find_best_match(authors) {
Some((candidate, _points)) => match authors[candidate].merge_from(author) {
Ok(_) => {},
Err(e) => tracing::warn!(?author, error = %e, "merge_authors: merge_from failed"),
},
None => {
if !author.has_partial_match(authors) {
authors.push(author.clone());
}
},
}
}
}
pub async fn update_item_from_adapters(
&mut self,
item: &mut Entity,
adapter2work_id: &mut HashMap<usize, String>,
mw_api: Arc<RwLock<Api>>,
) -> Result<()> {
let mut authors: Vec<GenericAuthorInfo> = vec![];
for adapter_id in 0..self.adapters.len() {
let publication_id =
match self.adapters[adapter_id].publication_id_from_item(item).await {
Some(id) => id,
_ => continue,
};
let adapter = &mut self.adapters[adapter_id];
adapter2work_id.insert(adapter_id, publication_id.clone());
adapter
.update_statements_for_publication_id_default(
&publication_id,
item,
)
.await;
adapter.update_statements_for_publication_id(&publication_id, item).await;
let authors2 = adapter.get_author_list(&publication_id).await;
self.merge_authors(&mut authors, &authors2);
}
if !item.has_claims_with_property("P31") {
let work_type_q = adapter2work_id
.iter()
.find_map(|(adapter_id, pub_id)| self.adapters[*adapter_id].get_work_type(pub_id))
.unwrap_or_else(|| "Q13442814".to_string()); item.add_claim(Statement::new_normal(
Snak::new_item("P31", &work_type_q),
vec![],
vec![],
));
}
let authors = tokio::task::spawn_blocking(move || {
GenericAuthorInfo::deduplicate(&mut authors);
authors
})
.await?;
let mut futures = vec![];
for author in &authors {
let future =
author.get_or_create_author_item(mw_api.clone(), self.cache.clone(), false);
futures.push(future);
}
let new_authors = futures::future::join_all(futures).await;
self.update_author_items(&new_authors, mw_api.clone()).await;
self.create_or_update_author_statements(item, &new_authors);
Ok(())
}
pub async fn update_author_items(
&mut self,
authors: &Vec<GenericAuthorInfo>,
mw_api: Arc<RwLock<Api>>,
) {
let qs: Vec<String> =
authors.iter().filter_map(|a| a.wikidata_item().map(str::to_string)).collect();
if qs.is_empty() {
return;
}
let api = mw_api.read().await;
if self.entities.load_entities(&api, &qs).await.is_err() {
return;
}
drop(api);
for author in authors {
author.update_author_item(&mut self.entities, mw_api.clone()).await;
}
}
fn update_item_with_ids(&self, item: &mut wikibase::Entity, ids: &Vec<GenericWorkIdentifier>) {
for id in ids {
let prop = match id.work_type() {
GenericWorkType::Property(prop) => prop.to_owned(),
_ => continue,
};
if item.has_claims_with_property(prop.as_str()) {
continue;
}
let id2statement = self
.adapters
.iter()
.filter(|adapter| adapter.publication_property().as_ref() == Some(&prop))
.find_map(|adapter| adapter.publication_id_for_statement(id.id()));
if let Some(id) = id2statement {
item.add_claim(Statement::new_normal(
Snak::new_external_id(prop.as_str(), &id),
vec![],
vec![],
))
}
}
}
pub async fn create_or_update_item_from_ids(
&mut self,
mw_api: Arc<RwLock<Api>>,
ids: &Vec<GenericWorkIdentifier>,
) -> Result<Option<EditResult>> {
if ids.is_empty() {
return Ok(None);
}
let items = match self.testing {
true => vec![],
false => self.get_items_for_ids(ids).await,
};
self.create_or_update_item_from_items(mw_api, ids, &items).await
}
pub async fn create_or_update_item_from_q(
&mut self,
mw_api: Arc<RwLock<Api>>,
q: &str,
) -> Result<Option<EditResult>> {
let items = vec![q.to_owned()];
self.create_or_update_item_from_items(mw_api, &vec![], &items).await
}
fn new_publication_item(&self) -> Entity {
Entity::new_empty_item()
}
async fn create_or_update_item_from_items(
&mut self,
mw_api: Arc<RwLock<Api>>,
ids: &Vec<GenericWorkIdentifier>,
items: &[String],
) -> Result<Option<EditResult>> {
let mut item: wikibase::Entity;
let mut original_item = Entity::new_empty_item();
match items.first() {
Some(q) => {
let api = mw_api.read().await;
item = self
.entities
.load_entity(&api, q.clone())
.await
.with_context(|| format!("load_entity({q})"))?
.to_owned();
drop(api);
original_item = item.clone();
},
None => item = self.new_publication_item(),
}
self.update_item_with_ids(&mut item, ids);
let mut adapter2work_id = HashMap::new();
self.update_item_from_adapters(&mut item, &mut adapter2work_id, mw_api.clone())
.await?;
if item.claims().len() < 4 {
tracing::warn!(?ids, claim_count = item.claims().len(), "skipping: too few claims to write");
return Ok(None);
}
self.apply_diff_for_item(original_item, item, mw_api).await
}
pub async fn apply_diff_for_item(
&mut self,
original_item: Entity,
item: Entity,
mw_api: Arc<RwLock<Api>>,
) -> Result<Option<EditResult>> {
let mut params = EntityDiffParams::none();
params.labels.add = EntityDiffParamState::All;
params.aliases.add = EntityDiffParamState::All;
params.claims.add = EntityDiffParamState::All;
params.claims.remove = EntityDiffParamState::some(&vec!["P2093"]);
params.references.list =
vec![(EntityDiffParamState::All, EntityDiffParamState::except(&vec!["P813"]))];
let mut diff = EntityDiff::new(&original_item, &item, ¶ms);
diff.set_edit_summary(self.edit_summary.to_owned());
if diff.is_empty() {
return Ok(match original_item.id().as_str() {
"" => None,
id => Some(EditResult { q: id.to_string(), edited: false }),
});
}
if self.testing {
println!("{}", diff.to_string_pretty().unwrap());
Ok(None)
} else {
let new_json = {
let mut api = mw_api.write().await;
diff.apply_diff(&mut api, &diff)
.await
.map_err(|e| anyhow!("apply_diff_for_item: apply_diff failed: {e}"))?
};
match EntityDiff::get_entity_id(&new_json) {
Some(q) => Ok(Some(EditResult { q, edited: true })),
None => Ok(None),
}
}
}
pub async fn update_from_paper_ids(
&mut self,
original_ids: &[GenericWorkIdentifier],
) -> Vec<GenericWorkIdentifier> {
let mut ids: HashSet<GenericWorkIdentifier> = HashSet::new();
original_ids.iter().filter(|id| id.is_legit()).for_each(|id| {
ids.insert(id.to_owned());
});
for _ in 0..MAX_ID_DISCOVERY_PASSES {
let last_id_size = ids.len();
for adapter_id in 0..self.adapters.len() {
let adapter = &mut self.adapters[adapter_id];
let vids: Vec<GenericWorkIdentifier> = ids.iter().cloned().collect();
adapter
.get_identifier_list(&vids)
.await
.iter()
.filter(|id| id.is_legit())
.for_each(|id| {
ids.insert(id.clone());
});
}
if last_id_size == ids.len() {
break;
}
}
ids.iter().filter(|id| id.is_legit()).cloned().collect()
}
pub async fn get_items_for_ids(&self, ids: &Vec<GenericWorkIdentifier>) -> Vec<String> {
let mut items: Vec<String> = vec![];
for id in ids {
let r = match id.work_type() {
GenericWorkType::Property(prop) => self.cache.get(prop.as_str(), id.id()).await,
GenericWorkType::Item => Some(id.id().to_owned()),
};
if let Some(q) = r {
items.push(q)
}
}
tokio::task::spawn_blocking(move || {
items.sort();
items.dedup();
items
})
.await
.unwrap_or_default()
}
pub fn entities_mut(&mut self) -> &mut entity_container::EntityContainer {
&mut self.entities
}
fn get_p50s_from_item(item: &mut Entity) -> Vec<String> {
item.claims()
.par_iter()
.filter(|statement| statement.property() == "P50")
.filter_map(|statement| match statement.main_snak().data_value().as_ref()?.value() {
Value::Entity(entity) => Some(entity.id().to_string()),
_ => None,
})
.collect()
}
fn merge_qualifiers_with_p2093_precedence(
p2093_statement: &Statement,
p50_statement: &Statement,
) -> Vec<Snak> {
let p2093_props: HashSet<&str> =
p2093_statement.qualifiers().iter().map(|q| q.property()).collect();
let mut merged: Vec<Snak> = p2093_statement.qualifiers().to_vec();
for q in p50_statement.qualifiers() {
if q.property() != "P1932" && !p2093_props.contains(q.property()) {
merged.push(q.clone());
}
}
if let Some(dv) = p2093_statement.main_snak().data_value() {
if let Value::StringValue(author_name_string) = dv.value() {
merged.push(Snak::new_string("P1932", author_name_string));
}
}
merged
}
fn replace_p2093_with_p50_in_place(
p50_statement: &Statement,
p2093_statement: &mut Statement,
) {
let merged_qualifiers =
Self::merge_qualifiers_with_p2093_precedence(p2093_statement, p50_statement);
let references = p2093_statement.references().to_owned();
*p2093_statement = Statement::new_normal(
p50_statement.main_snak().clone(),
merged_qualifiers,
references,
);
}
fn remove_p2093_statement(p2093_statement: &mut Statement) {
p2093_statement.set_main_snak(SNAK_REMOVE_STATEMENT.to_owned());
}
fn remove_statements_with_no_value(item: &mut Entity) {
item.claims_mut()
.retain(|statement| *statement.main_snak() != *SNAK_REMOVE_STATEMENT);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_p2093(name: &str, ordinal: &str) -> Statement {
Statement::new_normal(
Snak::new_string("P2093", name),
vec![Snak::new_string("P1545", ordinal)],
vec![],
)
}
async fn make_wdp() -> WikidataPapers {
use wiremock::{
matchers::{method, query_param},
Mock, MockServer, ResponseTemplate,
};
const SITEINFO: &str = include_str!("../test_data/api_siteinfo.json");
let mock_server = MockServer::start().await;
Mock::given(method("GET"))
.and(query_param("meta", "siteinfo"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-type", "application/json; charset=utf-8")
.set_body_string(SITEINFO),
)
.mount(&mock_server)
.await;
let mw_api = Arc::new(tokio::sync::RwLock::new(
wikibase::mediawiki::api::Api::new(&mock_server.uri()).await.unwrap(),
));
let cache = Arc::new(WikidataStringCache::new(mw_api));
WikidataPapers::new(cache)
}
fn get_string_qualifier(statement: &Statement, property: &str) -> Option<String> {
statement.qualifiers().iter().find_map(|q| {
if q.property() != property {
return None;
}
match q.data_value().as_ref()?.value() {
Value::StringValue(s) => Some(s.to_string()),
_ => None,
}
})
}
fn get_ordinal(statement: &Statement) -> Option<String> {
get_string_qualifier(statement, "P1545")
}
#[tokio::test]
async fn update_author_statements_preserves_p2093_ordinal() {
let wdp = make_wdp().await;
let mut item = Entity::new_empty_item();
item.add_claim(make_p2093("Giorgio Alimonti", "42"));
let mut adapter_author = GenericAuthorInfo::new();
adapter_author.set_name(Some("Giorgio Alimonti".to_string()));
adapter_author.set_wikidata_item(Some("Q64863661".to_string()));
adapter_author.set_list_number(Some("44".to_string()));
wdp.update_author_statements(&[adapter_author], &mut item);
assert_eq!(item.claims().len(), 1, "Expected exactly one claim");
let claim = &item.claims()[0];
assert_eq!(claim.main_snak().property(), "P50", "Should be P50");
let ordinal = get_ordinal(claim);
assert_eq!(
ordinal,
Some("42".to_string()),
"Ordinal should be preserved from the original P2093 statement, not taken from adapter"
);
}
#[tokio::test]
async fn update_author_statements_prevents_duplicate_p50() {
let wdp = make_wdp().await;
let mut item = Entity::new_empty_item();
item.add_claim(make_p2093("Giorgio Alimonti", "42"));
item.add_claim(make_p2093("Alimonti Giorgio", "44"));
let mut adapter_author = GenericAuthorInfo::new();
adapter_author.set_name(Some("Giorgio Alimonti".to_string()));
adapter_author.set_wikidata_item(Some("Q64863661".to_string()));
adapter_author.set_list_number(Some("42".to_string()));
wdp.update_author_statements(&[adapter_author], &mut item);
let p50_count = item
.claims()
.iter()
.filter(|s| s.property() == "P50")
.filter(|s| match s.main_snak().data_value() {
Some(dv) => matches!(dv.value(), Value::Entity(e) if e.id() == "Q64863661"),
None => false,
})
.count();
assert!(
p50_count <= 1,
"Same Q-item should not appear in multiple P50 statements, got {}",
p50_count
);
}
#[tokio::test]
async fn update_author_statements_no_conflicting_ordinals() {
let wdp = make_wdp().await;
let mut item = Entity::new_empty_item();
item.add_claim(make_p2093("Jahred Adelman", "15"));
item.add_claim(make_p2093("Stephanie Zimmermann", "2879"));
let mut author1 = GenericAuthorInfo::new();
author1.set_name(Some("Jahred Adelman".to_string()));
author1.set_wikidata_item(Some("Q100001".to_string()));
author1.set_list_number(Some("15".to_string()));
let mut author2 = GenericAuthorInfo::new();
author2.set_name(Some("Stephanie Zimmermann".to_string()));
author2.set_wikidata_item(Some("Q100002".to_string()));
author2.set_list_number(Some("15".to_string()));
wdp.update_author_statements(&[author1, author2], &mut item);
let ordinals: Vec<String> = item.claims().iter().filter_map(get_ordinal).collect();
let mut unique = ordinals.clone();
unique.sort();
unique.dedup();
assert_eq!(
ordinals.len(),
unique.len(),
"Ordinals should be unique, but got duplicates: {:?}",
ordinals
);
}
fn get_named_as(statement: &Statement) -> Option<String> {
get_string_qualifier(statement, "P1932")
}
#[tokio::test]
async fn update_author_statements_p1932_preserves_original_name() {
let wdp = make_wdp().await;
let mut item = Entity::new_empty_item();
item.add_claim(make_p2093("José GarcÃa", "5"));
let mut adapter_author = GenericAuthorInfo::new();
adapter_author.set_name(Some("Jose Garcia".to_string())); adapter_author.set_wikidata_item(Some("Q12345".to_string()));
adapter_author.set_list_number(Some("5".to_string()));
wdp.update_author_statements(&[adapter_author], &mut item);
assert_eq!(item.claims().len(), 1);
let claim = &item.claims()[0];
assert_eq!(claim.main_snak().property(), "P50");
let named_as = get_named_as(claim);
assert_eq!(
named_as,
Some("José GarcÃa".to_string()),
"P1932 should preserve the original author name string from Wikidata"
);
}
#[tokio::test]
async fn update_author_statements_p1932_preserves_original_name_transliteration() {
let wdp = make_wdp().await;
let mut item = Entity::new_empty_item();
item.add_claim(make_p2093("Smith, John A.", "1"));
let mut adapter_author = GenericAuthorInfo::new();
adapter_author.set_name(Some("John Smith".to_string())); adapter_author.set_wikidata_item(Some("Q99999".to_string()));
adapter_author.set_list_number(Some("1".to_string()));
wdp.update_author_statements(&[adapter_author], &mut item);
assert_eq!(item.claims().len(), 1);
let claim = &item.claims()[0];
assert_eq!(claim.main_snak().property(), "P50");
let named_as = get_named_as(claim);
assert_eq!(
named_as,
Some("Smith, John A.".to_string()),
"P1932 should preserve the original author name string from Wikidata"
);
}
#[tokio::test]
async fn new_publication_item_has_no_p31() {
let wdp = make_wdp().await;
let item = wdp.new_publication_item();
assert!(
!item.has_claims_with_property("P31"),
"new_publication_item should not set P31; it should be set later based on work type"
);
}
struct FakeGrowingAdapter {
counter: u64,
author_cache: HashMap<String, String>,
}
#[async_trait::async_trait(?Send)]
impl crate::scientific_publication_adapter::ScientificPublicationAdapter for FakeGrowingAdapter {
fn name(&self) -> &str {
"FakeGrowingAdapter"
}
fn author_cache(&self) -> &HashMap<String, String> {
&self.author_cache
}
fn author_cache_mut(&mut self) -> &mut HashMap<String, String> {
&mut self.author_cache
}
async fn update_statements_for_publication_id(&self, _: &str, _: &mut Entity) {}
async fn get_identifier_list(
&mut self,
_ids: &[GenericWorkIdentifier],
) -> Vec<GenericWorkIdentifier> {
let n = self.counter;
self.counter += 1;
vec![GenericWorkIdentifier::new_prop(
crate::identifiers::IdProp::DOI,
&format!("10.0/test{n}"),
)]
}
}
#[tokio::test]
async fn update_from_paper_ids_terminates_with_misbehaving_adapter() {
let mut wdp = make_wdp().await;
wdp.add_adapter(Box::new(FakeGrowingAdapter {
counter: 0,
author_cache: HashMap::new(),
}));
let initial = vec![GenericWorkIdentifier::new_prop(
crate::identifiers::IdProp::DOI,
"10.0/initial",
)];
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
wdp.update_from_paper_ids(&initial),
)
.await;
let ids = result.expect("update_from_paper_ids must terminate even with a growing adapter");
assert!(
ids.len() <= 1 + MAX_ID_DISCOVERY_PASSES,
"expected at most {} ids, got {}",
1 + MAX_ID_DISCOVERY_PASSES,
ids.len()
);
assert!(!ids.is_empty(), "expected at least the initial id to survive");
}
#[tokio::test]
async fn update_from_paper_ids_with_no_adapters_returns_legit_inputs() {
let mut wdp = make_wdp().await;
let initial =
vec![GenericWorkIdentifier::new_prop(crate::identifiers::IdProp::DOI, "10.0/a")];
let ids = wdp.update_from_paper_ids(&initial).await;
assert_eq!(ids.len(), 1);
assert_eq!(ids[0].id(), "10.0/A");
}
#[tokio::test]
async fn update_from_paper_ids_filters_non_legit_inputs() {
let mut wdp = make_wdp().await;
let initial = vec![
GenericWorkIdentifier::new_prop(crate::identifiers::IdProp::DOI, "10.0/legit"),
GenericWorkIdentifier::new_prop(crate::identifiers::IdProp::DOI, ""),
GenericWorkIdentifier::new_prop(crate::identifiers::IdProp::DOI, "0"),
];
let ids = wdp.update_from_paper_ids(&initial).await;
assert_eq!(ids.len(), 1, "expected only the legit DOI to survive; got {ids:?}");
assert_eq!(ids[0].id(), "10.0/LEGIT");
}
fn author_named(name: &str, list_num: usize) -> GenericAuthorInfo {
GenericAuthorInfo::new_from_name_num(name, list_num)
}
#[tokio::test]
async fn merge_authors_shortcut_when_existing_is_empty() {
let wdp = make_wdp().await;
let mut authors = vec![];
let authors2 = vec![author_named("Alice Smith", 1), author_named("Bob Jones", 2)];
wdp.merge_authors(&mut authors, &authors2);
assert_eq!(authors.len(), 2);
assert_eq!(authors[0].name(), Some("Alice Smith"));
assert_eq!(authors[1].name(), Some("Bob Jones"));
}
#[tokio::test]
async fn merge_authors_appends_when_no_overlap() {
let wdp = make_wdp().await;
let mut authors = vec![author_named("Alice Smith", 1)];
let authors2 = vec![author_named("Bob Jones", 2)];
wdp.merge_authors(&mut authors, &authors2);
assert_eq!(authors.len(), 2);
assert!(authors.iter().any(|a| a.name() == Some("Bob Jones")));
}
#[tokio::test]
async fn merge_authors_merges_when_best_match_found() {
let wdp = make_wdp().await;
let mut a1 = author_named("Alice Smith", 1);
let mut a2 = author_named("Alice Smith", 1);
a2.prop2id_mut().insert("P496".to_string(), "0000-0001-2345-6789".to_string());
let mut authors = vec![a1.clone()];
let authors2 = vec![a2];
wdp.merge_authors(&mut authors, &authors2);
assert_eq!(authors.len(), 1, "exact-match merge must not duplicate");
assert_eq!(
authors[0].prop2id().get("P496"),
Some(&"0000-0001-2345-6789".to_string()),
"ORCID from author2 should have merged into authors[0]"
);
let _ = &mut a1;
}
#[tokio::test]
async fn merge_authors_skips_partial_match_to_avoid_duplicates() {
let wdp = make_wdp().await;
let mut a1 = author_named("Alice Smith", 1);
a1.prop2id_mut().insert("P496".to_string(), "0000-0001-2345-6789".to_string());
let mut a2 = author_named("A. Smith", 5);
a2.prop2id_mut().insert("P496".to_string(), "0000-0001-2345-6789".to_string());
let mut authors = vec![a1];
let authors2 = vec![a2];
wdp.merge_authors(&mut authors, &authors2);
assert!(
authors.len() <= 1 || authors.iter().filter(|a| a.prop2id().get("P496").is_some()).count() <= 1,
"ORCID-sharing authors must not appear twice; got {authors:?}",
);
}
#[tokio::test]
async fn create_author_statements_dedups_by_wikidata_item() {
let mut wdp = make_wdp().await;
let mut a1 = author_named("Alice Smith", 1);
a1.set_wikidata_item(Some("Q100".to_string()));
let mut a2 = author_named("A. Smith", 2);
a2.set_wikidata_item(Some("Q100".to_string()));
let mut item = Entity::new_empty_item();
wdp.create_author_statements(&vec![a1, a2], &mut item);
let p50_count =
item.claims().iter().filter(|s| s.main_snak().property() == "P50").count();
assert_eq!(p50_count, 1, "duplicate Q-item must produce only one P50");
}
#[tokio::test]
async fn create_author_statements_dedups_by_name_when_no_item() {
let mut wdp = make_wdp().await;
let a1 = author_named("Alice Smith", 1);
let a2 = author_named("alice smith", 2);
let mut item = Entity::new_empty_item();
wdp.create_author_statements(&vec![a1, a2], &mut item);
let p2093_count =
item.claims().iter().filter(|s| s.main_snak().property() == "P2093").count();
assert_eq!(p2093_count, 1, "duplicate simplified-name must produce only one P2093");
}
#[tokio::test]
async fn create_author_statements_distinct_authors_both_produce_statements() {
let mut wdp = make_wdp().await;
let mut a1 = author_named("Alice Smith", 1);
a1.set_wikidata_item(Some("Q100".to_string()));
let a2 = author_named("Bob Jones", 2);
let mut item = Entity::new_empty_item();
wdp.create_author_statements(&vec![a1, a2], &mut item);
let p50_count =
item.claims().iter().filter(|s| s.main_snak().property() == "P50").count();
let p2093_count =
item.claims().iter().filter(|s| s.main_snak().property() == "P2093").count();
assert_eq!(p50_count, 1, "Alice's Q-item should produce a P50");
assert_eq!(p2093_count, 1, "Bob's nameless author should produce a P2093");
}
#[tokio::test]
async fn create_author_statements_item_and_nameless_dup_with_same_simplified_name() {
let mut wdp = make_wdp().await;
let mut a1 = author_named("Alice Smith", 1);
a1.set_wikidata_item(Some("Q100".to_string()));
let a2 = author_named("Alice Smith", 2);
let mut item = Entity::new_empty_item();
wdp.create_author_statements(&vec![a1, a2], &mut item);
let p50_count =
item.claims().iter().filter(|s| s.main_snak().property() == "P50").count();
let p2093_count =
item.claims().iter().filter(|s| s.main_snak().property() == "P2093").count();
assert_eq!(p50_count, 1);
assert_eq!(p2093_count, 1, "nameless author with same name as a Q-item author still gets a P2093");
}
}