use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use commonmeta::Person;
use serde_json::Value;
use crate::app::AppState;
use crate::countries;
use crate::db;
use crate::error::AppError;
use crate::i18n::Lang;
pub const PAGE_SIZE: usize = 25;
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 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 {
slot: Mutex<PersonSlot>,
fetching: tokio::sync::Mutex<()>,
}
#[derive(Default)]
struct PersonSlot {
person: Option<Arc<Person>>,
next_fetch: Option<Instant>,
}
impl PersonCache {
fn slot(&self) -> std::sync::MutexGuard<'_, PersonSlot> {
self.slot.lock().unwrap_or_else(|e| e.into_inner())
}
pub fn set(&self, person: Person) -> Arc<Person> {
let person = Arc::new(person);
*self.slot() = PersonSlot {
person: Some(person.clone()),
next_fetch: Some(Instant::now() + PERSON_TTL),
};
person
}
fn fetch_failed(&self) {
self.slot().next_fetch = Some(Instant::now() + PERSON_RETRY);
}
fn settled(&self) -> Option<Option<Arc<Person>>> {
let slot = self.slot();
let due = slot.next_fetch.is_none_or(|at| Instant::now() >= at);
(!due).then(|| slot.person.clone())
}
}
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() {
return person.ok_or_else(|| unavailable(orcid));
}
let _fetching = app.person.fetching.lock().await;
if let Some(person) = app.person.settled() {
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(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();
let person = app.person.slot().person.clone();
person.ok_or_else(|| unavailable(orcid))
}
pub async fn hidden(app: &AppState) -> Result<bool, AppError> {
let value = app.db.read(|c| db::get_meta(c, HIDDEN_KEY)).await?;
Ok(value.as_deref() == Some("1"))
}
pub async fn set_hidden(app: &AppState, hidden: bool) -> Result<(), AppError> {
app.db
.write(move |c| db::set_meta(c, HIDDEN_KEY, if hidden { "1" } else { "0" }))
.await
}
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 commonmeta_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 = if person.name.is_empty() {
[person.given_name.as_str(), person.family_name.as_str()]
.iter()
.filter(|s| !s.is_empty())
.copied()
.collect::<Vec<_>>()
.join(" ")
} else {
person.name.clone()
};
let description = person
.description
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let country = person.country.trim();
PersonView {
orcid_url: format!("https://orcid.org/{orcid}"),
commonmeta_url: format!("https://commonmeta.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 WorkView {
pub title: String,
pub link: Option<String>,
pub doi_url: Option<String>,
pub authors: String,
pub date: String,
pub container: String,
pub kind: String,
pub language: String,
pub description: String,
pub license: String,
pub license_url: Option<String>,
pub citation: String,
}
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 doi_url = (!doi.is_empty()).then(|| format!("https://doi.org/{doi}"));
let url = text(data, "url");
let link = doi_url
.clone()
.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 {
citation: citation(&authors, date, &title, &container, link.as_deref()),
title,
link,
doi_url,
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")),
license: rights.trim_end_matches('.').to_string(),
license_url: web_url(rights).then(|| rights.to_string()),
}
}
}
fn citation(authors: &str, date: &str, title: &str, container: &str, link: Option<&str>) -> String {
let year = date
.split(|c: char| !c.is_ascii_digit())
.find(|part| part.len() == 4);
let byline = match (authors, year) {
("", None) => String::new(),
("", Some(year)) => format!("({year})"),
(authors, None) => authors.to_string(),
(authors, Some(year)) => format!("{authors} ({year})"),
};
[byline.as_str(), title, container, link.unwrap_or("")]
.iter()
.filter(|part| !part.is_empty())
.map(|part| part.trim_end_matches('.'))
.collect::<Vec<_>>()
.join(". ")
}
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::*;
use serde_json::json;
#[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() {
let cache = PersonCache::default();
assert!(cache.settled().is_none(), "nothing fetched yet");
cache.fetch_failed();
assert!(matches!(cache.settled(), Some(None)));
cache.set(Person::default());
assert!(matches!(cache.settled(), Some(Some(_))));
cache.fetch_failed();
assert!(matches!(cache.settled(), Some(Some(_))));
}
#[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());
assert_eq!(
w.citation,
"Laurel Haak, ORCID (2012). ORCID: a system to uniquely identify researchers. \
Learned Publishing. https://doi.org/10.1087/20120404"
);
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, "CC BY 4.0");
assert_eq!(w.license_url, None);
assert!(!w.citation.contains("secret"));
}
#[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 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…");
}
}