use crate::enrichment::Provider;
use crate::hardware::Resource;
use crate::infrastructure::ComputeEnvironment;
use crate::namespaces::{activity_streams, bibo, codemeta, dcat, dcterms, schema_org, DEFAULT_RAID_SCHEMA_URI, DEFAULT_ROR_SCHEMA_URI};
use crate::pid::raid::{Language, SpatialCoverage};
use crate::pid::{Handle, PersistentIdentifierConvert, PersistentIdentifierParse, PublicationIdentifierType, SWHID};
use crate::research_activity::output::Funding;
use crate::standard::cff::{Agent, Cff, Identifier, IdentifierType, Person};
use crate::standard::text::Text;
#[cfg(feature = "std")]
use crate::standard::zon;
use crate::types::{deserialize_one_or_many_strings, deserialize_optional_one_or_many, deserialize_optional_one_or_many_strings};
use crate::util::constants::{DEFAULT_GRAPHIC_CAPTION, DEFAULT_GRAPHIC_HREF, MAX_LENGTH_SUBTITLE, MAX_LENGTH_TITLE};
use crate::util::constants::{
MAX_COUNT_APPROACH, MAX_COUNT_CAPABILITIES, MAX_COUNT_IMPACT, MAX_COUNT_RESEARCH_AREAS, MAX_LENGTH_RESEARCH_FOCUS, MAX_LENGTH_SECTION_CHALLENGE,
MAX_LENGTH_SECTION_MISSION,
};
use crate::util::constants::{MAX_LENGTH_APPROACH, MAX_LENGTH_CAPABILIY, MAX_LENGTH_IMPACT, MAX_LENGTH_RESEARCH_AREA};
use crate::validation::{Validate, ValidationReport};
use crate::vocabulary;
use crate::{
ClassificationLevel, ContactPoint, ControlledVocabulary, ImageObject, Keyword, MediaObject, OneOrMany, OrganizationType, Other, Status, Website,
};
use acorn_core::prelude::alloc::{format, vec, String, ToString, Vec};
#[cfg(feature = "std")]
use acorn_core::util::MimeType;
use acorn_core::util::{LinkedData, MarkdownSupport, ToProse, Unstructured};
use acorn_core::validation::ValidationError;
use acorn_core::AcornResult;
use acorn_macros::With;
use bon::Builder;
use convert_case::{Case, Casing};
use core::hash::{Hash, Hasher};
use derive_more::Display;
#[cfg(feature = "std")]
use schemars::schema_for;
use schemars::JsonSchema;
use serde::{Deserialize, Deserializer, Serialize};
use serde_trim::{option_string_trim, string_trim, vec_string_trim};
use serde_with::skip_serializing_none;
pub mod aspect;
pub mod common;
pub mod logbook;
mod markdown;
pub mod output;
pub mod s3a;
use aspect::AspectFramework;
pub use common::{
ActivityContributor, ActivityDateType, ActivityOrganization, AgentIdentifier, ControlledSubject, DatedAgentRole, DatedOrganizationRole,
RelatedResourceCategory, RightsAccess, StructuredRights, TypedActivityDate, TypedIdentifier, TypedRelatedResource,
};
pub use logbook::{
GraduationCandidate, GraduationCandidates, GraduationTarget, Logbook, LogbookCategory, LogbookDiscovery, LogbookEntry, LogbookEntryOrigin,
LogbookError, LogbookEventType, LogbookState, LogbookUpdates, ReportingWindow,
};
use markdown::Document;
pub use output::ResearchOutput;
type ValidationResult = core::result::Result<(), ValidationError>;
#[derive(Deserialize, JsonSchema)]
#[serde(untagged)]
enum StringOrStructured<T> {
String(String),
Structured(T),
}
#[derive(Debug, Display)]
enum Vocabulary {
#[display("keywords")]
Keywords,
#[display("partners")]
Partners,
#[display("sponsors")]
Sponsors,
#[display("technology")]
Technology,
}
#[skip_serializing_none]
#[derive(Builder, Clone, Debug, Deserialize, JsonSchema, Serialize)]
#[builder(start_fn = init, on(String, into))]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct EnrichmentProvenance {
pub provider: Provider,
pub source: Option<String>,
#[builder(default)]
pub fields: Vec<String>,
}
#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema, Validate, With)]
#[builder(start_fn = init)]
#[serde(deny_unknown_fields)]
pub struct Research {
#[validate(length(
min = 10,
max = "MAX_LENGTH_RESEARCH_FOCUS",
message = "Focus is too long, reduce the length below 150 characters."
))]
#[builder(default = "Focus of the research".to_string())]
#[serde(deserialize_with = "string_trim")]
pub focus: String,
#[validate(length(min = 1, max = "MAX_COUNT_RESEARCH_AREAS"), custom(function = "is_attribute_areas"))]
#[builder(default = vec!["Areas of research".to_string()])]
#[serde(deserialize_with = "vec_string_trim")]
pub areas: Vec<String>,
}
#[skip_serializing_none]
#[derive(Builder, Clone, Debug, Display, eserde::Deserialize, Serialize, JsonSchema, Validate, With)]
#[builder(start_fn = init)]
#[display("Research Activity ({title})")]
#[serde(deny_unknown_fields)]
#[validate(schema(function = "ResearchActivity::is_valid", skip_on_field_errors = false))]
pub struct ResearchActivity {
#[serde(rename = "@context")]
#[eserde(compat)]
#[with(skip)]
pub context: Option<ResearchActivityContext>,
#[serde(rename = "@type")]
pub research_activity_type: Option<String>,
#[validate(nested)]
#[builder(default)]
pub meta: ResearchActivityMetadata,
#[eserde(compat)]
pub aspect: Option<AspectFramework>,
#[validate(length(min = 4, max = "MAX_LENGTH_TITLE"))]
#[builder(default = "Research Activity Title".to_string())]
#[serde(deserialize_with = "string_trim")]
pub title: String,
#[validate(length(max = "MAX_LENGTH_SUBTITLE", message = "Subtitle is too long, reduce the length below 75 characters."))]
#[serde(default, deserialize_with = "option_string_trim")]
pub subtitle: Option<String>,
#[validate(nested)]
#[eserde(compat)]
pub sections: Option<Sections>,
#[validate(nested)]
#[eserde(compat)]
pub logbook: Option<Logbook>,
#[validate(nested)]
#[builder(default)]
#[eserde(compat)]
pub contact: ContactPoint,
#[eserde(compat)]
pub notes: Option<Other>,
}
#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[builder(start_fn = init, on(String, into))]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ResearchActivityContext {
pub meta: String,
pub title: String,
pub subtitle: String,
pub sections: String,
#[serde(default = "default_logbook_context")]
pub logbook: String,
pub contact: String,
pub notes: String,
}
#[skip_serializing_none]
#[derive(Builder, Clone, Debug, Serialize, eserde::Deserialize, JsonSchema, Validate, With)]
#[builder(start_fn = init)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ResearchActivityMetadata {
#[serde(rename = "@context")]
#[eserde(compat)]
#[with(skip)]
pub context: Option<ResearchActivityMetadataContext>,
#[serde(rename = "@type")]
pub metadata_type: Option<String>,
#[builder(into)]
#[serde(default, deserialize_with = "deserialize_optional_one_or_many")]
pub enrichment: Option<OneOrMany<EnrichmentProvenance>>,
#[eserde(compat)]
pub classification: Option<ClassificationLevel>,
#[builder(default = false)]
#[serde(default)]
#[schemars(default)]
pub archive: bool,
#[builder(default = true)]
#[serde(default = "default_draft")]
#[schemars(default = "default_draft")]
pub draft: bool,
#[builder(default = Status::Active)]
#[serde(default)]
#[eserde(compat)]
pub status: Status,
#[validate(kebabcase)]
#[builder(default = "some-research-project".to_string())]
#[serde(alias = "id", rename = "identifier", deserialize_with = "string_trim")]
pub identifier: String,
#[builder(into)]
#[validate(custom(function = "is_attribute_publication_identifier_list"))]
#[serde(default, deserialize_with = "deserialize_optional_one_or_many_strings")]
pub doi: Option<OneOrMany<String>>,
#[builder(into)]
#[validate(custom(function = "is_attribute_handle_list"))]
#[serde(default, deserialize_with = "deserialize_optional_one_or_many_strings")]
pub handle: Option<OneOrMany<String>>,
#[builder(into)]
#[validate(custom(function = "is_attribute_books"))]
#[serde(default, deserialize_with = "deserialize_optional_one_or_many_strings")]
pub books: Option<OneOrMany<String>>,
#[builder(into)]
#[serde(default, deserialize_with = "deserialize_optional_one_or_many_strings")]
pub patents: Option<OneOrMany<String>>,
#[builder(into)]
#[validate(each(url))]
#[serde(default, deserialize_with = "deserialize_optional_one_or_many_strings")]
pub publications: Option<OneOrMany<String>>,
#[builder(into)]
#[validate(nested)]
#[serde(default, deserialize_with = "deserialize_optional_one_or_many")]
pub outputs: Option<OneOrMany<ResearchOutput>>,
#[builder(into)]
#[validate(each(doi))]
#[serde(default, deserialize_with = "deserialize_optional_one_or_many_strings")]
pub raid: Option<OneOrMany<String>>,
#[builder(into)]
#[validate(custom(function = "is_attribute_swhid_list"))]
#[serde(default, deserialize_with = "deserialize_optional_one_or_many_strings")]
pub swhid: Option<OneOrMany<String>>,
#[builder(into)]
#[validate(each(ror))]
#[serde(default, deserialize_with = "deserialize_optional_one_or_many_strings")]
pub ror: Option<OneOrMany<String>>,
#[eserde(compat)]
pub additional_type: Option<OrganizationType>,
#[builder(into)]
#[validate(nested)]
#[serde(alias = "graphics")]
#[serde(default, deserialize_with = "deserialize_optional_one_or_many")]
pub media: Option<OneOrMany<MediaObject>>,
#[builder(into)]
#[validate(nested)]
#[serde(default, deserialize_with = "deserialize_optional_one_or_many")]
pub websites: Option<OneOrMany<Website>>,
#[builder(default = Vec::<String>::new())]
#[serde(default, deserialize_with = "deserialize_one_or_many_strings", skip_serializing_if = "Vec::is_empty")]
#[schemars(with = "Option<OneOrMany<String>>")]
pub keywords: Vec<Keyword>,
#[builder(default = Vec::<String>::new())]
#[serde(default, deserialize_with = "deserialize_one_or_many_strings", skip_serializing_if = "Vec::is_empty")]
#[schemars(with = "Option<OneOrMany<String>>")]
pub technology: Vec<String>,
#[builder(into)]
#[serde(default, deserialize_with = "deserialize_optional_one_or_many_strings")]
pub partners: Option<OneOrMany<String>>,
#[builder(into)]
#[validate(nested)]
#[serde(default, deserialize_with = "deserialize_optional_one_or_many")]
pub resources: Option<OneOrMany<Resource>>,
#[builder(into)]
#[validate(nested)]
#[serde(default, deserialize_with = "deserialize_optional_one_or_many")]
pub infrastructure: Option<OneOrMany<ComputeEnvironment>>,
#[builder(into)]
#[validate(nested)]
#[serde(alias = "dates")]
#[serde(default, deserialize_with = "deserialize_optional_one_or_many")]
pub typed_dates: Option<OneOrMany<TypedActivityDate>>,
#[builder(into)]
#[validate(nested)]
#[serde(default, deserialize_with = "deserialize_string_or_structured_vec")]
#[schemars(with = "Option<OneOrMany<StringOrStructured<ActivityContributor>>>")]
pub contributors: Option<OneOrMany<ActivityContributor>>,
#[builder(into)]
#[validate(nested)]
#[serde(default, deserialize_with = "deserialize_string_or_structured_vec")]
#[schemars(with = "Option<OneOrMany<StringOrStructured<ActivityOrganization>>>")]
pub organizations: Option<OneOrMany<ActivityOrganization>>,
#[builder(into)]
#[validate(nested)]
#[serde(alias = "sponsors", default, deserialize_with = "deserialize_string_or_structured_vec")]
#[schemars(with = "Option<OneOrMany<StringOrStructured<Funding>>>")]
pub funders: Option<OneOrMany<Funding>>,
#[builder(into)]
#[validate(nested)]
#[serde(alias = "related", default, deserialize_with = "deserialize_string_or_structured_vec")]
#[schemars(with = "Option<OneOrMany<StringOrStructured<TypedRelatedResource>>>")]
pub related_resources: Option<OneOrMany<TypedRelatedResource>>,
#[builder(into)]
#[validate(nested)]
#[serde(default, deserialize_with = "deserialize_string_or_structured_vec")]
#[schemars(with = "Option<OneOrMany<StringOrStructured<Language>>>")]
pub languages: Option<OneOrMany<Language>>,
#[builder(into)]
#[validate(nested)]
#[serde(default, deserialize_with = "deserialize_string_or_structured_vec")]
#[schemars(with = "Option<OneOrMany<StringOrStructured<ControlledSubject>>>")]
pub subjects: Option<OneOrMany<ControlledSubject>>,
#[builder(into)]
#[validate(nested)]
#[serde(default, deserialize_with = "deserialize_string_or_structured_vec")]
#[schemars(with = "Option<OneOrMany<StringOrStructured<SpatialCoverage>>>")]
pub spatial_coverage: Option<OneOrMany<SpatialCoverage>>,
#[builder(into)]
#[validate(nested)]
#[serde(default, deserialize_with = "deserialize_string_or_structured_vec")]
#[schemars(with = "Option<OneOrMany<StringOrStructured<StructuredRights>>>")]
pub rights: Option<OneOrMany<StructuredRights>>,
#[validate(nested)]
#[serde(default, deserialize_with = "deserialize_string_or_structured")]
pub access: Option<StructuredRights>,
}
#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema)]
#[builder(start_fn = init, on(String, into))]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ResearchActivityMetadataContext {
pub classification: String,
pub archive: String,
pub draft: String,
pub status: String,
pub identifier: String,
pub doi: String,
#[serde(default = "default_outputs_context")]
pub outputs: String,
#[serde(default = "default_handle_context")]
pub handle: String,
pub raid: String,
#[serde(default = "default_swhid_context")]
pub swhid: String,
pub ror: String,
pub additional_type: String,
pub media: String,
pub websites: String,
pub keywords: String,
pub technology: String,
pub partners: String,
pub resources: String,
#[serde(default = "default_infrastructure_context")]
pub infrastructure: String,
#[serde(default = "default_typed_dates_context")]
pub typed_dates: String,
#[serde(default = "default_contributors_context")]
pub contributors: String,
#[serde(default = "default_organizations_context")]
pub organizations: String,
#[serde(alias = "sponsors", default = "default_funders_context")]
pub funders: String,
#[serde(alias = "related", default = "default_related_resources_context")]
pub related_resources: String,
#[serde(default = "default_languages_context")]
pub languages: String,
#[serde(default = "default_subjects_context")]
pub subjects: String,
#[serde(default = "default_spatial_coverage_context")]
pub spatial_coverage: String,
#[serde(default = "default_rights_context")]
pub rights: String,
#[serde(default = "default_access_context")]
pub access: String,
}
#[skip_serializing_none]
#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema, Validate, With)]
#[builder(start_fn = init)]
#[serde(deny_unknown_fields)]
pub struct Sections {
#[validate(length(
min = 10,
max = "MAX_LENGTH_SECTION_MISSION",
message = "Mission is too long, reduce the length below 250 characters."
))]
#[builder(default = "Purpose of the research".to_string())]
#[serde(alias = "introduction", deserialize_with = "string_trim")]
pub mission: String,
#[validate(length(
min = 10,
max = "MAX_LENGTH_SECTION_CHALLENGE",
message = "Challenge is too long, reduce the length below 500 characters."
))]
#[builder(default = "Reason for the research".to_string())]
#[serde(deserialize_with = "string_trim")]
pub challenge: String,
#[validate(
length(min = 1, max = "MAX_COUNT_APPROACH", message = "Limit the number of approaches to 6"),
custom(function = "is_attribute_approach")
)]
#[builder(default = vec!["List of actions taken to perform the research".to_string()])]
#[serde(deserialize_with = "vec_string_trim")]
pub approach: Vec<String>,
#[validate(length(min = 1, max = "MAX_COUNT_IMPACT"), custom(function = "is_attribute_impact"))]
#[builder(default = vec!["List of tangible proof that validates the research approach".to_string()])]
#[serde(alias = "outcomes", deserialize_with = "vec_string_trim")]
pub impact: Vec<String>,
#[validate(length(min = 1, max = 4, message = "Limit the number of achievements to 4"))]
pub achievement: Option<Vec<String>>,
#[validate(length(min = 1, max = "MAX_COUNT_CAPABILITIES"), custom(function = "is_attribute_capabilities"))]
pub capabilities: Option<Vec<String>>,
#[validate(nested)]
#[builder(default = Research::init().build())]
pub research: Research,
}
impl From<String> for ActivityContributor {
fn from(value: String) -> Self {
Self::init().name(value.trim().to_string()).build()
}
}
impl From<String> for ActivityOrganization {
fn from(value: String) -> Self {
Self::init().name(value.trim().to_string()).build()
}
}
impl From<&ResearchActivity> for Cff {
fn from(rad: &ResearchActivity) -> Self {
let sections = rad.sections.clone();
let contact = rad.contact.clone();
let meta = rad.meta.clone();
let title = rad.title.clone();
let ContactPoint {
given_name,
family_name,
identifier: orcid,
email,
organization,
affiliation,
..
} = contact;
let person_affiliation = affiliation.or(if organization.is_empty() { None } else { Some(organization) });
let person = Agent::Person(Person {
given_names: Some(given_name),
family_names: Some(family_name),
orcid,
email: Some(email),
affiliation: person_affiliation,
address: None,
alias: None,
city: None,
country: None,
fax: None,
name_particle: None,
name_suffix: None,
postal_code: None,
region: None,
tel: None,
website: None,
});
let keywords = if meta.keywords.is_empty() { None } else { Some(meta.keywords) };
let publication_identifiers = meta.doi.unwrap_or_default();
let doi = match publication_identifiers.as_slice() {
| [single] if PublicationIdentifierType::from(single.as_str()).is_doi() => Some(single.clone()),
| _ => None,
};
let identifiers = publication_identifiers
.into_iter()
.filter(|_| doi.is_none())
.map(|value| match PublicationIdentifierType::from(value.as_str()) {
| PublicationIdentifierType::Doi(_) => Identifier {
description: None,
kind: IdentifierType::Doi,
value,
},
| _ => Identifier {
description: Some("arXiv identifier".to_string()),
kind: IdentifierType::Other,
value,
},
})
.chain(meta.handle.unwrap_or_default().into_iter().map(|value| Identifier {
description: Some("Handle identifier".to_string()),
kind: IdentifierType::Other,
value: Handle::format(value),
}))
.chain(meta.swhid.unwrap_or_default().into_iter().map(|value| Identifier {
description: None,
kind: IdentifierType::Swh,
value: SWHID::format(value),
}))
.collect::<Vec<_>>();
let identifiers = (!identifiers.is_empty()).then_some(identifiers);
Cff {
abstract_text: sections.map(|sections| sections.mission),
authors: vec![person.clone()],
contact: Some(vec![person]),
keywords,
doi,
identifiers,
title,
..Cff::default()
}
}
}
impl From<ResearchActivity> for Cff {
fn from(rad: ResearchActivity) -> Self {
Cff::from(&rad)
}
}
impl From<String> for ControlledSubject {
fn from(value: String) -> Self {
Self::init().subject(value.trim().to_string()).build()
}
}
impl From<String> for Funding {
fn from(value: String) -> Self {
Self::init().name(value.trim().to_string()).build()
}
}
impl From<String> for Language {
fn from(value: String) -> Self {
Self::init().id(value.trim().to_string()).build()
}
}
impl MarkdownSupport for Research {
fn to_markdown(&self) -> String {
let Research { focus, areas } = self;
let focus = focus.to_markdown_text();
let areas = areas.iter().map(MarkdownSupport::to_markdown_text).collect::<Vec<_>>();
format!(
r#"
## Focus
{focus}
## Areas{}"#,
areas.to_markdown(),
)
}
}
impl ResearchActivity {
pub fn authored_image_url(&self) -> Option<String> {
self.meta.media.as_ref().and_then(|values| {
values.iter().find_map(|value| match value {
| MediaObject::Image(ImageObject { content_url: Some(url), .. }) if !url.trim().is_empty() => Some(url.trim().to_string()),
| _ => None,
})
})
}
fn is_valid(&self, _context: &()) -> Result<(), ValidationReport> {
let activity_classification = self.meta.classification.clone().unwrap_or_default();
let classification_error = self
.logbook
.as_ref()
.into_iter()
.flat_map(|logbook| &logbook.entries)
.enumerate()
.find(|(_, entry)| {
entry
.classification
.as_ref()
.is_some_and(|classification| classification > &activity_classification)
})
.map(|(index, entry)| {
let mut error = ValidationError::new("logbook_classification")
.with_message("Research activity classification must equal or exceed every logbook entry classification");
error.add_param("activityClassification".into(), &activity_classification);
error.add_param("entryClassification".into(), &entry.classification);
ValidationReport::from_error(format!("logbook.entries[{index}].classification"), error)
})
.map(Err)
.unwrap_or(Ok(()));
match (!self.meta.draft && self.sections.is_none(), classification_error) {
| (false, result) => result,
| (true, Ok(())) => Err(ValidationReport::from_error(
"sections",
ValidationError::new("required").with_message("Non-draft research activity data requires sections"),
)),
| (true, Err(mut report)) => {
report.add(
"sections",
ValidationError::new("required").with_message("Non-draft research activity data requires sections"),
);
Err(report)
}
}
}
pub fn new() -> Self {
ResearchActivity::default()
}
#[cfg(feature = "std")]
pub fn serialize_as(&self, mime: &MimeType) -> AcornResult<String> {
match mime {
| MimeType::Json | MimeType::Jsonc => {
serde_json::to_string_pretty(self).map_err(|why| schema_error!("Failed to serialize JSON RAD — {why}"))
}
| MimeType::Markdown => Ok(self.to_markdown()),
| MimeType::Yaml => serde_json::to_value(self)
.map_err(|why| schema_error!("Failed to convert RAD to value for YAML serialization — {why}"))
.and_then(|value| serde_norway::to_string(&value).map_err(|why| schema_error!("Failed to serialize YAML RAD — {why}"))),
| MimeType::Zon => zon::encode(self).map_err(|why| schema_error!("Failed to serialize ZON RAD — {why}")),
| _ => Err(schema_error!("Unsupported mime type for research activity serialization: {mime:?}")),
}
}
pub fn source_from(&self, provider: Provider) -> Option<String> {
match provider {
| Provider::CiteAs | Provider::OpenAlex => self.meta.doi.as_ref().and_then(|values| values.first()).cloned(),
| Provider::Orcid => self
.contact
.identifier
.clone()
.filter(|value| !value.trim().is_empty())
.or_else(|| Some(format!("{} {}", self.contact.given_name, self.contact.family_name))),
| Provider::Osti => Some(self.meta.identifier.clone()),
| Provider::Ror => self
.contact
.affiliation
.clone()
.filter(|value| !value.trim().is_empty())
.or_else(|| Some(self.contact.organization.clone())),
}
.filter(|value| !value.trim().is_empty())
}
pub fn is_markdown<T>(source: T) -> bool
where
Text: TryFrom<T>,
{
Text::try_from(source).ok().is_some_and(|text| Document::recognizes(text.content()))
}
pub fn is_yaml(content: &str) -> bool {
serde_norway::from_str::<serde_json::Value>(content)
.ok()
.is_some_and(|value| match value {
| serde_json::Value::Object(fields) => {
["contact", "meta", "sections", "title"]
.iter()
.filter(|field| fields.contains_key(**field))
.count()
>= 2
}
| _ => false,
})
}
#[cfg(feature = "std")]
pub fn to_schema(format: &str) {
let schema = schema_for!(ResearchActivity);
let output = match format.to_lowercase().as_str() {
| "yaml" | "yml" => serde_norway::to_string(&schema).unwrap_or_default(),
| _ => serde_json::to_string_pretty(&schema).unwrap_or_default(),
};
println!("{output}");
}
pub fn copy(&self) -> ResearchActivity {
let ResearchActivity {
meta,
title,
subtitle,
sections,
logbook,
contact,
notes,
..
} = self.clone();
ResearchActivity::init()
.meta(meta)
.title(title)
.maybe_subtitle(subtitle)
.maybe_sections(sections)
.maybe_logbook(logbook)
.contact(contact)
.maybe_notes(notes)
.build()
}
pub fn format(self) -> ResearchActivity {
let Self { meta, contact, .. } = self.clone();
Self {
meta: meta.format(),
contact: contact.format(),
..self
}
}
}
impl Default for ResearchActivity {
fn default() -> Self {
ResearchActivity::init().build()
}
}
impl Hash for ResearchActivity {
fn hash<H: Hasher>(&self, state: &mut H) {
self.meta.identifier.hash(state);
}
}
impl LinkedData for ResearchActivity {
fn with_context(&self) -> Self {
Self {
context: Some(ResearchActivityContext::default()),
research_activity_type: None,
meta: self.meta.with_context(),
contact: self.contact.with_context(),
..self.clone().copy()
}
}
}
impl MarkdownSupport for ResearchActivity {
fn to_markdown(&self) -> String {
Document::from(self).to_markdown()
}
#[cfg(feature = "std")]
fn from_markdown(content: &str) -> Result<Self, String> {
Document::try_from(content).and_then(Self::try_from).map_err(|why| why.to_string())
}
#[cfg(not(feature = "std"))]
fn from_markdown(_content: &str) -> Result<Self, String> {
Err("Markdown parsing requires the std feature".to_string())
}
}
impl ToProse for ResearchActivity {
fn to_prose(&self) -> String {
let websites = self
.meta
.websites
.clone()
.unwrap_or_default()
.into_iter()
.map(|website| website.to_markdown())
.collect::<Vec<String>>()
.join("\n");
let sections = self.sections.as_ref().map(MarkdownSupport::to_markdown).unwrap_or_default();
match &self.subtitle {
| Some(subtitle) => format!(
r#"{title}
{subtitle}
{sections}
{websites}"#,
title = self.title
),
| None => format!(
r#"{title}
{sections}
{websites}"#,
title = self.title
),
}
}
}
impl Default for ResearchActivityContext {
fn default() -> Self {
ResearchActivityContext::init()
.meta(schema_org("CreativeWork"))
.title(dcterms("title"))
.subtitle(schema_org("alternativeHeadline"))
.sections(schema_org("CreativeWork"))
.logbook(default_logbook_context())
.contact(dcat("contactPoint"))
.notes(schema_org("Text"))
.build()
}
}
impl ResearchActivityMetadata {
pub(crate) fn first_image(self) -> Option<MediaObject> {
match self.media {
| Some(values) => values.into_iter().filter(|x| x.clone().is_image()).collect::<Vec<_>>().first().cloned(),
| None => None,
}
}
pub fn first_image_content_url(self) -> String {
match self.first_image() {
| Some(media) => match media {
| MediaObject::Image(ImageObject { content_url, .. }) => match content_url {
| Some(value) if !value.is_empty() => value.clone().trim().to_string(),
| Some(_) | None => DEFAULT_GRAPHIC_HREF.to_string(),
},
| _ => DEFAULT_GRAPHIC_HREF.to_string(),
},
| None => DEFAULT_GRAPHIC_HREF.to_string(),
}
}
pub fn first_image_caption(self) -> String {
match self.first_image() {
| Some(MediaObject::Image(ImageObject { caption, .. })) => match caption.clone() {
| value if !value.is_empty() => value.clone(),
| _ => DEFAULT_GRAPHIC_CAPTION.to_string(),
},
| Some(_) | None => DEFAULT_GRAPHIC_CAPTION.to_string(),
}
}
pub fn format(self) -> Self {
let keywords = Vocabulary::Keywords.resolve(Some(self.keywords.clone()));
let technology = Vocabulary::Technology.resolve(Some(self.technology.clone()));
let partners = match Vocabulary::Partners.resolve(self.partners.clone().map(OneOrMany::into_vec)) {
| values if !values.is_empty() => Some(values.into()),
| _ => None,
};
let funders = self.funders.clone().and_then(|funders| {
let funders = funders
.into_iter()
.filter_map(|funder| {
Vocabulary::Sponsors
.resolve(Some(vec![funder.name.clone()]))
.into_iter()
.next()
.map(|name| Funding { name, ..funder })
})
.collect::<Vec<_>>();
(!funders.is_empty()).then_some(funders.into())
});
Self {
keywords,
technology,
partners,
funders,
..self
}
}
}
impl Default for ResearchActivityMetadata {
fn default() -> Self {
ResearchActivityMetadata::init().build()
}
}
impl LinkedData for ResearchActivityMetadata {
fn with_context(&self) -> Self {
Self {
context: Some(ResearchActivityMetadataContext::default()),
metadata_type: None,
..self.clone()
}
}
}
impl Default for ResearchActivityMetadataContext {
fn default() -> Self {
ResearchActivityMetadataContext::init()
.classification(schema_org("DefinedTerm"))
.archive(schema_org("Boolean"))
.draft(schema_org("Boolean"))
.status(schema_org("DefinedTerm"))
.identifier(codemeta("identifier"))
.doi(bibo("doi"))
.outputs(schema_org("CreativeWork"))
.handle(codemeta("identifier"))
.raid(DEFAULT_RAID_SCHEMA_URI)
.swhid(codemeta("identifier"))
.ror(DEFAULT_ROR_SCHEMA_URI)
.additional_type(schema_org("additionalType"))
.media(schema_org("MediaObject"))
.websites(schema_org("WebSite"))
.keywords(dcat("keyword"))
.technology(schema_org("DefinedTerm"))
.partners(schema_org("Text"))
.resources(schema_org("DefinedTerm"))
.infrastructure(default_infrastructure_context())
.typed_dates(dcterms("date"))
.contributors(schema_org("contributor"))
.organizations(schema_org("Organization"))
.funders(codemeta("sponsor"))
.related_resources(dcterms("relation"))
.languages(dcterms("language"))
.subjects(dcterms("subject"))
.spatial_coverage(dcterms("spatial"))
.rights(dcterms("rights"))
.access(dcterms("accessRights"))
.build()
}
}
impl Default for Sections {
fn default() -> Self {
Sections::init().build()
}
}
impl MarkdownSupport for Sections {
fn to_markdown(&self) -> String {
let Sections {
mission,
challenge,
approach,
impact,
achievement,
capabilities,
research,
} = self;
let mission = mission.to_markdown_text();
let challenge = challenge.to_markdown_text();
let approach = approach.iter().map(MarkdownSupport::to_markdown_text).collect::<Vec<_>>();
let impact = impact.iter().map(MarkdownSupport::to_markdown_text).collect::<Vec<_>>();
let achievement = achievement
.as_ref()
.map(|values| values.iter().map(MarkdownSupport::to_markdown_text).collect::<Vec<_>>())
.map(|values| format!("\n\n## Achievement{}", values.to_markdown()))
.unwrap_or_default();
let capabilities = capabilities
.as_ref()
.map(|values| values.iter().map(MarkdownSupport::to_markdown_text).collect::<Vec<_>>())
.map(|values| format!("\n\n## Capabilities{}", values.to_markdown()))
.unwrap_or_default();
format!(
r#"
## Mission
{}
## Challenge
{}
## Approach{}
## Impact{}{}{}
{}"#,
mission,
challenge,
approach.to_markdown(),
impact.to_markdown(),
achievement,
capabilities,
research.to_markdown(),
)
}
}
impl From<String> for SpatialCoverage {
fn from(value: String) -> Self {
Self {
id: value.trim().to_string(),
schema_uri: None,
place: Vec::new(),
}
}
}
impl From<String> for StructuredRights {
fn from(value: String) -> Self {
match value.trim() {
| "open" => Self::init().access(RightsAccess::Open).build(),
| "embargoed" => Self::init().access(RightsAccess::Embargoed).build(),
| value => Self::init().rights(value.to_string()).build(),
}
}
}
impl From<String> for TypedRelatedResource {
fn from(value: String) -> Self {
Self::init()
.identifier(TypedIdentifier::init().value(value.trim().to_string()).build())
.relation_type("Related")
.category(RelatedResourceCategory::Related)
.build()
}
}
impl Vocabulary {
fn resolve(self, values: Option<Vec<String>>) -> Vec<String> {
ControlledVocabulary::normalize(&self.to_string(), values.unwrap_or_default()).into_values()
}
}
fn default_access_context() -> String {
dcterms("accessRights")
}
fn default_contributors_context() -> String {
schema_org("contributor")
}
fn default_draft() -> bool {
true
}
fn default_funders_context() -> String {
codemeta("sponsor")
}
fn default_handle_context() -> String {
codemeta("identifier")
}
fn default_infrastructure_context() -> String {
dcterms("relation")
}
fn default_languages_context() -> String {
dcterms("language")
}
fn default_logbook_context() -> String {
activity_streams("OrderedCollection")
}
fn default_organizations_context() -> String {
schema_org("Organization")
}
fn default_outputs_context() -> String {
schema_org("CreativeWork")
}
fn default_related_resources_context() -> String {
dcterms("relation")
}
fn default_rights_context() -> String {
dcterms("rights")
}
fn default_spatial_coverage_context() -> String {
dcterms("spatial")
}
fn default_subjects_context() -> String {
dcterms("subject")
}
fn default_swhid_context() -> String {
codemeta("identifier")
}
fn default_typed_dates_context() -> String {
dcterms("date")
}
fn deserialize_string_or_structured<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de> + From<String>,
{
Option::<StringOrStructured<T>>::deserialize(deserializer).map(|value| {
value.map(|value| match value {
| StringOrStructured::String(value) => T::from(value),
| StringOrStructured::Structured(value) => value,
})
})
}
fn deserialize_string_or_structured_vec<'de, D, T>(deserializer: D) -> Result<Option<OneOrMany<T>>, D::Error>
where
D: Deserializer<'de>,
T: Deserialize<'de> + From<String>,
{
deserialize_optional_one_or_many(deserializer).map(|values| {
values.map(|values| {
values.map_items(|value| match value {
| StringOrStructured::String(value) => T::from(value),
| StringOrStructured::Structured(value) => value,
})
})
})
}
pub(crate) fn is_attribute_approach(value: &[String]) -> ValidationResult {
const CODE: &str = "sections.approach";
const MAX_LENGTH: usize = MAX_LENGTH_APPROACH;
value
.iter()
.enumerate()
.find(|(_, x)| x.len() > MAX_LENGTH)
.map(|(index, x)| {
let length = x.len();
validation_error_with_index_and_length(
CODE,
format!("Each approach statement should be less than {MAX_LENGTH} characters"),
index,
length,
)
})
.map_or(Ok(()), Err)
}
pub(crate) fn is_attribute_areas(value: &[String]) -> ValidationResult {
const CODE: &str = "sections.areas";
const MAX_LENGTH: usize = MAX_LENGTH_RESEARCH_AREA;
value
.iter()
.enumerate()
.find(|(_, x)| x.len() > MAX_LENGTH)
.map(|(index, x)| {
let length = x.len();
validation_error_with_index_and_length(CODE, format!("Each area should be less than {MAX_LENGTH} characters"), index, length)
})
.map_or(Ok(()), Err)
}
pub(crate) fn is_attribute_books(value: &[String]) -> ValidationResult {
const CODE: &str = "meta.books";
value
.iter()
.position(|x| !x.is_isbn())
.map(|index| validation_error_with_index(CODE, "Every book should be a valid ISBN".to_string(), index))
.map_or(Ok(()), Err)
}
pub(crate) fn is_attribute_capabilities(value: &[String]) -> ValidationResult {
const CODE: &str = "sections.capabilities";
const MAX_LENGTH: usize = MAX_LENGTH_CAPABILIY;
value
.iter()
.enumerate()
.find(|(_, x)| x.len() > MAX_LENGTH)
.map(|(index, x)| {
let length = x.len();
validation_error_with_index_and_length(
CODE,
format!("Each capability should be less than {MAX_LENGTH} characters"),
index,
length,
)
})
.map_or(Ok(()), Err)
}
pub(crate) fn is_attribute_handle_list(value: &[String]) -> ValidationResult {
const CODE: &str = "meta.handle";
value
.iter()
.position(|identifier| !Handle::is_valid(identifier))
.map(|index| validation_error_with_index(CODE, "Every Handle should be valid".to_string(), index))
.map_or(Ok(()), Err)
}
pub(crate) fn is_attribute_impact(value: &[String]) -> ValidationResult {
const CODE: &str = "sections.impact";
const MAX_LENGTH: usize = MAX_LENGTH_IMPACT;
value
.iter()
.enumerate()
.find(|(_, x)| x.len() > MAX_LENGTH)
.map(|(index, x)| {
let length = x.len();
validation_error_with_index_and_length(
CODE,
format!("Each impact statement should be less than {MAX_LENGTH} characters"),
index,
length,
)
})
.or_else(|| {
value
.first()
.and_then(|first| {
let ends_with_period = first.trim().ends_with(".");
value.iter().position(|x| x.trim().ends_with(".") != ends_with_period)
})
.map(|index| {
validation_error_with_index(
CODE,
"Impact statements should be all sentences with periods or all phrases without periods".to_string(),
index,
)
})
})
.or_else(|| {
value
.iter()
.position(|x| {
x.trim().chars().find(|c| c.is_alphabetic()).is_none_or(|letter| {
let actual = letter.to_string();
actual != actual.to_case(Case::Upper)
})
})
.map(|index| validation_error_with_index(CODE, "Impact statements should begin with a capital letter".to_string(), index))
})
.map_or(Ok(()), Err)
}
pub(crate) fn is_attribute_publication_identifier_list(value: &[String]) -> ValidationResult {
const CODE: &str = "meta.doi";
value
.iter()
.position(|identifier| PublicationIdentifierType::from(identifier.as_str()).is_unknown())
.map(|index| {
validation_error_with_index(
CODE,
"Every publication identifier should be a valid DOI or arXiv identifier".to_string(),
index,
)
})
.map_or(Ok(()), Err)
}
pub(crate) fn is_attribute_swhid_list(value: &[String]) -> ValidationResult {
const CODE: &str = "meta.swhid";
value
.iter()
.position(|identifier| !SWHID::is_valid(identifier))
.map(|index| validation_error_with_index(CODE, "Every SWHID should be valid".to_string(), index))
.map_or(Ok(()), Err)
}
pub(crate) fn resolve_from_csv_asset(name: String, value: String) -> Option<String> {
let data = vocabulary::csv(&name);
super::resolve_from_list_of_lists(value, data)
}
fn validation_error_with_index(code: &'static str, message: String, index: usize) -> ValidationError {
let mut err = ValidationError::new(code).with_message(message);
err.add_param("index".into(), &index);
err
}
fn validation_error_with_index_and_length(code: &'static str, message: String, index: usize, length: usize) -> ValidationError {
let mut err = validation_error_with_index(code, message, index);
err.add_param("length".into(), &length);
err
}
#[cfg(test)]
mod tests;