1#[cfg(feature = "std")]
6use crate::error::ApiResult;
7#[cfg(feature = "std")]
8use crate::io::{image_paths, jsonc_parse_value, parent, read_file, write_file, FromPath, InputOutput};
9#[cfg(feature = "std")]
10use crate::prelude::PathBuf;
11use crate::prelude::*;
12use crate::schema::hardware::Resource;
13use crate::schema::namespaces::{bibo, codemeta, dcat, dcterms, schema_org, DEFAULT_RAID_SCHEMA_URI, DEFAULT_ROR_SCHEMA_URI};
14use crate::schema::pid::{PersistentIdentifierConvert, PublicationIdentifierType};
15use crate::schema::standard::cff::{Agent, Cff, Identifier, IdentifierType, Person};
16use crate::schema::standard::text::Text;
17use crate::schema::validate::{is_doi, is_kebabcase, is_ror, is_urls};
18use crate::schema::{
19 ClassificationLevel, ContactPoint, ControlledVocabulary, ImageObject, Keyword, MediaObject, OrganizationType, Other, Status, Website,
20};
21use crate::util::constants::{DEFAULT_GRAPHIC_CAPTION, DEFAULT_GRAPHIC_HREF, MAX_LENGTH_SUBTITLE, MAX_LENGTH_TITLE};
22use crate::util::constants::{
23 MAX_COUNT_APPROACH, MAX_COUNT_CAPABILITIES, MAX_COUNT_IMPACT, MAX_COUNT_RESEARCH_AREAS, MAX_LENGTH_RESEARCH_FOCUS, MAX_LENGTH_SECTION_CHALLENGE,
24 MAX_LENGTH_SECTION_MISSION,
25};
26use crate::util::constants::{MAX_LENGTH_APPROACH, MAX_LENGTH_CAPABILIY, MAX_LENGTH_IMPACT, MAX_LENGTH_RESEARCH_AREA};
27#[cfg(feature = "std")]
28use crate::util::{Constant, Label};
29use crate::util::{LinkedData, MarkdownSupport, ToProse, Unstructured};
30#[cfg(feature = "std")]
31use crate::util::{MimeType, StringConversion};
32use bon::Builder;
33#[cfg(feature = "std")]
34use color_eyre::eyre::eyre;
35use convert_case::{Case, Casing};
36use core::hash::{Hash, Hasher};
37use derive_more::Display;
38#[cfg(feature = "std")]
39use nucleo_matcher::{
40 pattern::{CaseMatching, Normalization, Pattern},
41 Config, Matcher,
42};
43#[cfg(feature = "std")]
44use owo_colors::OwoColorize;
45#[cfg(feature = "std")]
46use schemars::schema_for;
47use schemars::JsonSchema;
48use serde::{Deserialize, Serialize};
49use serde_trim::{option_string_trim, string_trim, vec_string_trim};
50use serde_with::skip_serializing_none;
51#[cfg(feature = "std")]
52use tracing::{debug, trace};
53use validator::{Validate, ValidationError};
54
55pub mod aspect;
56mod markdown;
57pub mod s3a;
58use aspect::AspectFramework;
59use markdown::Document;
60pub(crate) use markdown::MarkdownParser;
61
62type ValidationResult = core::result::Result<(), ValidationError>;
63#[derive(Debug, Display)]
64enum Vocabulary {
65 #[display("keywords")]
66 Keywords,
67 #[display("partners")]
68 Partners,
69 #[display("sponsors")]
70 Sponsors,
71 #[display("technology")]
72 Technology,
73}
74#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
76#[builder(start_fn = init)]
77#[serde(deny_unknown_fields)]
78pub struct Research {
79 #[validate(length(
81 min = 10,
82 max = "MAX_LENGTH_RESEARCH_FOCUS",
83 message = "Focus is too long, reduce the length below 150 characters."
84 ))]
85 #[builder(default = "Focus of the research".to_string())]
86 #[serde(deserialize_with = "string_trim")]
87 pub focus: String,
88 #[validate(length(min = 1, max = "MAX_COUNT_RESEARCH_AREAS"), custom(function = "is_attribute_areas"))]
90 #[builder(default = vec!["Areas of research".to_string()])]
91 #[serde(deserialize_with = "vec_string_trim")]
92 pub areas: Vec<String>,
93}
94#[skip_serializing_none]
97#[derive(Builder, Clone, Debug, Display, eserde::Deserialize, Serialize, JsonSchema, Validate)]
98#[builder(start_fn = init)]
99#[display("Research Activity ({title})")]
100#[serde(deny_unknown_fields)]
101pub struct ResearchActivity {
102 #[serde(rename = "@context")]
104 #[eserde(compat)]
105 pub context: Option<ResearchActivityContext>,
106 #[serde(rename = "@type")]
108 pub research_activity_type: Option<String>,
109 #[validate(nested)]
111 #[builder(default)]
112 pub meta: ResearchActivityMetadata,
113 #[eserde(compat)]
115 pub aspect: Option<AspectFramework>,
116 #[validate(length(min = 4, max = "MAX_LENGTH_TITLE"))]
118 #[builder(default = "Research Activity Title".to_string())]
119 #[serde(deserialize_with = "string_trim")]
120 pub title: String,
121 #[validate(length(max = "MAX_LENGTH_SUBTITLE", message = "Subtitle is too long, reduce the length below 75 characters."))]
123 #[serde(default, deserialize_with = "option_string_trim")]
124 pub subtitle: Option<String>,
125 #[validate(nested)]
127 #[builder(default)]
128 #[eserde(compat)]
129 pub sections: Sections,
130 #[validate(nested)]
132 #[builder(default)]
133 #[eserde(compat)]
134 pub contact: ContactPoint,
135 #[eserde(compat)]
137 pub notes: Option<Other>,
138}
139#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema)]
143#[builder(start_fn = init, on(String, into))]
144#[serde(deny_unknown_fields, rename_all = "camelCase")]
145pub struct ResearchActivityContext {
146 pub meta: String,
148 pub title: String,
150 pub subtitle: String,
152 pub sections: String,
154 pub contact: String,
156 pub notes: String,
158}
159#[skip_serializing_none]
161#[derive(Builder, Clone, Debug, Serialize, eserde::Deserialize, JsonSchema, Validate)]
162#[builder(start_fn = init)]
163#[serde(deny_unknown_fields, rename_all = "camelCase")]
164pub struct ResearchActivityMetadata {
165 #[serde(rename = "@context")]
167 #[eserde(compat)]
168 pub context: Option<ResearchActivityMetadataContext>,
169 #[serde(rename = "@type")]
171 pub metadata_type: Option<String>,
172 #[eserde(compat)]
174 pub classification: Option<ClassificationLevel>,
175 #[builder(default = false)]
179 pub archive: bool,
180 #[builder(default = true)]
184 pub draft: bool,
185 #[builder(default = Status::Active)]
187 #[serde(default)]
188 #[eserde(compat)]
189 pub status: Status,
190 #[validate(custom(function = "is_kebabcase"))]
197 #[builder(default = "some-research-project".to_string())]
198 #[serde(alias = "id", rename = "identifier", deserialize_with = "string_trim")]
199 pub identifier: String,
200 #[validate(custom(function = "is_attribute_publication_identifier_list"))]
204 #[serde(default)]
205 pub doi: Option<Vec<String>>,
206 #[validate(custom(function = "is_attribute_books"))]
208 #[serde(default)]
209 pub books: Option<Vec<String>>,
210 #[serde(default)]
212 pub patents: Option<Vec<String>>,
213 #[validate(custom(function = "is_urls"))]
215 #[serde(default)]
216 pub publications: Option<Vec<String>>,
217 #[validate(custom(function = "is_attribute_doi_list"))]
219 #[serde(default)]
220 pub raid: Option<Vec<String>>,
221 #[validate(custom(function = "is_attribute_ror_list"))]
225 #[serde(default)]
226 pub ror: Option<Vec<String>>,
227 #[eserde(compat)]
229 pub additional_type: Option<OrganizationType>,
230 #[validate(nested)]
232 #[serde(alias = "graphics")]
233 #[eserde(compat)]
234 pub media: Option<Vec<MediaObject>>,
235 #[validate(nested)]
237 #[eserde(compat)]
238 pub websites: Option<Vec<Website>>,
239 #[builder(default = Vec::<String>::new())]
243 pub keywords: Vec<Keyword>,
244 #[builder(default = Vec::<String>::new())]
254 #[serde(deserialize_with = "vec_string_trim")]
255 pub technology: Vec<String>,
256 pub sponsors: Option<Vec<String>>,
262 pub partners: Option<Vec<String>>,
269 pub related: Option<Vec<String>>,
271 #[eserde(compat)]
279 pub resources: Option<Vec<Resource>>,
280}
281#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema)]
285#[builder(start_fn = init, on(String, into))]
286#[serde(deny_unknown_fields, rename_all = "camelCase")]
287pub struct ResearchActivityMetadataContext {
288 pub classification: String,
290 pub archive: String,
292 pub draft: String,
294 pub status: String,
296 pub identifier: String,
298 pub doi: String,
300 pub raid: String,
302 pub ror: String,
304 pub additional_type: String,
306 pub media: String,
308 pub websites: String,
310 pub keywords: String,
312 pub technology: String,
314 pub sponsors: String,
316 pub partners: String,
318 pub related: String,
320 pub resources: String,
322}
323#[skip_serializing_none]
325#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
326#[builder(start_fn = init)]
327#[serde(deny_unknown_fields)]
328pub struct Sections {
329 #[validate(length(
333 min = 10,
334 max = "MAX_LENGTH_SECTION_MISSION",
335 message = "Mission is too long, reduce the length below 250 characters."
336 ))]
337 #[builder(default = "Purpose of the research".to_string())]
338 #[serde(alias = "introduction", deserialize_with = "string_trim")]
339 pub mission: String,
340 #[validate(length(
344 min = 10,
345 max = "MAX_LENGTH_SECTION_CHALLENGE",
346 message = "Challenge is too long, reduce the length below 500 characters."
347 ))]
348 #[builder(default = "Reason for the research".to_string())]
349 #[serde(deserialize_with = "string_trim")]
350 pub challenge: String,
351 #[validate(
357 length(min = 1, max = "MAX_COUNT_APPROACH", message = "Limit the number of approaches to 6"),
358 custom(function = "is_attribute_approach")
359 )]
360 #[builder(default = vec!["List of actions taken to perform the research".to_string()])]
361 #[serde(deserialize_with = "vec_string_trim")]
362 pub approach: Vec<String>,
363 #[validate(length(min = 1, max = "MAX_COUNT_IMPACT"), custom(function = "is_attribute_impact"))]
369 #[builder(default = vec!["List of tangible proof that validates the research approach".to_string()])]
370 #[serde(deserialize_with = "vec_string_trim")]
371 pub impact: Vec<String>,
372 #[validate(length(min = 1, max = 4, message = "Limit the number of achievements to 4"))]
377 pub achievement: Option<Vec<String>>,
378 #[validate(length(min = 1, max = "MAX_COUNT_CAPABILITIES"), custom(function = "is_attribute_capabilities"))]
386 pub capabilities: Option<Vec<String>>,
387 #[validate(nested)]
397 #[builder(default = Research::init().build())]
398 pub research: Research,
399}
400impl Default for ResearchActivity {
401 fn default() -> Self {
402 ResearchActivity::init().build()
403 }
404}
405impl Default for ResearchActivityContext {
406 fn default() -> Self {
407 ResearchActivityContext::init()
408 .meta(schema_org("CreativeWork"))
409 .title(dcterms("title"))
410 .subtitle(schema_org("alternativeHeadline"))
411 .sections(schema_org("CreativeWork"))
412 .contact(dcat("contactPoint"))
413 .notes(schema_org("Text"))
414 .build()
415 }
416}
417impl Default for ResearchActivityMetadata {
418 fn default() -> Self {
419 ResearchActivityMetadata::init().build()
420 }
421}
422impl Default for ResearchActivityMetadataContext {
423 fn default() -> Self {
424 ResearchActivityMetadataContext::init()
425 .classification(schema_org("DefinedTerm"))
426 .archive(schema_org("Boolean"))
427 .draft(schema_org("Boolean"))
428 .status(schema_org("DefinedTerm"))
429 .identifier(codemeta("identifier"))
430 .doi(bibo("doi"))
431 .raid(DEFAULT_RAID_SCHEMA_URI)
432 .ror(DEFAULT_ROR_SCHEMA_URI)
433 .additional_type(schema_org("additionalType"))
434 .media(schema_org("MediaObject"))
435 .websites(schema_org("WebSite"))
436 .keywords(dcat("keyword"))
437 .technology(schema_org("DefinedTerm"))
438 .sponsors(codemeta("sponsor"))
439 .partners(schema_org("Text"))
440 .related(schema_org("Text"))
441 .resources(schema_org("DefinedTerm"))
442 .build()
443 }
444}
445impl Default for Sections {
446 fn default() -> Self {
447 Sections::init().build()
448 }
449}
450impl From<&ResearchActivity> for Cff {
451 fn from(rad: &ResearchActivity) -> Self {
452 let sections = rad.sections.clone();
453 let contact = rad.contact.clone();
454 let meta = rad.meta.clone();
455 let title = rad.title.clone();
456 let ContactPoint {
457 given_name,
458 family_name,
459 identifier: orcid,
460 email,
461 organization,
462 affiliation,
463 ..
464 } = contact;
465 let person_affiliation = affiliation.or(if organization.is_empty() { None } else { Some(organization) });
466 let person = Agent::Person(Person {
467 given_names: Some(given_name),
468 family_names: Some(family_name),
469 orcid,
470 email: Some(email),
471 affiliation: person_affiliation,
472 address: None,
473 alias: None,
474 city: None,
475 country: None,
476 fax: None,
477 name_particle: None,
478 name_suffix: None,
479 postal_code: None,
480 region: None,
481 tel: None,
482 website: None,
483 });
484 let keywords = if meta.keywords.is_empty() { None } else { Some(meta.keywords) };
485 let publication_identifiers = meta.doi.unwrap_or_default();
486 let doi = match publication_identifiers.as_slice() {
487 | [single] if matches!(PublicationIdentifierType::from(single.as_str()), PublicationIdentifierType::Doi(_)) => Some(single.clone()),
488 | _ => None,
489 };
490 let identifiers = doi.is_none().then(|| {
491 publication_identifiers
492 .into_iter()
493 .map(|value| match PublicationIdentifierType::from(value.as_str()) {
494 | PublicationIdentifierType::Doi(_) => Identifier {
495 description: None,
496 kind: IdentifierType::Doi,
497 value,
498 },
499 | _ => Identifier {
500 description: Some("arXiv identifier".to_string()),
501 kind: IdentifierType::Other,
502 value,
503 },
504 })
505 .collect::<Vec<_>>()
506 });
507 let identifiers = identifiers.filter(|values| !values.is_empty());
508 Cff {
509 abstract_text: Some(sections.mission),
510 authors: vec![person.clone()],
511 contact: Some(vec![person]),
512 keywords,
513 doi,
514 identifiers,
515 title,
516 ..Cff::default()
517 }
518 }
519}
520impl From<ResearchActivity> for Cff {
521 fn from(rad: ResearchActivity) -> Self {
522 Cff::from(&rad)
523 }
524}
525impl Hash for ResearchActivity {
526 fn hash<H: Hasher>(&self, state: &mut H) {
527 self.meta.identifier.hash(state);
528 }
529}
530#[cfg(feature = "std")]
531impl InputOutput for ResearchActivity {
532 fn read(path: impl Into<PathBuf>) -> ApiResult<Self> {
533 let source = path.into().clone();
534 let result = match MimeType::from_path(&source) {
535 | MimeType::Json => Self::read_json(source.clone()),
536 | MimeType::Jsonc => Self::read_jsonc(source.clone()),
537 | MimeType::Markdown => Self::read_markdown(source.clone()),
538 | MimeType::Yaml => Self::read_yaml(source.clone()),
539 | _ => Err(eyre!("Unsupported research activity data file extension")),
540 };
541 if let Ok(data) = &result {
542 debug!(path = source.to_string_lossy().to_string(), "=> {}", Label::using());
543 debug!("=> {} Research activity data = {:#?}", Label::using(), data.dimmed().cyan());
544 }
545 result
546 }
547 fn read_json(path: PathBuf) -> ApiResult<Self> {
548 read_file(path.clone()).and_then(|content| {
549 eserde::json::from_str::<Self>(&content).map_err(|errors| {
550 let details: Vec<String> = errors
551 .iter()
552 .map(|e| format!("{}: {}", e.path().map_or("root".into(), |p| p.to_string()), e.message()))
553 .collect();
554 eyre!("{}", details.join("\n"))
555 })
556 })
557 }
558 fn read_jsonc(path: PathBuf) -> ApiResult<Self> {
559 read_file(path.clone()).and_then(|content| {
560 jsonc_parse_value(&content).and_then(|value| {
561 serde_json::to_string(&value)
562 .map_err(|why| eyre!("JSONC conversion error — {why}"))
563 .and_then(|json| {
564 eserde::json::from_str::<Self>(&json).map_err(|errors| {
565 let details: Vec<String> = errors
566 .iter()
567 .map(|e| format!("{}: {}", e.path().map_or("root".into(), |p| p.to_string()), e.message()))
568 .collect();
569 eyre!("{}", details.join("\n"))
570 })
571 })
572 })
573 })
574 }
575 fn read_markdown(path: PathBuf) -> ApiResult<Self> {
576 read_file(path).and_then(|content| Self::from_markdown(&content).map_err(|why| eyre!("{why}")))
577 }
578 fn read_yaml(path: PathBuf) -> ApiResult<Self> {
579 read_file(path.clone()).and_then(|content| serde_norway::from_str(&content).map_err(|why| eyre!("Failed to parse YAML RAD — {why}")))
580 }
581 fn write(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
582 let output = path.into();
583 let mime = MimeType::from_path(&output);
584 match mime {
585 | MimeType::Cff => self.write_cff(output),
586 | MimeType::Json | MimeType::Jsonc => self.write_json(output),
587 | MimeType::Markdown => self.write_markdown(output),
588 | MimeType::Yaml => self.write_yaml(output),
589 | _ => Err(eyre!("Unsupported research activity data file extension for writing")),
590 }
591 }
592 fn write_cff(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
593 let output = path.into().with_extension("cff");
594 let data: Cff = self.into();
595 serde_norway::to_string(&data)
596 .map_err(|why| eyre!("Failed to serialize RAD CITATION.cff — {why}"))
597 .and_then(|content| write_file(output, content))
598 }
599 fn write_json(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
600 let output = path.into();
601 serde_json::to_string_pretty(&self)
602 .map_err(|why| eyre!("Failed to serialize JSON RAD — {why}"))
603 .and_then(|content| write_file(output, content))
604 }
605 fn write_markdown(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
606 let output = path.into().with_extension("md");
607 let content = self.to_markdown();
608 write_file(output, content)
609 }
610 fn write_yaml(&self, path: impl Into<PathBuf>) -> ApiResult<()> {
611 let output = path.into().with_extension("yaml");
612 serde_json::to_value(self)
613 .map_err(|why| eyre!("Failed to convert RAD to value for YAML serialization — {why}"))
614 .and_then(|value| serde_norway::to_string(&value).map_err(|why| eyre!("Failed to serialize YAML RAD — {why}")))
615 .and_then(|content| write_file(output, content))
616 }
617}
618impl LinkedData for ResearchActivity {
619 fn with_context(&self) -> Self {
620 Self {
621 context: Some(ResearchActivityContext::default()),
622 research_activity_type: None,
623 meta: self.meta.with_context(),
624 contact: self.contact.with_context(),
625 ..self.clone().copy()
626 }
627 }
628}
629impl LinkedData for ResearchActivityMetadata {
630 fn with_context(&self) -> Self {
631 Self {
632 context: Some(ResearchActivityMetadataContext::default()),
633 metadata_type: None,
634 ..self.clone()
635 }
636 }
637}
638impl MarkdownSupport for Research {
639 fn to_markdown(&self) -> String {
640 let Research { focus, areas } = self;
641 let focus = focus.to_markdown_text();
642 let areas = areas.iter().map(MarkdownSupport::to_markdown_text).collect::<Vec<_>>();
643 format!(
644 r#"
645## Focus
646{focus}
647
648## Areas{}"#,
649 areas.to_markdown(),
650 )
651 }
652}
653impl MarkdownSupport for ResearchActivity {
654 fn to_markdown(&self) -> String {
655 Document::from(self).to_markdown()
656 }
657 #[cfg(feature = "std")]
658 fn from_markdown(content: &str) -> Result<Self, String> {
659 Document::try_from(content).and_then(Self::try_from).map_err(|why| why.to_string())
660 }
661 #[cfg(not(feature = "std"))]
662 fn from_markdown(_content: &str) -> Result<Self, String> {
663 Err("Markdown parsing requires the std feature".to_string())
664 }
665}
666impl MarkdownSupport for Sections {
667 fn to_markdown(&self) -> String {
668 let Sections {
669 mission,
670 challenge,
671 approach,
672 impact,
673 achievement,
674 capabilities,
675 research,
676 } = self;
677 let mission = mission.to_markdown_text();
678 let challenge = challenge.to_markdown_text();
679 let approach = approach.iter().map(MarkdownSupport::to_markdown_text).collect::<Vec<_>>();
680 let impact = impact.iter().map(MarkdownSupport::to_markdown_text).collect::<Vec<_>>();
681 let achievement = achievement
682 .as_ref()
683 .map(|values| values.iter().map(MarkdownSupport::to_markdown_text).collect::<Vec<_>>())
684 .map(|values| format!("\n\n## Achievement{}", values.to_markdown()))
685 .unwrap_or_default();
686 let capabilities = capabilities
687 .as_ref()
688 .map(|values| values.iter().map(MarkdownSupport::to_markdown_text).collect::<Vec<_>>())
689 .map(|values| format!("\n\n## Capabilities{}", values.to_markdown()))
690 .unwrap_or_default();
691 format!(
692 r#"
693## Mission
694{}
695
696## Challenge
697{}
698
699## Approach{}
700
701## Impact{}{}{}
702{}"#,
703 mission,
704 challenge,
705 approach.to_markdown(),
706 impact.to_markdown(),
707 achievement,
708 capabilities,
709 research.to_markdown(),
710 )
711 }
712}
713impl ResearchActivity {
714 pub fn new() -> Self {
716 ResearchActivity::default()
717 }
718 #[cfg(feature = "std")]
720 pub fn serialize_as(&self, mime: &MimeType) -> ApiResult<String> {
721 match mime {
722 | MimeType::Json | MimeType::Jsonc => serde_json::to_string_pretty(self).map_err(|why| eyre!("Failed to serialize JSON RAD — {why}")),
723 | MimeType::Markdown => Ok(self.to_markdown()),
724 | MimeType::Yaml => serde_json::to_value(self)
725 .map_err(|why| eyre!("Failed to convert RAD to value for YAML serialization — {why}"))
726 .and_then(|value| serde_norway::to_string(&value).map_err(|why| eyre!("Failed to serialize YAML RAD — {why}"))),
727 | _ => Err(eyre!("Unsupported mime type for research activity serialization: {mime:?}")),
728 }
729 }
730 pub fn is_markdown<T>(source: T) -> bool
732 where
733 Text: TryFrom<T>,
734 {
735 Text::try_from(source).ok().is_some_and(|text| Document::recognizes(text.content()))
736 }
737 #[cfg(feature = "std")]
739 pub fn to_schema(format: &str) {
740 let schema = schema_for!(ResearchActivity);
741 let output = match format.to_lowercase().as_str() {
742 | "yaml" | "yml" => serde_norway::to_string(&schema).unwrap_or_default(),
743 | _ => serde_json::to_string_pretty(&schema).unwrap_or_default(),
744 };
745 println!("{output}");
746 }
747 pub fn copy(&self) -> ResearchActivity {
749 let ResearchActivity {
750 meta,
751 title,
752 subtitle,
753 sections,
754 contact,
755 notes,
756 ..
757 } = self.clone();
758 ResearchActivity::init()
759 .meta(meta)
760 .title(title)
761 .maybe_subtitle(subtitle)
762 .sections(sections)
763 .contact(contact)
764 .maybe_notes(notes)
765 .build()
766 }
767 pub fn format(self) -> ResearchActivity {
772 let Self { meta, contact, .. } = self.clone();
773 Self {
774 meta: meta.format(),
775 contact: contact.format(),
776 ..self
777 }
778 }
779 #[cfg(feature = "std")]
784 pub fn format_with(self, context: Option<PathBuf>) -> ResearchActivity {
785 let Self { meta, contact, .. } = self.clone();
786 Self {
787 meta: meta.format_with(context.clone()),
788 contact: contact.format_with(context.clone()),
789 ..self
790 }
791 .format()
792 }
793}
794impl ResearchActivityMetadata {
795 pub(crate) fn first_image(self) -> Option<MediaObject> {
796 match self.media {
797 | Some(values) => values.into_iter().filter(|x| x.clone().is_image()).collect::<Vec<_>>().first().cloned(),
798 | None => None,
799 }
800 }
801 pub fn first_image_content_url(self) -> String {
803 match self.first_image() {
804 | Some(media) => match media {
805 | MediaObject::Image(ImageObject { content_url, .. }) => match content_url {
806 | Some(value) if !value.is_empty() => value.clone().trim().to_string(),
807 | Some(_) | None => DEFAULT_GRAPHIC_HREF.to_string(),
808 },
809 | _ => DEFAULT_GRAPHIC_HREF.to_string(),
810 },
811 | None => DEFAULT_GRAPHIC_HREF.to_string(),
812 }
813 }
814 pub fn first_image_caption(self) -> String {
816 match self.first_image() {
817 | Some(MediaObject::Image(ImageObject { caption, .. })) => match caption.clone() {
818 | value if !value.is_empty() => value.clone(),
819 | _ => DEFAULT_GRAPHIC_CAPTION.to_string(),
820 },
821 | Some(_) | None => DEFAULT_GRAPHIC_CAPTION.to_string(),
822 }
823 }
824 pub fn format(self) -> Self {
826 let keywords = Vocabulary::Keywords.resolve(Some(self.keywords.clone()));
827 let technology = Vocabulary::Technology.resolve(Some(self.technology.clone()));
828 let partners = match Vocabulary::Partners.resolve(self.partners.clone()) {
829 | values if !values.is_empty() => Some(values),
830 | _ => None,
831 };
832 let sponsors = match Vocabulary::Sponsors.resolve(self.sponsors.clone()) {
833 | values if !values.is_empty() => Some(values),
834 | _ => None,
835 };
836 Self {
837 keywords,
838 technology,
839 partners,
840 sponsors,
841 ..self
842 }
843 }
844 #[cfg(feature = "std")]
846 pub fn format_with(self, path: Option<PathBuf>) -> Self {
847 let path_parent = match path {
848 | Some(value) => parent(value),
849 | None => PathBuf::from("."),
850 };
851 debug!(path = path_parent.to_absolute_path(), "=> {} Parent directory", Label::using());
852 let name = match image_paths(&path_parent) {
853 | value if !value.is_empty() => value.first().and_then(|v| v.file_name().map(|f| f.to_string_lossy().to_string())),
854 | _ => None,
855 };
856 let media = match name {
857 | Some(value) => {
858 debug!(value, "=> {} First image", Label::using());
859 let first_graphic = match self.media.clone() {
860 | Some(values) if !values.is_empty() => {
861 let caption = self.clone().first_image_caption();
862 let image_data = ImageObject::init().caption(caption.to_string()).content_url(value.clone()).build();
863 MediaObject::Image(image_data)
864 }
865 | Some(_) | None => {
866 let image_data = ImageObject::init().caption("".to_string()).content_url(value.clone()).build();
867 MediaObject::Image(image_data)
868 }
869 };
870 let rest = match self.clone().media {
871 | Some(values) if !values.is_empty() => values.into_iter().skip(1).collect::<Vec<_>>(),
872 | Some(_) | None => vec![],
873 };
874 Some([vec![first_graphic], rest].concat())
875 }
876 | None => self.media.clone(),
877 };
878 Self { media, ..self }.format()
879 }
880}
881impl ToProse for ResearchActivity {
882 fn to_prose(&self) -> String {
883 let websites = self
884 .meta
885 .websites
886 .clone()
887 .unwrap_or_default()
888 .into_iter()
889 .map(|website| website.to_markdown())
890 .collect::<Vec<String>>()
891 .join("\n");
892 let sections = self.sections.to_markdown();
893 match &self.subtitle {
894 | Some(subtitle) => format!(
895 r#"{title}
896{subtitle}
897{sections}
898{websites}"#,
899 title = self.title
900 ),
901 | None => format!(
902 r#"{title}
903{sections}
904{websites}"#,
905 title = self.title
906 ),
907 }
908 }
909}
910impl Vocabulary {
911 fn resolve(self, values: Option<Vec<String>>) -> Vec<String> {
913 ControlledVocabulary::normalize(&self.to_string(), values.unwrap_or_default()).into_values()
914 }
915}
916#[cfg(feature = "std")]
917pub(crate) fn resolve_from_csv_asset(name: String, value: String) -> Option<String> {
918 let data = Constant::csv(&name);
919 resolve_from_list_of_lists(value, data, name)
920}
921#[cfg(not(feature = "std"))]
922pub(crate) fn resolve_from_csv_asset(_name: String, value: String) -> Option<String> {
923 Some(value)
924}
925#[cfg(feature = "std")]
926pub(crate) fn resolve_from_list_of_lists<I: IntoIterator<Item = Vec<String>>>(value: String, data: I, name: String) -> Option<String> {
927 fn match_list<I: IntoIterator<Item = String> + Clone>(value: String, values: I) -> Vec<(String, u32)> {
928 let pattern = Pattern::parse(&value, CaseMatching::Ignore, Normalization::Smart);
929 let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
930 pattern.match_list(values.clone(), &mut matcher)
931 }
932 fn print_resolution(output: Option<String>, value: String, name: String) {
933 let label = name.to_case(Case::Title);
934 match output {
935 | Some(resolved) => {
936 if resolved.eq(&value.to_string()) {
937 trace!("=> {} {} = \"{}\"", Label::using(), label, value.clone());
938 } else {
939 debug!(input = value.clone(), resolved, "=> {} {}", Label::found(), label);
940 }
941 }
942 | None => {
943 debug!(value = value.clone(), "=> {} {}", Label::not_found(), label);
944 }
945 };
946 }
947 let output = data
948 .into_iter()
949 .flat_map(|values| {
950 let sanitized = value.normalize();
951 let matched = match_list(sanitized, values.clone().into_iter().take(4));
952 trace!("{} => {:?}", value.clone(), matched.clone());
953 if matched.clone().is_empty() {
954 None
955 } else {
956 match values.first() {
957 | Some(x) => {
958 if value.eq(x) {
959 Some((x.into(), 10000))
960 } else {
961 let score = matched.into_iter().map(|(_, score)| score).max();
962 match score {
963 | Some(value) if value > 0 => Some((x.to_string(), value)),
964 | Some(_) | None => None,
965 }
966 }
967 }
968 | None => None,
969 }
970 }
971 })
972 .max_by_key(|(_, score)| *score)
973 .map(|(x, _)| x.to_string());
974 print_resolution(output.clone(), value, name);
975 output
976}
977#[cfg(not(feature = "std"))]
978#[allow(dead_code)]
979pub(crate) fn resolve_from_list_of_lists<I: IntoIterator<Item = Vec<String>>>(value: String, _data: I, _name: String) -> Option<String> {
980 Some(value)
981}
982fn validation_error_with_index(code: &'static str, message: String, index: usize) -> ValidationError {
983 let mut err = ValidationError::new(code).with_message(message.into());
984 err.add_param("index".into(), &index);
985 err
986}
987fn validation_error_with_index_and_length(code: &'static str, message: String, index: usize, length: usize) -> ValidationError {
988 let mut err = validation_error_with_index(code, message, index);
989 err.add_param("length".into(), &length);
990 err
991}
992pub(crate) fn is_attribute_approach(value: &[String]) -> ValidationResult {
994 const CODE: &str = "sections.approach";
995 const MAX_LENGTH: usize = MAX_LENGTH_APPROACH;
996 value
997 .iter()
998 .enumerate()
999 .find(|(_, x)| x.len() > MAX_LENGTH)
1000 .map(|(index, x)| {
1001 let length = x.len();
1002 validation_error_with_index_and_length(
1003 CODE,
1004 format!("Each approach statement should be less than {MAX_LENGTH} characters"),
1005 index,
1006 length,
1007 )
1008 })
1009 .map_or(Ok(()), Err)
1010}
1011pub(crate) fn is_attribute_areas(value: &[String]) -> ValidationResult {
1013 const CODE: &str = "sections.areas";
1014 const MAX_LENGTH: usize = MAX_LENGTH_RESEARCH_AREA;
1015 value
1016 .iter()
1017 .enumerate()
1018 .find(|(_, x)| x.len() > MAX_LENGTH)
1019 .map(|(index, x)| {
1020 let length = x.len();
1021 validation_error_with_index_and_length(CODE, format!("Each area should be less than {MAX_LENGTH} characters"), index, length)
1022 })
1023 .map_or(Ok(()), Err)
1024}
1025pub(crate) fn is_attribute_books(value: &[String]) -> ValidationResult {
1027 const CODE: &str = "meta.books";
1028 value
1029 .iter()
1030 .position(|x| !x.is_isbn())
1031 .map(|index| validation_error_with_index(CODE, "Every book should be a valid ISBN".to_string(), index))
1032 .map_or(Ok(()), Err)
1033}
1034pub(crate) fn is_attribute_capabilities(value: &[String]) -> ValidationResult {
1036 const CODE: &str = "sections.capabilities";
1037 const MAX_LENGTH: usize = MAX_LENGTH_CAPABILIY;
1038 value
1039 .iter()
1040 .enumerate()
1041 .find(|(_, x)| x.len() > MAX_LENGTH)
1042 .map(|(index, x)| {
1043 let length = x.len();
1044 validation_error_with_index_and_length(
1045 CODE,
1046 format!("Each capability should be less than {MAX_LENGTH} characters"),
1047 index,
1048 length,
1049 )
1050 })
1051 .map_or(Ok(()), Err)
1052}
1053pub(crate) fn is_attribute_publication_identifier_list(value: &[String]) -> ValidationResult {
1055 const CODE: &str = "meta.doi";
1056 value
1057 .iter()
1058 .position(|identifier| matches!(PublicationIdentifierType::from(identifier.as_str()), PublicationIdentifierType::Unknown))
1059 .map(|index| {
1060 validation_error_with_index(
1061 CODE,
1062 "Every publication identifier should be a valid DOI or arXiv identifier".to_string(),
1063 index,
1064 )
1065 })
1066 .map_or(Ok(()), Err)
1067}
1068pub(crate) fn is_attribute_doi_list(value: &[String]) -> ValidationResult {
1070 const CODE: &str = "meta.raid";
1071 value
1072 .iter()
1073 .position(|identifier| is_doi(identifier).is_err())
1074 .map(|index| validation_error_with_index(CODE, "Every DOI should be valid".to_string(), index))
1075 .map_or(Ok(()), Err)
1076}
1077pub(crate) fn is_attribute_ror_list(value: &[String]) -> ValidationResult {
1079 const CODE: &str = "meta.ror";
1080 value
1081 .iter()
1082 .position(|x| is_ror(x).is_err())
1083 .map(|index| validation_error_with_index(CODE, "Every ROR should be valid".to_string(), index))
1084 .map_or(Ok(()), Err)
1085}
1086pub(crate) fn is_attribute_impact(value: &[String]) -> ValidationResult {
1088 const CODE: &str = "sections.impact";
1089 const MAX_LENGTH: usize = MAX_LENGTH_IMPACT;
1090 value
1091 .iter()
1092 .enumerate()
1093 .find(|(_, x)| x.len() > MAX_LENGTH)
1094 .map(|(index, x)| {
1095 let length = x.len();
1096 validation_error_with_index_and_length(
1097 CODE,
1098 format!("Each impact statement should be less than {MAX_LENGTH} characters"),
1099 index,
1100 length,
1101 )
1102 })
1103 .or_else(|| {
1104 value
1105 .first()
1106 .and_then(|first| {
1107 let ends_with_period = first.trim().ends_with(".");
1108 value.iter().position(|x| x.trim().ends_with(".") != ends_with_period)
1109 })
1110 .map(|index| {
1111 validation_error_with_index(
1112 CODE,
1113 "Impact statements should be all sentences with periods or all phrases without periods".to_string(),
1114 index,
1115 )
1116 })
1117 })
1118 .or_else(|| {
1119 value
1120 .iter()
1121 .position(|x| {
1122 x.trim().chars().find(|c| c.is_alphabetic()).is_none_or(|letter| {
1123 let actual = letter.to_string();
1124 actual != actual.to_case(Case::Upper)
1125 })
1126 })
1127 .map(|index| validation_error_with_index(CODE, "Impact statements should begin with a capital letter".to_string(), index))
1128 })
1129 .map_or(Ok(()), Err)
1130}
1131
1132#[cfg(test)]
1133mod tests;