use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use commonmeta::Person;
use serde_json::{Value, json};
use crate::app::AppState;
use crate::countries;
use crate::error::AppError;
use crate::i18n::Lang;
use crate::store;
pub const PAGE_SIZE: usize = 10;
pub const FEATURED_MAX: usize = 10;
pub const EXPORT_MAX: usize = 10_000;
pub const FACET_VALUES: usize = 10;
pub const SEARCH_MAX: usize = 200;
pub const FACET_PICKS_MAX: usize = 20;
const URLS_MAX: usize = 5;
const PERSON_TTL: Duration = Duration::from_secs(3600);
const PERSON_RETRY: Duration = Duration::from_secs(300);
const PERSON_TIMEOUT: Duration = Duration::from_secs(15);
const PEOPLE_MAX: usize = 256;
pub const OWNER_KEY: &str = "profile_owner_orcid";
pub const HIDDEN_KEY: &str = "profile_hidden";
pub fn normalize_orcid(input: &str) -> Option<String> {
commonmeta::utils::validate_orcid(input.trim()).map(|id| id.replace(' ', "-"))
}
#[derive(Default)]
pub struct PersonCache {
slots: Mutex<HashMap<String, PersonSlot>>,
fetching: tokio::sync::Mutex<()>,
}
#[derive(Default)]
struct PersonSlot {
person: Option<Arc<Person>>,
next_fetch: Option<Instant>,
}
impl PersonCache {
fn slots(&self) -> std::sync::MutexGuard<'_, HashMap<String, PersonSlot>> {
self.slots.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn set(&self, orcid: &str, person: Person) -> Arc<Person> {
let person = Arc::new(person);
let mut slots = self.slots();
slots.insert(
orcid.to_string(),
PersonSlot {
person: Some(person.clone()),
next_fetch: Some(Instant::now() + PERSON_TTL),
},
);
while slots.len() > PEOPLE_MAX {
let Some(soonest) = slots
.iter()
.min_by_key(|(_, slot)| slot.next_fetch)
.map(|(orcid, _)| orcid.clone())
else {
break;
};
slots.remove(&soonest);
}
person
}
fn fetch_failed(&self, orcid: &str) {
self.slots()
.entry(orcid.to_string())
.or_default()
.next_fetch = Some(Instant::now() + PERSON_RETRY);
}
fn settled(&self, orcid: &str) -> Option<Option<Arc<Person>>> {
let slots = self.slots();
let slot = slots.get(orcid)?;
let due = slot.next_fetch.is_none_or(|at| Instant::now() >= at);
(!due).then(|| slot.person.clone())
}
fn stale(&self, orcid: &str) -> Option<Arc<Person>> {
self.slots().get(orcid).and_then(|slot| slot.person.clone())
}
pub fn cached(&self, orcid: &str) -> (Option<Arc<Person>>, bool) {
(self.stale(orcid), self.settled(orcid).is_none())
}
}
fn unavailable(orcid: &str) -> AppError {
AppError::Upstream(format!("ORCID record {orcid} is not available"))
}
pub async fn person(app: &AppState, orcid: &str) -> Result<Arc<Person>, AppError> {
if let Some(person) = app.person.settled(orcid) {
return person.ok_or_else(|| unavailable(orcid));
}
let _fetching = app.person.fetching.lock().await;
if let Some(person) = app.person.settled(orcid) {
return person.ok_or_else(|| unavailable(orcid));
}
let id = orcid.to_string();
let fetch = tokio::task::spawn_blocking(move || commonmeta::fetch_orcid(&id));
let error = match tokio::time::timeout(PERSON_TIMEOUT, fetch).await {
Ok(Ok(Ok(person))) => return Ok(app.person.set(orcid, person)),
Ok(Ok(Err(error))) => error.to_string(),
Ok(Err(error)) => format!("blocking task: {error}"),
Err(_) => "timed out".to_string(),
};
tracing::warn!(orcid, %error, "fetching the ORCID record");
app.person.fetch_failed(orcid);
app.person.stale(orcid).ok_or_else(|| unavailable(orcid))
}
pub struct WorkTerms(Vec<(String, Option<String>, Option<String>)>);
impl WorkTerms {
pub fn new(rows: Vec<store::WorkTerms>) -> Self {
let mut languages: HashMap<String, Option<String>> = HashMap::new();
let mut licenses: HashMap<String, Option<String>> = HashMap::new();
WorkTerms(
rows.into_iter()
.map(|row| {
let language = languages
.entry(row.language)
.or_insert_with_key(|text| crate::vocab::language(text))
.clone();
let license = licenses
.entry(row.rights)
.or_insert_with_key(|text| crate::vocab::license(text))
.clone();
(row.key, language, license)
})
.collect(),
)
}
fn counts<'a>(values: impl Iterator<Item = &'a str>) -> Vec<(String, usize)> {
let mut counts: HashMap<&str, usize> = HashMap::new();
for value in values {
*counts.entry(value).or_default() += 1;
}
let mut counts: Vec<(String, usize)> = counts
.into_iter()
.map(|(value, n)| (value.to_string(), n))
.collect();
counts.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
counts.truncate(FACET_VALUES);
counts
}
pub fn languages(&self) -> Vec<(String, usize)> {
Self::counts(self.0.iter().filter_map(|(_, l, _)| l.as_deref()))
}
pub fn licenses(&self) -> Vec<(String, usize)> {
Self::counts(self.0.iter().filter_map(|(_, _, l)| l.as_deref()))
}
pub fn keys(&self, languages: &[String], licenses: &[String]) -> Option<Arc<Vec<String>>> {
if languages.is_empty() && licenses.is_empty() {
return None;
}
let among = |picked: &[String], value: &Option<String>| {
picked.is_empty() || value.as_ref().is_some_and(|v| picked.contains(v))
};
Some(Arc::new(
self.0
.iter()
.filter(|(_, language, license)| {
among(languages, language) && among(licenses, license)
})
.map(|(key, _, _)| key.clone())
.collect(),
))
}
}
pub fn person_name(person: &Person) -> Option<String> {
let name = if person.name.trim().is_empty() {
[person.given_name.trim(), person.family_name.trim()]
.iter()
.filter(|s| !s.is_empty())
.copied()
.collect::<Vec<_>>()
.join(" ")
} else {
person.name.trim().to_string()
};
(!name.is_empty()).then_some(name)
}
pub struct Link {
pub name: String,
pub url: String,
}
pub struct AffiliationView {
pub name: String,
pub ror: Option<String>,
pub role: String,
pub dates: String,
}
pub struct PersonView {
pub orcid_url: String,
pub name: String,
pub given_name: String,
pub family_name: String,
pub additional_names: String,
pub description: String,
pub summary: String,
pub urls: Vec<Link>,
pub country: String,
pub affiliations: Vec<AffiliationView>,
pub json: String,
}
impl PersonView {
pub fn new(orcid: &str, person: &Person, lang: Lang) -> Self {
let name = person_name(person).unwrap_or_default();
let description = person
.description
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let country = person.country.trim();
PersonView {
orcid_url: format!("https://orcid.org/{orcid}"),
name: if name.is_empty() {
orcid.to_string()
} else {
name
},
given_name: person.given_name.clone(),
family_name: person.family_name.clone(),
additional_names: person.additional_names.join(" · "),
summary: summary(&description, 200),
description,
urls: person
.urls
.iter()
.filter(|u| web_url(&u.url))
.take(URLS_MAX)
.map(|u| Link {
name: if u.name.is_empty() {
u.url.clone()
} else {
u.name.clone()
},
url: u.url.clone(),
})
.collect(),
country: countries::name(&country.to_ascii_uppercase(), lang)
.unwrap_or(country)
.to_string(),
affiliations: person
.affiliations
.iter()
.filter(|a| !a.name.is_empty())
.map(|a| AffiliationView {
name: a.name.clone(),
ror: a
.identifier
.starts_with("https://ror.org/")
.then(|| a.identifier.clone()),
role: a.role.clone(),
dates: lang.date_range(&a.start_date, &a.end_date),
})
.collect(),
json: serde_json::to_string_pretty(person).unwrap_or_default(),
}
}
}
pub struct ExportFormat {
pub value: &'static str,
pub label: Option<&'static str>,
extension: &'static str,
content_type: &'static str,
}
const fn export_format(
value: &'static str,
label: Option<&'static str>,
extension: &'static str,
content_type: &'static str,
) -> ExportFormat {
ExportFormat {
value,
label,
extension,
content_type,
}
}
const JSON: &str = "application/json";
const XML: &str = "application/xml";
const TEXT: &str = "text/plain; charset=utf-8";
pub const EXPORT_FORMATS: [ExportFormat; 11] = [
export_format("bibtex", Some("BibTeX"), "bib", TEXT),
export_format("commonmeta", Some("Commonmeta"), "json", JSON),
export_format("crossref", Some("Crossref"), "json", JSON),
export_format("crossref_xml", Some("Crossref XML"), "xml", XML),
export_format("csl", Some("CSL-JSON"), "json", JSON),
export_format("datacite", Some("DataCite JSON"), "json", JSON),
export_format("datacite_xml", Some("DataCite XML"), "xml", XML),
export_format(CITATION, None, "txt", TEXT),
export_format("inveniordm", Some("InvenioRDM"), "json", JSON),
export_format("ris", Some("RIS"), "ris", TEXT),
export_format("schemaorg", Some("Schema.org"), "json", JSON),
];
pub const CITATION: &str = "citation";
pub const DEFAULT_EXPORT_FORMAT: &str = "commonmeta";
pub const CITATION_STYLES: [(&str, &str); 6] = [
("apa", "American Psychological Association 7th edition"),
(
"chicago-author-date",
"Chicago Manual of Style 18th Edition (Author-Date)",
),
(
"harvard-cite-them-right",
"Cite Them Right 12th Edition (Harvard)",
),
("ieee", "IEEE"),
("modern-language-association", "MLA Handbook 9th Edition"),
("vancouver", "Vancouver"),
];
impl ExportFormat {
pub fn find(value: &str) -> Option<&'static ExportFormat> {
EXPORT_FORMATS.iter().find(|f| f.value == value)
}
pub fn content_type(&self) -> &'static str {
self.content_type
}
pub fn filename(&self, orcid: &str) -> String {
format!("{orcid}.{}", self.extension)
}
}
fn csl_type(item_type: &str) -> &'static str {
match item_type {
"artwork" => "graphic",
"audioRecording" => "song",
"bill" => "bill",
"blogPost" => "post-weblog",
"book" => "book",
"bookSection" => "chapter",
"case" => "legal_case",
"computerProgram" => "software",
"conferencePaper" => "paper-conference",
"dataset" => "dataset",
"dictionaryEntry" => "entry-dictionary",
"email" | "instantMessage" | "letter" => "personal_communication",
"encyclopediaArticle" => "entry-encyclopedia",
"film" | "videoRecording" => "motion_picture",
"forumPost" => "post",
"hearing" => "hearing",
"interview" => "interview",
"journalArticle" => "article-journal",
"magazineArticle" => "article-magazine",
"manuscript" => "manuscript",
"map" => "map",
"newspaperArticle" => "article-newspaper",
"patent" => "patent",
"podcast" | "radioBroadcast" | "tvBroadcast" => "broadcast",
"preprint" => "article",
"presentation" => "speech",
"report" => "report",
"standard" => "standard",
"statute" => "legislation",
"thesis" => "thesis",
"webpage" => "webpage",
_ => "document",
}
}
fn csl_item(data: &Value) -> Value {
let names = |creator_type: &str| -> Vec<Value> {
data.get("creators")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter(|c| text(c, "creatorType") == creator_type)
.map(|c| match text(c, "name") {
"" => json!({"family": text(c, "lastName"), "given": text(c, "firstName")}),
full => json!({"literal": full}),
})
.collect()
};
let date_parts: Vec<i64> = text(data, "date")
.split(['-', '/'])
.take(3)
.map_while(|part| part.trim().parse().ok())
.collect();
let year = text(data, "date")
.split(|c: char| !c.is_ascii_digit())
.find(|part| part.len() == 4)
.and_then(|year| year.parse::<i64>().ok());
let issued = match (date_parts.first(), year) {
(Some(y), _) if *y > 999 => date_parts.clone(),
(_, Some(year)) => vec![year],
_ => Vec::new(),
};
let mut item = json!({
"id": match text(data, "DOI") {
"" => text(data, "key").to_string(),
doi => format!("https://doi.org/{doi}"),
},
"type": csl_type(text(data, "itemType")),
"title": text(data, "title"),
});
let fields = [
("DOI", text(data, "DOI")),
("URL", text(data, "url")),
(
"container-title",
CONTAINER_FIELDS
.iter()
.map(|field| text(data, field))
.find(|value| !value.is_empty())
.unwrap_or(""),
),
("publisher", text(data, "publisher")),
("abstract", text(data, "abstractNote")),
("language", text(data, "language")),
("volume", text(data, "volume")),
("issue", text(data, "issue")),
("page", text(data, "pages")),
("ISSN", text(data, "ISSN")),
("ISBN", text(data, "ISBN")),
];
for (field, value) in fields {
if !value.is_empty() {
item[field] = json!(value);
}
}
for (field, creator_type) in [("author", "author"), ("editor", "editor")] {
let names = names(creator_type);
if !names.is_empty() {
item[field] = json!(names);
}
}
if !issued.is_empty() {
item["issued"] = json!({"date-parts": [issued]});
}
item
}
pub fn export(
works: &[Value],
format: &ExportFormat,
style: &str,
lang: Lang,
) -> Result<Vec<u8>, String> {
let entities = works
.iter()
.map(|work| commonmeta::read("csl", &csl_item(work).to_string()))
.collect::<Result<Vec<_>, _>>()
.map_err(|e| e.to_string())?;
let bytes = commonmeta::write_list_citation(
&entities,
format.value,
Some(style),
Some(lang.citation_locale()),
)
.map_err(|e| e.to_string())?;
if format.value != CITATION {
return Ok(bytes);
}
let entries: Vec<String> = String::from_utf8_lossy(&bytes)
.lines()
.map(|line| decode_entities(&plain_text(line)))
.filter(|line| !line.is_empty())
.collect();
Ok(entries.join("\n\n").into_bytes())
}
fn cc_icons(rights: &str) -> Vec<&'static str> {
let rights = rights.to_ascii_uppercase();
let rights = rights
.strip_prefix("HTTPS://")
.or_else(|| rights.strip_prefix("HTTP://"))
.map_or(rights.as_str(), |url| url.trim_start_matches("WWW."));
let is_url = rights.starts_with("CREATIVECOMMONS.ORG/");
if !is_url && !rights.starts_with("CC") && !rights.starts_with("CREATIVE COMMONS") {
return Vec::new();
}
let words: Vec<&str> = rights.split(|c: char| !c.is_ascii_alphanumeric()).collect();
if !is_url && !matches!(words[0], "CC" | "CC0" | "CREATIVE") {
return Vec::new();
}
let has = |names: &[&str]| words.iter().any(|word| names.contains(word));
let mut icons = vec!["cc"];
if has(&["CC0", "ZERO"]) {
icons.push("zero");
} else {
for (icon, names) in [
("by", &["BY", "ATTRIBUTION"]),
("sa", &["SA", "SHAREALIKE"]),
("nc", &["NC", "NONCOMMERCIAL"]),
("nd", &["ND", "NODERIVATIVES"]),
] {
if has(names) {
icons.push(icon);
}
}
}
icons
}
fn cc_license(spdx: &str, lang: Lang) -> Option<(String, String)> {
if let Some(version) = spdx.strip_prefix("CC0-") {
let name = match lang {
Lang::En => format!("CC0 {version} Universal Public Domain Dedication"),
Lang::De => format!("Public-Domain-Widmung CC0 {version} Universell"),
};
let url = format!("https://creativecommons.org/publicdomain/zero/{version}/legalcode");
return Some((name, url));
}
let mut parts = spdx.strip_prefix("CC-")?.split('-');
let mut elements = Vec::new();
let version = loop {
match parts.next()? {
part @ ("BY" | "NC" | "SA" | "ND") => elements.push(part),
part if part.chars().all(|c| c.is_ascii_digit() || c == '.') => break part,
_ => return None,
}
};
let jurisdiction = parts.next();
if elements.is_empty() || version.is_empty() || parts.next().is_some() {
return None;
}
let current = version.starts_with('4');
let names: Vec<&str> = elements
.iter()
.map(|element| match (lang, *element) {
(Lang::En, "BY") => "Attribution",
(Lang::En, "NC") => "NonCommercial",
(Lang::En, "SA") => "ShareAlike",
(Lang::En, _) if current => "NoDerivatives",
(Lang::En, _) => "NoDerivs",
(Lang::De, "BY") => "Namensnennung",
(Lang::De, "NC") => "Nicht kommerziell",
(Lang::De, "SA") => "Weitergabe unter gleichen Bedingungen",
(Lang::De, _) if current => "Keine Bearbeitungen",
(Lang::De, _) => "Keine Bearbeitung",
})
.collect();
let place = jurisdiction
.map(|code| format!(" {}", countries::name(code, lang).unwrap_or(code)))
.unwrap_or_default();
let name = match lang {
Lang::En => format!(
"Creative Commons {} {version}{place} License",
names.join("-")
),
Lang::De => format!(
"Creative-Commons-Lizenz {} {version}{place}",
names.join(" – ")
),
};
let path = jurisdiction
.map(|code| format!("{}/", code.to_ascii_lowercase()))
.unwrap_or_default();
let url = format!(
"https://creativecommons.org/licenses/{}/{version}/{path}legalcode",
elements.join("-").to_ascii_lowercase()
);
Some((name, url))
}
fn cc_name(rights: &str) -> Option<String> {
let (_, path) = rights.split_once("creativecommons.org/")?;
let mut parts = path.split('/');
match (parts.next()?, parts.next()?, parts.next()?) {
("licenses", kind, version) if !version.is_empty() => {
Some(format!("CC {} {version}", kind.to_ascii_uppercase()))
}
("publicdomain", "zero", version) if !version.is_empty() => Some(format!("CC0 {version}")),
_ => None,
}
}
fn decode_entities(text: &str) -> String {
text.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace("&", "&")
}
pub struct WorkView {
pub key: String,
pub title: String,
pub link: Option<String>,
pub has_doi: bool,
pub authors: String,
pub date: String,
pub container: String,
pub kind: String,
pub language: String,
pub description: String,
pub pdf: Option<String>,
pub license: String,
pub is_license: bool,
pub license_url: Option<String>,
pub license_icons: Vec<&'static str>,
}
fn web_url(url: &str) -> bool {
url.starts_with("https://") || url.starts_with("http://")
}
fn text<'a>(data: &'a Value, field: &str) -> &'a str {
data.get(field).and_then(Value::as_str).unwrap_or("").trim()
}
const CONTAINER_FIELDS: &[&str] = &[
"publicationTitle",
"blogTitle",
"websiteTitle",
"bookTitle",
"proceedingsTitle",
"meetingName",
"repository",
"institution",
"university",
"publisher",
];
const AUTHORS_MAX: usize = 5;
impl WorkView {
pub fn new(data: &Value, lang: Lang) -> Self {
let doi = text(data, "DOI");
let url = text(data, "url");
let link = (!doi.is_empty())
.then(|| format!("https://doi.org/{doi}"))
.or_else(|| web_url(url).then(|| url.to_string()));
let creators: Vec<&Value> = data
.get("creators")
.and_then(Value::as_array)
.map(|a| a.iter().collect())
.unwrap_or_default();
let main_type = if creators.iter().any(|c| text(c, "creatorType") == "author") {
"author"
} else {
creators
.first()
.map(|c| text(c, "creatorType"))
.unwrap_or("")
};
let names: Vec<String> = creators
.iter()
.filter(|c| text(c, "creatorType") == main_type)
.map(|c| match text(c, "name") {
"" => format!("{} {}", text(c, "firstName"), text(c, "lastName"))
.trim()
.to_string(),
full => full.to_string(),
})
.filter(|n| !n.is_empty())
.collect();
let mut authors = names
.iter()
.take(AUTHORS_MAX)
.cloned()
.collect::<Vec<_>>()
.join(", ");
if names.len() > AUTHORS_MAX {
authors.push_str(" et al.");
}
let title = match text(data, "title") {
"" => "Untitled",
title => title,
}
.to_string();
let date = text(data, "date");
let container = CONTAINER_FIELDS
.iter()
.map(|field| text(data, field))
.find(|value| !value.is_empty())
.unwrap_or("")
.to_string();
let rights = text(data, "rights");
WorkView {
key: text(data, "key").to_string(),
title,
link,
has_doi: !doi.is_empty(),
authors,
date: lang.date(date),
container,
kind: lang.item_type(text(data, "itemType")),
language: lang.language(text(data, "language")),
description: plain_text(text(data, "abstractNote")),
pdf: None,
license: cc_name(rights).unwrap_or_else(|| rights.trim_end_matches('.').to_string()),
is_license: web_url(rights) || !cc_icons(rights).is_empty(),
license_url: web_url(rights).then(|| rights.to_string()),
license_icons: cc_icons(rights),
}
.with_cc_license(rights, lang)
}
fn with_cc_license(mut self, rights: &str, lang: Lang) -> Self {
let spdx = crate::vocab::license(rights.trim_end_matches('.'));
if let Some((name, url)) = spdx.as_deref().and_then(|id| cc_license(id, lang)) {
self.license = name;
self.is_license = true;
self.license_url = Some(url);
self.license_icons = cc_icons(spdx.as_deref().unwrap_or_default());
}
self
}
}
fn stored_copy(url: &str, public_url: &str) -> bool {
let host = |url: &str| -> String {
let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
let authority = rest.split(['/', '?', '#']).next().unwrap_or("");
let host = authority.rsplit('@').next().unwrap_or("");
host.split(':').next().unwrap_or("").to_ascii_lowercase()
};
let target = host(url);
let within = |domain: &str| target == domain || target.ends_with(&format!(".{domain}"));
target == host(public_url)
|| within("zotero.org")
|| within("your-objectstorage.com")
|| within("amazonaws.com")
|| within("r2.cloudflarestorage.com")
|| url.to_ascii_lowercase().contains("x-amz-signature=")
}
fn mining_url(url: &str) -> bool {
let url = url.to_ascii_lowercase();
["//api.", "/tdm/", "/chorus/", "text-and-data-mining"]
.iter()
.any(|part| url.contains(part))
}
fn preferred_pdf(own: &str, attached: &[String], public_url: &str) -> Option<String> {
let path = own.split(['?', '#']).next().unwrap_or("");
let own_is_pdf = path.to_ascii_lowercase().ends_with(".pdf");
let pdfs: Vec<&str> = attached
.iter()
.map(String::as_str)
.chain(own_is_pdf.then_some(own))
.filter(|url| web_url(url) && !stored_copy(url, public_url))
.collect();
pdfs.iter()
.find(|url| !mining_url(url))
.or(pdfs.first())
.map(|url| url.to_string())
}
pub fn work_pdfs(sources: &[store::PdfSources], public_url: &str) -> HashMap<String, String> {
sources
.iter()
.filter_map(|s| {
let pdf = preferred_pdf(s.url.trim(), &s.attached, public_url)?;
Some((s.key.clone(), pdf))
})
.collect()
}
pub(crate) fn plain_text(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(at) = rest.find('<') {
let (head, tail) = rest.split_at(at);
out.push_str(head);
let opens_tag = tail[1..]
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '/');
match tail.find('>').filter(|_| opens_tag) {
Some(end) => rest = &tail[end + 1..],
None => {
out.push('<');
rest = &tail[1..];
}
}
}
out.push_str(rest);
out.trim().to_string()
}
fn summary(text: &str, max: usize) -> String {
if text.chars().count() <= max {
return text.to_string();
}
let cut: String = text.chars().take(max).collect();
let cut = cut.rsplit_once(' ').map_or(cut.as_str(), |(head, _)| head);
format!("{}…", cut.trim_end_matches([',', ';', ':', '.']))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalizes_orcid_ids() {
assert_eq!(
normalize_orcid("https://orcid.org/0000-0003-1419-2405").as_deref(),
Some("0000-0003-1419-2405")
);
assert_eq!(
normalize_orcid("0000-0003-1419-2405").as_deref(),
Some("0000-0003-1419-2405")
);
assert_eq!(normalize_orcid("0000-0003-1419-2404"), None);
assert_eq!(normalize_orcid("1"), None);
}
#[test]
fn a_failed_fetch_is_not_retried_at_once() {
const ID: &str = "0000-0003-1419-2405";
let cache = PersonCache::default();
assert!(cache.settled(ID).is_none(), "nothing fetched yet");
cache.fetch_failed(ID);
assert!(matches!(cache.settled(ID), Some(None)));
cache.set(ID, Person::default());
assert!(matches!(cache.settled(ID), Some(Some(_))));
cache.fetch_failed(ID);
assert!(matches!(cache.settled(ID), Some(Some(_))));
assert!(cache.settled("0000-0002-1825-0097").is_none());
for n in 0..PEOPLE_MAX + 10 {
cache.set(&format!("id-{n}"), Person::default());
}
assert_eq!(cache.slots().len(), PEOPLE_MAX);
}
#[test]
fn describes_works_as_citations() {
let w = WorkView::new(
&json!({
"itemType": "journalArticle",
"title": "ORCID: a system to uniquely identify researchers",
"date": "2012-10-01",
"DOI": "10.1087/20120404",
"url": "https://example.org/orcid",
"publicationTitle": "Learned Publishing",
"language": "en-US",
"rights": "https://creativecommons.org/licenses/by/4.0/",
"creators": [
{"firstName": "Laurel", "lastName": "Haak", "creatorType": "author"},
{"name": "ORCID", "creatorType": "author"},
{"firstName": "Eve", "lastName": "Editor", "creatorType": "editor"},
],
}),
Lang::En,
);
assert_eq!(w.link.as_deref(), Some("https://doi.org/10.1087/20120404"));
assert_eq!(w.authors, "Laurel Haak, ORCID");
assert_eq!(w.date, "October 1, 2012");
assert_eq!(w.container, "Learned Publishing");
assert_eq!(w.kind, "Journal article");
assert_eq!(w.language, "English");
assert!(w.license_url.is_some());
let many: Vec<Value> = (0..7)
.map(|i| json!({"lastName": format!("A{i}"), "creatorType": "author"}))
.collect();
let w = WorkView::new(
&json!({
"itemType": "blogPost", "url": "javascript:alert(1)", "creators": many,
"rights": "CC BY 4.0.", "tags": [{"tag": "secret"}],
}),
Lang::De,
);
assert_eq!(w.link, None);
assert_eq!(w.title, "Untitled");
assert_eq!(w.authors, "A0, A1, A2, A3, A4 et al.");
assert_eq!(w.kind, "Blogbeitrag");
assert_eq!(w.license, "Creative-Commons-Lizenz Namensnennung 4.0");
assert_eq!(
w.license_url.as_deref(),
Some("https://creativecommons.org/licenses/by/4.0/legalcode")
);
}
#[test]
fn reads_the_person_card() {
let person: Person = serde_json::from_value(json!({
"id": "https://orcid.org/0000-0003-1419-2405",
"given_name": "Martin",
"family_name": "Fenner",
"additional_names": ["Martin Hellmut Fenner"],
"description": "First paragraph.\nSecond paragraph.",
"affiliations": [
{"name": "Front Matter", "role": "Founder", "start_date": "2021-08-01"},
{"identifier": "https://ror.org/04wxnsj81", "identifier_type": "ROR",
"name": "DataCite", "start_date": "2015-08", "end_date": "2021-07-31"},
],
"urls": [
{"name": "Homepage", "url": "https://front-matter.de/mfenner"},
{"name": "Bad", "url": "javascript:alert(1)"},
],
"country": "DE",
}))
.unwrap();
let view = PersonView::new("0000-0003-1419-2405", &person, Lang::En);
assert_eq!(view.name, "Martin Fenner");
assert_eq!(view.description, "First paragraph. Second paragraph.");
assert_eq!(view.urls.len(), 1);
assert_eq!(view.country, "Germany");
assert_eq!(view.affiliations[0].dates, "Started August 1, 2021");
assert_eq!(view.affiliations[0].ror, None);
assert_eq!(
view.affiliations[1].ror.as_deref(),
Some("https://ror.org/04wxnsj81")
);
assert!(view.json.contains("Fenner"));
let view = PersonView::new("0000-0003-1419-2405", &person, Lang::De);
assert_eq!(view.country, "Deutschland");
assert_eq!(view.affiliations[0].dates, "Beginn 1. August 2021");
}
#[test]
fn exports_only_what_the_profile_publishes() {
let work = json!({
"key": "ARTICLEA", "itemType": "journalArticle", "title": "A <i>title</i>",
"date": "2012-10-01", "DOI": "10.1087/20120404", "publicationTitle": "Journal",
"creators": [
{"firstName": "Laurel", "lastName": "Haak", "creatorType": "author"},
{"name": "ORCID", "creatorType": "author"},
],
"tags": [{"tag": "secret tag"}], "collections": ["SECRETCL"],
"extra": "secret extra",
});
let item = csl_item(&work);
assert_eq!(item["type"], "article-journal");
assert_eq!(item["issued"]["date-parts"], json!([[2012, 10, 1]]));
assert_eq!(item["author"][1]["literal"], "ORCID");
assert_eq!(
csl_item(&json!({"date": "circa 1990"}))["issued"]["date-parts"],
json!([[1990]])
);
for format in &EXPORT_FORMATS {
let bytes = export(std::slice::from_ref(&work), format, "apa", Lang::En)
.unwrap_or_else(|e| panic!("{}: {e}", format.value));
let output = String::from_utf8_lossy(&bytes);
assert!(output.contains("20120404"), "{}: {output}", format.value);
assert!(!output.contains("secret"), "{}: {output}", format.value);
assert!(!output.contains("SECRETCL"), "{}", format.value);
}
let citation = ExportFormat::find(CITATION).unwrap();
let bytes = export(std::slice::from_ref(&work), citation, "apa", Lang::En).unwrap();
let output = String::from_utf8_lossy(&bytes);
assert!(output.contains("Haak"), "{output}");
assert!(!output.contains('<'), "{output}");
}
#[test]
fn reads_creative_commons_licenses() {
for (rights, icons) in [
("CC-BY-4.0", vec!["cc", "by"]),
("CC BY 4.0", vec!["cc", "by"]),
("cc-by-nc-sa-4.0", vec!["cc", "by", "sa", "nc"]),
("CC BY-ND 4.0", vec!["cc", "by", "nd"]),
("CC0", vec!["cc", "zero"]),
("CC0 1.0", vec!["cc", "zero"]),
(
"https://creativecommons.org/licenses/by/4.0/legalcode",
vec!["cc", "by"],
),
(
"https://www.creativecommons.org/licenses/by-nc/4.0/",
vec!["cc", "by", "nc"],
),
(
"https://creativecommons.org/publicdomain/zero/1.0/",
vec!["cc", "zero"],
),
(
"Creative Commons Attribution 4.0 International",
vec!["cc", "by"],
),
("", vec![]),
("All rights reserved", vec![]),
("MIT", vec![]),
("CCC permission required", vec![]),
("https://example.org/licenses/by/4.0/", vec![]),
] {
assert_eq!(cc_icons(rights), icons, "{rights}");
}
}
#[test]
fn names_licenses_and_other_rights() {
let view = |rights: &str| WorkView::new(&json!({"rights": rights}), Lang::En);
for (rights, name, url, icons) in [
(
"https://creativecommons.org/licenses/by-nc-sa/4.0/deed.en",
"Creative Commons Attribution-NonCommercial-ShareAlike 4.0 License",
"https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode",
vec!["cc", "by", "sa", "nc"],
),
(
"CC BY 4.0",
"Creative Commons Attribution 4.0 License",
"https://creativecommons.org/licenses/by/4.0/legalcode",
vec!["cc", "by"],
),
(
"Attribution 4.0 International (CC BY 4.0)",
"Creative Commons Attribution 4.0 License",
"https://creativecommons.org/licenses/by/4.0/legalcode",
vec!["cc", "by"],
),
(
"CC BY-ND 3.0",
"Creative Commons Attribution-NoDerivs 3.0 License",
"https://creativecommons.org/licenses/by-nd/3.0/legalcode",
vec!["cc", "by", "nd"],
),
(
"https://creativecommons.org/licenses/by/3.0/de/",
"Creative Commons Attribution 3.0 Germany License",
"https://creativecommons.org/licenses/by/3.0/de/legalcode",
vec!["cc", "by"],
),
(
"https://creativecommons.org/publicdomain/zero/1.0/",
"CC0 1.0 Universal Public Domain Dedication",
"https://creativecommons.org/publicdomain/zero/1.0/legalcode",
vec!["cc", "zero"],
),
] {
let w = view(rights);
assert_eq!(w.license, name, "{rights}");
assert_eq!(w.license_url.as_deref(), Some(url), "{rights}");
assert_eq!(w.license_icons, icons, "{rights}");
assert!(w.is_license);
}
let w = WorkView::new(&json!({"rights": "CC BY-NC-ND 4.0"}), Lang::De);
assert_eq!(
w.license,
"Creative-Commons-Lizenz Namensnennung – Nicht kommerziell – Keine Bearbeitungen 4.0"
);
let w = view("https://example.org/terms");
assert_eq!(w.license, "https://example.org/terms");
assert!(w.is_license);
let w = view("All rights reserved.");
assert_eq!(w.license, "All rights reserved");
assert!(!w.is_license);
}
#[test]
fn links_only_to_where_a_pdf_was_published() {
const PUBLIC: &str = "https://defter.example.org";
let urls = |urls: &[&str]| urls.iter().map(|u| u.to_string()).collect::<Vec<_>>();
let pick = |own: &str, attached: &[&str]| preferred_pdf(own, &urls(attached), PUBLIC);
const READER: &str = "https://onlinelibrary.wiley.com/doi/pdf/10.1/a";
const MINING: &str = "https://api.wiley.com/onlinelibrary/tdm/v1/articles/10.1";
assert_eq!(pick("", &[READER]).as_deref(), Some(READER));
assert_eq!(pick("", &[MINING, READER]).as_deref(), Some(READER));
assert_eq!(pick("", &[MINING]).as_deref(), Some(MINING));
assert_eq!(
pick("https://example.org/a.PDF?download=1", &[]).as_deref(),
Some("https://example.org/a.PDF?download=1")
);
assert_eq!(pick("https://example.org/a", &[]), None);
assert_eq!(pick("", &[]), None);
for stored in [
"https://defter.example.org/users/1/items/ABCDEFGH/file",
"https://www.zotero.org/users/1/items/ABCDEFGH/file/view",
"https://api.zotero.org/users/1/items/ABCDEFGH/file",
"https://zotero.fsn1.your-objectstorage.com/ab/cd.pdf",
"https://bucket.s3.amazonaws.com/ab/cd.pdf",
"https://x.r2.cloudflarestorage.com/ab/cd.pdf",
"https://store.example.org/ab.pdf?X-Amz-Signature=abc",
"javascript:alert(1)//.pdf",
"file:///Users/me/a.pdf",
] {
assert_eq!(pick("", &[stored]), None, "{stored}");
assert_eq!(pick(stored, &[]), None, "{stored}");
}
assert_eq!(
pick("", &["https://notzotero.org/a.pdf"]).as_deref(),
Some("https://notzotero.org/a.pdf")
);
}
#[test]
fn abstracts_lose_their_markup() {
assert_eq!(
plain_text("<jats:p>Results: p < 0.05 for <i>all</i></jats:p>"),
"Results: p < 0.05 for all"
);
assert_eq!(plain_text("unclosed <b"), "unclosed <b");
}
#[test]
fn summaries_end_at_a_word() {
assert_eq!(summary("short", 200), "short");
assert_eq!(summary("one two three four", 12), "one two…");
}
}