use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use commonmeta::Person;
use serde_json::Value;
use crate::app::AppState;
use crate::db;
use crate::error::AppError;
pub const FEATURED_MAX: usize = 5;
pub const PAGE_SIZE: usize = 25;
pub const TAGS_MAX: usize = 30;
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: Vec<String>,
pub summary: String,
pub urls: Vec<Link>,
pub country: String,
pub affiliations: Vec<AffiliationView>,
}
impl PersonView {
pub fn new(orcid: &str, person: &Person) -> 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: Vec<String> = person
.description
.split('\n')
.map(str::trim)
.filter(|p| !p.is_empty())
.map(String::from)
.collect();
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.join(" "), 200),
description,
urls: person
.urls
.iter()
.filter(|u| web_url(&u.url))
.map(|u| Link {
name: if u.name.is_empty() {
u.url.clone()
} else {
u.name.clone()
},
url: u.url.clone(),
})
.collect(),
country: country(&person.country),
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: date_range(&a.start_date, &a.end_date),
})
.collect(),
}
}
}
pub struct WorkView {
pub title: String,
pub link: Option<String>,
pub doi_url: Option<String>,
pub authors: String,
pub year: String,
pub container: String,
pub kind: 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) -> 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 = text(data, "title");
WorkView {
title: if title.is_empty() {
"Untitled".to_string()
} else {
title.to_string()
},
link,
doi_url,
authors,
year: text(data, "date")
.split(|c: char| !c.is_ascii_digit())
.find(|part| part.len() == 4)
.unwrap_or("")
.to_string(),
container: CONTAINER_FIELDS
.iter()
.map(|field| text(data, field))
.find(|value| !value.is_empty())
.unwrap_or("")
.to_string(),
kind: item_type_label(text(data, "itemType")),
}
}
}
fn item_type_label(item_type: &str) -> String {
let mut label = String::new();
for (i, c) in item_type.chars().enumerate() {
if i == 0 {
label.extend(c.to_uppercase());
} else if c.is_uppercase() {
label.push(' ');
label.extend(c.to_lowercase());
} else {
label.push(c);
}
}
label
}
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([',', ';', ':', '.']))
}
fn country(code: &str) -> String {
let code = code.trim().to_ascii_uppercase();
if code.len() != 2 || !code.bytes().all(|b| b.is_ascii_uppercase()) {
return code;
}
let flag: String = code
.bytes()
.filter_map(|b| char::from_u32(0x1F1E6 + u32::from(b - b'A')))
.collect();
format!("{flag} {code}")
}
const MONTHS: [&str; 12] = [
"January",
"February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
];
fn format_date(date: &str) -> String {
let parts: Vec<&str> = date.trim().split('-').collect();
let month = parts
.get(1)
.and_then(|m| m.parse::<usize>().ok())
.and_then(|m| MONTHS.get(m.wrapping_sub(1)));
let day = parts.get(2).and_then(|d| d.parse::<u32>().ok());
match (parts[0], month, day) {
(year, Some(month), Some(day)) => format!("{month} {day}, {year}"),
(year, Some(month), None) => format!("{month} {year}"),
(year, _, _) => year.to_string(),
}
}
fn date_range(start: &str, end: &str) -> String {
match (start.trim().is_empty(), end.trim().is_empty()) {
(false, false) => format!("{} – {}", format_date(start), format_date(end)),
(false, true) => format!("Since {}", format_date(start)),
(true, false) => format!("Until {}", format_date(end)),
(true, true) => String::new(),
}
}
#[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 formats_dates_and_ranges() {
assert_eq!(format_date("2015"), "2015");
assert_eq!(format_date("2015-08"), "August 2015");
assert_eq!(format_date("2021-08-01"), "August 1, 2021");
assert_eq!(date_range("2021-08-01", ""), "Since August 1, 2021");
assert_eq!(
date_range("2015-08", "2021-07-31"),
"August 2015 – July 31, 2021"
);
assert_eq!(date_range("", ""), "");
}
#[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",
"creators": [
{"firstName": "Laurel", "lastName": "Haak", "creatorType": "author"},
{"name": "ORCID", "creatorType": "author"},
{"firstName": "Eve", "lastName": "Editor", "creatorType": "editor"},
],
}));
assert_eq!(w.link.as_deref(), Some("https://doi.org/10.1087/20120404"));
assert_eq!(w.authors, "Laurel Haak, ORCID");
assert_eq!(w.year, "2012");
assert_eq!(w.container, "Learned Publishing");
assert_eq!(w.kind, "Journal article");
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,
}));
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, "Blog post");
}
#[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);
assert_eq!(view.name, "Martin Fenner");
assert_eq!(view.description, ["First paragraph.", "Second paragraph."]);
assert_eq!(view.urls.len(), 1);
assert_eq!(view.country, "🇩🇪 DE");
assert_eq!(view.affiliations[0].dates, "Since August 1, 2021");
assert_eq!(view.affiliations[0].ror, None);
assert_eq!(
view.affiliations[1].ror.as_deref(),
Some("https://ror.org/04wxnsj81")
);
}
#[test]
fn summaries_end_at_a_word() {
assert_eq!(summary("short", 200), "short");
assert_eq!(summary("one two three four", 12), "one two…");
}
}