#[cfg(feature = "std")]
use crate::error::ApiResult;
#[cfg(feature = "std")]
use crate::io::{image_paths, jsonc_parse_value, parent, read_file, write_file, FromPath, InputOutput};
#[cfg(feature = "std")]
use crate::prelude::PathBuf;
use crate::prelude::*;
use crate::schema::hardware::Resource;
use crate::schema::namespaces::{bibo, codemeta, dcat, dcterms, schema_org, DEFAULT_RAID_SCHEMA_URI, DEFAULT_ROR_SCHEMA_URI};
use crate::schema::pid::{PersistentIdentifierConvert, PublicationIdentifierType};
use crate::schema::standard::cff::{Agent, Cff, Identifier, IdentifierType, Person};
use crate::schema::standard::text::Text;
use crate::schema::validate::{is_doi, is_kebabcase, is_ror, is_urls};
use crate::schema::{
ClassificationLevel, ContactPoint, ControlledVocabulary, ImageObject, Keyword, MediaObject, OrganizationType, Other, Status, Website,
};
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};
#[cfg(feature = "std")]
use crate::util::{Constant, Label};
use crate::util::{LinkedData, MarkdownSupport, ToProse, Unstructured};
#[cfg(feature = "std")]
use crate::util::{MimeType, StringConversion};
use bon::Builder;
#[cfg(feature = "std")]
use color_eyre::eyre::eyre;
use convert_case::{Case, Casing};
use core::hash::{Hash, Hasher};
use derive_more::Display;
#[cfg(feature = "std")]
use nucleo_matcher::{
pattern::{CaseMatching, Normalization, Pattern},
Config, Matcher,
};
#[cfg(feature = "std")]
use owo_colors::OwoColorize;
#[cfg(feature = "std")]
use schemars::schema_for;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_trim::{option_string_trim, string_trim, vec_string_trim};
use serde_with::skip_serializing_none;
#[cfg(feature = "std")]
use tracing::{debug, trace};
use validator::{Validate, ValidationError};
pub mod aspect;
mod markdown;
pub mod s3a;
use aspect::AspectFramework;
use markdown::Document;
pub(crate) use markdown::MarkdownParser;
type ValidationResult = core::result::Result<(), ValidationError>;
#[derive(Debug, Display)]
enum Vocabulary {
#[display("keywords")]
Keywords,
#[display("partners")]
Partners,
#[display("sponsors")]
Sponsors,
#[display("technology")]
Technology,
}
#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
#[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)]
#[builder(start_fn = init)]
#[display("Research Activity ({title})")]
#[serde(deny_unknown_fields)]
pub struct ResearchActivity {
#[serde(rename = "@context")]
#[eserde(compat)]
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)]
#[builder(default)]
#[eserde(compat)]
pub sections: Sections,
#[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,
pub contact: String,
pub notes: String,
}
#[skip_serializing_none]
#[derive(Builder, Clone, Debug, Serialize, eserde::Deserialize, JsonSchema, Validate)]
#[builder(start_fn = init)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub struct ResearchActivityMetadata {
#[serde(rename = "@context")]
#[eserde(compat)]
pub context: Option<ResearchActivityMetadataContext>,
#[serde(rename = "@type")]
pub metadata_type: Option<String>,
#[eserde(compat)]
pub classification: Option<ClassificationLevel>,
#[builder(default = false)]
pub archive: bool,
#[builder(default = true)]
pub draft: bool,
#[builder(default = Status::Active)]
#[serde(default)]
#[eserde(compat)]
pub status: Status,
#[validate(custom(function = "is_kebabcase"))]
#[builder(default = "some-research-project".to_string())]
#[serde(alias = "id", rename = "identifier", deserialize_with = "string_trim")]
pub identifier: String,
#[validate(custom(function = "is_attribute_publication_identifier_list"))]
#[serde(default)]
pub doi: Option<Vec<String>>,
#[validate(custom(function = "is_attribute_books"))]
#[serde(default)]
pub books: Option<Vec<String>>,
#[serde(default)]
pub patents: Option<Vec<String>>,
#[validate(custom(function = "is_urls"))]
#[serde(default)]
pub publications: Option<Vec<String>>,
#[validate(custom(function = "is_attribute_doi_list"))]
#[serde(default)]
pub raid: Option<Vec<String>>,
#[validate(custom(function = "is_attribute_ror_list"))]
#[serde(default)]
pub ror: Option<Vec<String>>,
#[eserde(compat)]
pub additional_type: Option<OrganizationType>,
#[validate(nested)]
#[serde(alias = "graphics")]
#[eserde(compat)]
pub media: Option<Vec<MediaObject>>,
#[validate(nested)]
#[eserde(compat)]
pub websites: Option<Vec<Website>>,
#[builder(default = Vec::<String>::new())]
pub keywords: Vec<Keyword>,
#[builder(default = Vec::<String>::new())]
#[serde(deserialize_with = "vec_string_trim")]
pub technology: Vec<String>,
pub sponsors: Option<Vec<String>>,
pub partners: Option<Vec<String>>,
pub related: Option<Vec<String>>,
#[eserde(compat)]
pub resources: Option<Vec<Resource>>,
}
#[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,
pub raid: String,
pub ror: String,
pub additional_type: String,
pub media: String,
pub websites: String,
pub keywords: String,
pub technology: String,
pub sponsors: String,
pub partners: String,
pub related: String,
pub resources: String,
}
#[skip_serializing_none]
#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
#[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(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 Default for ResearchActivity {
fn default() -> Self {
ResearchActivity::init().build()
}
}
impl Default for ResearchActivityContext {
fn default() -> Self {
ResearchActivityContext::init()
.meta(schema_org("CreativeWork"))
.title(dcterms("title"))
.subtitle(schema_org("alternativeHeadline"))
.sections(schema_org("CreativeWork"))
.contact(dcat("contactPoint"))
.notes(schema_org("Text"))
.build()
}
}
impl Default for ResearchActivityMetadata {
fn default() -> Self {
ResearchActivityMetadata::init().build()
}
}
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"))
.raid(DEFAULT_RAID_SCHEMA_URI)
.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"))
.sponsors(codemeta("sponsor"))
.partners(schema_org("Text"))
.related(schema_org("Text"))
.resources(schema_org("DefinedTerm"))
.build()
}
}
impl Default for Sections {
fn default() -> Self {
Sections::init().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 matches!(PublicationIdentifierType::from(single.as_str()), PublicationIdentifierType::Doi(_)) => Some(single.clone()),
| _ => None,
};
let identifiers = doi.is_none().then(|| {
publication_identifiers
.into_iter()
.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,
},
})
.collect::<Vec<_>>()
});
let identifiers = identifiers.filter(|values| !values.is_empty());
Cff {
abstract_text: Some(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 Hash for ResearchActivity {
fn hash<H: Hasher>(&self, state: &mut H) {
self.meta.identifier.hash(state);
}
}
#[cfg(feature = "std")]
impl InputOutput for ResearchActivity {
fn read(path: impl Into<PathBuf>) -> ApiResult<Self> {
let source = path.into().clone();
let result = match MimeType::from_path(&source) {
| MimeType::Json => Self::read_json(source.clone()),
| MimeType::Jsonc => Self::read_jsonc(source.clone()),
| MimeType::Markdown => Self::read_markdown(source.clone()),
| MimeType::Yaml => Self::read_yaml(source.clone()),
| _ => Err(eyre!("Unsupported research activity data file extension")),
};
if let Ok(data) = &result {
debug!(path = source.to_string_lossy().to_string(), "=> {}", Label::using());
debug!("=> {} Research activity data = {:#?}", Label::using(), data.dimmed().cyan());
}
result
}
fn read_json(path: PathBuf) -> ApiResult<Self> {
read_file(path.clone()).and_then(|content| {
eserde::json::from_str::<Self>(&content).map_err(|errors| {
let details: Vec<String> = errors
.iter()
.map(|e| format!("{}: {}", e.path().map_or("root".into(), |p| p.to_string()), e.message()))
.collect();
eyre!("{}", details.join("\n"))
})
})
}
fn read_jsonc(path: PathBuf) -> ApiResult<Self> {
read_file(path.clone()).and_then(|content| {
jsonc_parse_value(&content).and_then(|value| {
serde_json::to_string(&value)
.map_err(|why| eyre!("JSONC conversion error — {why}"))
.and_then(|json| {
eserde::json::from_str::<Self>(&json).map_err(|errors| {
let details: Vec<String> = errors
.iter()
.map(|e| format!("{}: {}", e.path().map_or("root".into(), |p| p.to_string()), e.message()))
.collect();
eyre!("{}", details.join("\n"))
})
})
})
})
}
fn read_markdown(path: PathBuf) -> ApiResult<Self> {
read_file(path).and_then(|content| Self::from_markdown(&content).map_err(|why| eyre!("{why}")))
}
fn read_yaml(path: PathBuf) -> ApiResult<Self> {
read_file(path.clone()).and_then(|content| serde_norway::from_str(&content).map_err(|why| eyre!("Failed to parse YAML RAD — {why}")))
}
fn write(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
let output = path.into();
let mime = MimeType::from_path(&output);
match mime {
| MimeType::Cff => self.write_cff(output),
| MimeType::Json | MimeType::Jsonc => self.write_json(output),
| MimeType::Markdown => self.write_markdown(output),
| MimeType::Yaml => self.write_yaml(output),
| _ => Err(eyre!("Unsupported research activity data file extension for writing")),
}
}
fn write_cff(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
let output = path.into().with_extension("cff");
let data: Cff = self.into();
serde_norway::to_string(&data)
.map_err(|why| eyre!("Failed to serialize RAD CITATION.cff — {why}"))
.and_then(|content| write_file(output, content))
}
fn write_json(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
let output = path.into();
serde_json::to_string_pretty(&self)
.map_err(|why| eyre!("Failed to serialize JSON RAD — {why}"))
.and_then(|content| write_file(output, content))
}
fn write_markdown(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
let output = path.into().with_extension("md");
let content = self.to_markdown();
write_file(output, content)
}
fn write_yaml(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
let output = path.into().with_extension("yaml");
serde_json::to_value(self)
.map_err(|why| eyre!("Failed to convert RAD to value for YAML serialization — {why}"))
.and_then(|value| serde_norway::to_string(&value).map_err(|why| eyre!("Failed to serialize YAML RAD — {why}")))
.and_then(|content| write_file(output, content))
}
}
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 LinkedData for ResearchActivityMetadata {
fn with_context(&self) -> Self {
Self {
context: Some(ResearchActivityMetadataContext::default()),
metadata_type: None,
..self.clone()
}
}
}
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 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 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 ResearchActivity {
pub fn new() -> Self {
ResearchActivity::default()
}
#[cfg(feature = "std")]
pub fn serialize_as(&self, mime: &MimeType) -> ApiResult<String> {
match mime {
| MimeType::Json | MimeType::Jsonc => serde_json::to_string_pretty(self).map_err(|why| eyre!("Failed to serialize JSON RAD — {why}")),
| MimeType::Markdown => Ok(self.to_markdown()),
| MimeType::Yaml => serde_json::to_value(self)
.map_err(|why| eyre!("Failed to convert RAD to value for YAML serialization — {why}"))
.and_then(|value| serde_norway::to_string(&value).map_err(|why| eyre!("Failed to serialize YAML RAD — {why}"))),
| _ => Err(eyre!("Unsupported mime type for research activity serialization: {mime:?}")),
}
}
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()))
}
#[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,
contact,
notes,
..
} = self.clone();
ResearchActivity::init()
.meta(meta)
.title(title)
.maybe_subtitle(subtitle)
.sections(sections)
.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
}
}
#[cfg(feature = "std")]
pub fn format_with(self, context: Option<PathBuf>) -> ResearchActivity {
let Self { meta, contact, .. } = self.clone();
Self {
meta: meta.format_with(context.clone()),
contact: contact.format_with(context.clone()),
..self
}
.format()
}
}
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()) {
| values if !values.is_empty() => Some(values),
| _ => None,
};
let sponsors = match Vocabulary::Sponsors.resolve(self.sponsors.clone()) {
| values if !values.is_empty() => Some(values),
| _ => None,
};
Self {
keywords,
technology,
partners,
sponsors,
..self
}
}
#[cfg(feature = "std")]
pub fn format_with(self, path: Option<PathBuf>) -> Self {
let path_parent = match path {
| Some(value) => parent(value),
| None => PathBuf::from("."),
};
debug!(path = path_parent.to_absolute_path(), "=> {} Parent directory", Label::using());
let name = match image_paths(&path_parent) {
| value if !value.is_empty() => value.first().and_then(|v| v.file_name().map(|f| f.to_string_lossy().to_string())),
| _ => None,
};
let media = match name {
| Some(value) => {
debug!(value, "=> {} First image", Label::using());
let first_graphic = match self.media.clone() {
| Some(values) if !values.is_empty() => {
let caption = self.clone().first_image_caption();
let image_data = ImageObject::init().caption(caption.to_string()).content_url(value.clone()).build();
MediaObject::Image(image_data)
}
| Some(_) | None => {
let image_data = ImageObject::init().caption("".to_string()).content_url(value.clone()).build();
MediaObject::Image(image_data)
}
};
let rest = match self.clone().media {
| Some(values) if !values.is_empty() => values.into_iter().skip(1).collect::<Vec<_>>(),
| Some(_) | None => vec![],
};
Some([vec![first_graphic], rest].concat())
}
| None => self.media.clone(),
};
Self { media, ..self }.format()
}
}
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.to_markdown();
match &self.subtitle {
| Some(subtitle) => format!(
r#"{title}
{subtitle}
{sections}
{websites}"#,
title = self.title
),
| None => format!(
r#"{title}
{sections}
{websites}"#,
title = self.title
),
}
}
}
impl Vocabulary {
fn resolve(self, values: Option<Vec<String>>) -> Vec<String> {
ControlledVocabulary::normalize(&self.to_string(), values.unwrap_or_default()).into_values()
}
}
#[cfg(feature = "std")]
pub(crate) fn resolve_from_csv_asset(name: String, value: String) -> Option<String> {
let data = Constant::csv(&name);
resolve_from_list_of_lists(value, data, name)
}
#[cfg(not(feature = "std"))]
pub(crate) fn resolve_from_csv_asset(_name: String, value: String) -> Option<String> {
Some(value)
}
#[cfg(feature = "std")]
pub(crate) fn resolve_from_list_of_lists<I: IntoIterator<Item = Vec<String>>>(value: String, data: I, name: String) -> Option<String> {
fn match_list<I: IntoIterator<Item = String> + Clone>(value: String, values: I) -> Vec<(String, u32)> {
let pattern = Pattern::parse(&value, CaseMatching::Ignore, Normalization::Smart);
let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
pattern.match_list(values.clone(), &mut matcher)
}
fn print_resolution(output: Option<String>, value: String, name: String) {
let label = name.to_case(Case::Title);
match output {
| Some(resolved) => {
if resolved.eq(&value.to_string()) {
trace!("=> {} {} = \"{}\"", Label::using(), label, value.clone());
} else {
debug!(input = value.clone(), resolved, "=> {} {}", Label::found(), label);
}
}
| None => {
debug!(value = value.clone(), "=> {} {}", Label::not_found(), label);
}
};
}
let output = data
.into_iter()
.flat_map(|values| {
let sanitized = value.normalize();
let matched = match_list(sanitized, values.clone().into_iter().take(4));
trace!("{} => {:?}", value.clone(), matched.clone());
if matched.clone().is_empty() {
None
} else {
match values.first() {
| Some(x) => {
if value.eq(x) {
Some((x.into(), 10000))
} else {
let score = matched.into_iter().map(|(_, score)| score).max();
match score {
| Some(value) if value > 0 => Some((x.to_string(), value)),
| Some(_) | None => None,
}
}
}
| None => None,
}
}
})
.max_by_key(|(_, score)| *score)
.map(|(x, _)| x.to_string());
print_resolution(output.clone(), value, name);
output
}
#[cfg(not(feature = "std"))]
#[allow(dead_code)]
pub(crate) fn resolve_from_list_of_lists<I: IntoIterator<Item = Vec<String>>>(value: String, _data: I, _name: String) -> Option<String> {
Some(value)
}
fn validation_error_with_index(code: &'static str, message: String, index: usize) -> ValidationError {
let mut err = ValidationError::new(code).with_message(message.into());
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
}
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_publication_identifier_list(value: &[String]) -> ValidationResult {
const CODE: &str = "meta.doi";
value
.iter()
.position(|identifier| matches!(PublicationIdentifierType::from(identifier.as_str()), PublicationIdentifierType::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_doi_list(value: &[String]) -> ValidationResult {
const CODE: &str = "meta.raid";
value
.iter()
.position(|identifier| is_doi(identifier).is_err())
.map(|index| validation_error_with_index(CODE, "Every DOI should be valid".to_string(), index))
.map_or(Ok(()), Err)
}
pub(crate) fn is_attribute_ror_list(value: &[String]) -> ValidationResult {
const CODE: &str = "meta.ror";
value
.iter()
.position(|x| is_ror(x).is_err())
.map(|index| validation_error_with_index(CODE, "Every ROR 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)
}
#[cfg(test)]
mod tests;