1use crate::analyzer::readability::ReadabilityType;
6use crate::analyzer::vale::{Vale, ValeConfig};
7use crate::analyzer::{StaticAnalyzer, StaticAnalyzerConfig};
8use crate::constants::*;
9use crate::util::*;
10use bon::{builder, Builder};
11use derive_more::Display;
12use fancy_regex::Regex;
13use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
14use nucleo_matcher::{Config, Matcher};
15use owo_colors::OwoColorize;
16use percy_dom::prelude::{html, IterableNodes, View, VirtualNode};
17use petgraph::graph::Graph;
18use rayon::prelude::*;
19use schemars::{schema_for, JsonSchema};
20use serde::{Deserialize, Serialize};
21use serde_repr::*;
22use serde_trim::*;
23use serde_with::skip_serializing_none;
24use std::hash::{Hash, Hasher};
25use std::num::NonZeroU64;
26use std::path::PathBuf;
27use titlecase::Titlecase;
28use tracing::{debug, error, info, trace};
29use validator::Validate;
30
31pub mod graph;
32pub mod raid;
33pub mod validate;
34use graph::*;
35use validate::*;
36
37pub type Keyword = String;
54#[derive(Clone, Debug, Default, Display, Serialize, Deserialize, PartialEq, PartialOrd, JsonSchema)]
58#[serde(rename_all = "lowercase")]
59pub enum ClassificationLevel {
60 #[default]
62 #[display("UNCLASSIFIED")]
63 Unclassified,
64 #[display("CONFIDENTIAL")]
68 Confidential,
69 #[display("SECRET")]
73 Secret,
74 #[display("TOP SECRET")]
78 #[serde(alias = "top secret")]
79 TopSecret,
80}
81#[derive(Clone, Debug, Serialize, Deserialize, Display)]
82enum FuzzyValue {
83 #[display("partners")]
84 Partner,
85 #[display("keywords")]
87 Keyword,
88 #[display("sponsors")]
89 Sponsor,
90 #[display("technology")]
91 Technology,
92}
93#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
97#[serde(untagged)]
98pub enum MediaObject {
99 Image(ImageObject),
101 Video(VideoObject),
103}
104#[derive(Clone, Debug, Serialize, Deserialize, Display, Hash, PartialEq, PartialOrd, JsonSchema)]
106#[serde(rename_all = "lowercase")]
107pub enum OrganizationType {
108 #[display("agency")]
110 Agency,
111 #[display("center")]
113 Center,
114 #[display("consortium")]
116 Consortium,
117 #[display("directorate")]
119 Directorate,
120 #[display("division")]
122 Division,
123 #[display("facility")]
127 Facility,
128 #[display("FFRDC")]
130 Ffrdc,
131 #[display("group")]
133 Group,
134 #[display("office")]
136 Office,
137 #[display("program")]
139 Program,
140}
141#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
143#[serde(untagged)]
144pub enum Other {
145 Unformatted(String),
147 Formatted(Notes),
149}
150#[derive(Clone, Debug, Default, Display, Serialize_repr, Deserialize_repr, PartialEq, PartialOrd, JsonSchema)]
157#[repr(u8)]
158#[serde(deny_unknown_fields)]
159pub enum TechnologyReadinessLevel {
160 #[display("Greenfield Research")]
164 Principles = 0,
165 #[default]
169 #[display("Basic Research")]
170 Research = 1,
171 #[display("Technology Concept")]
175 Concept = 2,
176 #[display("Feasible")]
180 Feasible = 3,
181 #[display("Developing")]
185 Developing = 4,
186 #[display("Developed")]
190 Developed = 5,
191 #[display("Prototype")]
195 Prototype = 6,
196 #[display("Operational")]
200 Operational = 7,
201 #[display("Mission Ready")]
205 MissionReady = 8,
206 #[display("Mission Capable")]
210 MissionCapable = 9,
211}
212#[derive(Builder, Clone, Debug, Serialize, Deserialize, Validate, JsonSchema)]
216#[builder(start_fn = init)]
217#[serde(deny_unknown_fields, rename_all = "camelCase")]
218pub struct ContactPoint {
219 #[builder(default = "Researcher".to_string())]
230 #[serde(alias = "title", deserialize_with = "string_trim")]
231 pub job_title: String,
232 #[builder(default = "First".to_string())]
236 #[serde(alias = "first", deserialize_with = "string_trim")]
237 pub given_name: String,
238 #[builder(default = "Last".to_string())]
242 #[serde(alias = "last", deserialize_with = "string_trim")]
243 pub family_name: String,
244 #[validate(email(message = "Please provide a valid email"))]
248 #[builder(default = "first_last@example.com".to_string())]
249 #[serde(deserialize_with = "string_trim")]
250 pub email: String,
251 #[validate(custom(function = "is_phone_number"))]
255 #[builder(default = "123-456-7890".to_string())]
256 #[serde(alias = "phone", deserialize_with = "string_trim")]
257 pub telephone: String,
258 #[validate(url(message = "Please provide a valid profile URL"))]
262 #[builder(default = "https://example.com".to_string())]
263 #[serde(alias = "profile", deserialize_with = "string_trim")]
264 pub url: String,
265 #[builder(default = "Some Organization".to_string())]
269 #[serde(deserialize_with = "string_trim")]
270 pub organization: String,
271 pub affiliation: Option<String>,
277}
278#[skip_serializing_none]
282#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
283#[builder(start_fn = init)]
284#[serde(deny_unknown_fields, rename_all = "camelCase")]
285pub struct ImageObject {
286 #[validate(length(max = "MAX_LENGTH_IMAGE_CAPTION", message = "Caption is too long, please reduce the length below 100."))]
288 #[serde(deserialize_with = "string_trim")]
289 pub caption: String,
290 #[serde(alias = "size")]
296 pub content_size: Option<NonZeroU64>,
297 #[validate(custom(function = "has_image_extension"))]
299 #[serde(alias = "url", alias = "href")]
300 pub content_url: Option<String>,
301 pub height: Option<NonZeroU64>,
307 pub width: Option<NonZeroU64>,
313}
314#[skip_serializing_none]
316#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
317#[builder(start_fn = init)]
318#[serde(deny_unknown_fields, rename_all = "camelCase")]
319pub struct Metadata {
320 pub classification: Option<ClassificationLevel>,
322 pub trl: Option<TechnologyReadinessLevel>,
324 #[builder(default = false)]
328 pub archive: bool,
329 #[builder(default = true)]
333 pub draft: bool,
334 #[validate(custom(function = "is_kebabcase"))]
341 #[builder(default = "some-research-project".to_string())]
342 #[serde(alias = "id", rename = "identifier", deserialize_with = "string_trim")]
343 pub identifier: String,
344 #[validate(custom(function = "validate_attribute_doi"))]
348 #[serde(default)]
349 pub doi: Option<Vec<String>>,
350 #[validate(custom(function = "is_list_url"))]
352 #[serde(default)]
353 pub publications: Option<Vec<String>>,
354 #[validate(nested)]
358 #[serde(default)]
359 pub raid: Option<raid::Metadata>,
360 #[validate(custom(function = "validate_attribute_ror"))]
364 #[serde(default)]
365 pub ror: Option<Vec<String>>,
366 pub additional_type: Option<OrganizationType>,
370 #[serde(alias = "graphics")]
372 pub media: Option<Vec<MediaObject>>,
373 #[validate(nested)]
375 pub websites: Option<Vec<Website>>,
376 #[builder(default = Vec::<String>::new())]
378 pub keywords: Vec<Keyword>,
379 #[builder(default = Vec::<String>::new())]
389 #[serde(deserialize_with = "vec_string_trim")]
390 pub technology: Vec<String>,
391 pub sponsors: Option<Vec<String>>,
397 pub partners: Option<Vec<String>>,
404 pub related: Option<Vec<String>>,
408}
409#[skip_serializing_none]
413#[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, Validate)]
414#[serde(deny_unknown_fields)]
415pub struct Notes {
416 pub managers: Option<Vec<String>>,
418 pub programs: Option<Vec<String>>,
420 #[serde(default, deserialize_with = "option_string_trim")]
422 pub presentation: Option<String>,
423}
424#[skip_serializing_none]
430#[derive(Clone, Debug, Serialize, Deserialize, Display, Hash, PartialEq, PartialOrd)]
431#[display("Organization ({additional_type}) - {name})")]
432#[serde(deny_unknown_fields, rename_all = "camelCase")]
433pub struct Organization {
434 #[serde(deserialize_with = "string_trim")]
438 pub name: String,
439 #[serde(default, deserialize_with = "option_string_trim")]
443 pub ror: Option<String>,
444 #[serde(default, deserialize_with = "option_string_trim")]
448 pub alternative_name: Option<String>,
449 pub additional_type: OrganizationType,
453 pub keywords: Option<Vec<Keyword>>,
455 pub member: Vec<Organization>,
459}
460#[skip_serializing_none]
465#[derive(Builder, Clone, Debug, Display, Deserialize, Serialize, JsonSchema, Validate)]
466#[builder(start_fn = init)]
467#[display("Research Activity ({title})")]
468#[serde(deny_unknown_fields)]
469pub struct ResearchActivity {
470 #[validate(nested)]
472 #[builder(default)]
473 pub meta: Metadata,
474 #[validate(length(min = 4, max = "MAX_LENGTH_TITLE"))]
476 #[builder(default = "Research Activity Title".to_string())]
477 #[serde(deserialize_with = "string_trim")]
478 pub title: String,
479 #[validate(length(max = "MAX_LENGTH_SUBTITLE", message = "Subtitle is too long, please reduce the length below 75."))]
481 #[serde(default, deserialize_with = "option_string_trim")]
482 pub subtitle: Option<String>,
483 #[validate(nested)]
485 #[builder(default)]
486 pub sections: Sections,
487 #[validate(nested)]
489 #[builder(default)]
490 pub contact: ContactPoint,
491 pub notes: Option<Other>,
493}
494#[skip_serializing_none]
498#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
499#[serde(deny_unknown_fields, rename_all = "camelCase")]
500pub struct VideoObject {
501 #[serde(alias = "size")]
505 pub content_size: Option<NonZeroU64>,
506 #[validate(url)]
508 #[serde(alias = "url", alias = "href")]
509 pub content_url: Option<String>,
510 #[serde(deserialize_with = "string_trim")]
514 pub description: String,
515 pub duration: Option<String>,
520 pub height: Option<NonZeroU64>,
524 pub width: Option<NonZeroU64>,
528}
529#[derive(Clone, Debug, Serialize, Deserialize, Validate, JsonSchema)]
549#[serde(deny_unknown_fields)]
550pub struct Website {
551 #[serde(alias = "title", deserialize_with = "string_trim")]
555 pub description: String,
556 #[validate(url(message = "Please provide a valid URL"))]
558 #[serde(deserialize_with = "string_trim")]
559 pub url: String,
560}
561#[skip_serializing_none]
563#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
564#[builder(start_fn = init)]
565#[serde(deny_unknown_fields)]
566pub struct Sections {
567 #[validate(length(
571 min = 10,
572 max = "MAX_LENGTH_SECTION_MISSION",
573 message = "Mission is too long, please reduce the length below 250."
574 ))]
575 #[builder(default = "Purpose of the research".to_string())]
576 #[serde(alias = "introduction", deserialize_with = "string_trim")]
577 pub mission: String,
578 #[validate(length(
582 min = 10,
583 max = "MAX_LENGTH_SECTION_CHALLENGE",
584 message = "Challenge is too long, please reduce the length below 500."
585 ))]
586 #[builder(default = "Reason for the research".to_string())]
587 #[serde(deserialize_with = "string_trim")]
588 pub challenge: String,
589 #[validate(
595 length(min = 1, max = "MAX_COUNT_APPROACH", message = "Please limit the number of approaches to 6"),
596 custom(function = "validate_attribute_approach")
597 )]
598 #[builder(default = vec!["List of actions taken to perform the research".to_string()])]
599 #[serde(deserialize_with = "vec_string_trim")]
600 pub approach: Vec<String>,
601 #[validate(length(min = 1, max = "MAX_COUNT_IMPACT"), custom(function = "validate_attribute_impact"))]
607 #[builder(default = vec!["List of tangible proof that validates the research approach".to_string()])]
608 #[serde(alias = "outcomes", deserialize_with = "vec_string_trim")]
609 pub impact: Vec<String>,
610 #[validate(length(min = 1, max = 4, message = "Please limit the number of achievements to 4"))]
615 pub achievement: Option<Vec<String>>,
616 #[validate(length(min = 1, max = "MAX_COUNT_CAPABILITIES"), custom(function = "validate_attribute_capabilities"))]
624 pub capabilities: Option<Vec<String>>,
625 #[validate(nested)]
635 #[builder(default = Research::init().build())]
636 pub research: Research,
637}
638#[derive(Builder, Clone, Debug, Serialize, Deserialize, JsonSchema, Validate)]
640#[builder(start_fn = init)]
641#[serde(deny_unknown_fields)]
642pub struct Research {
643 #[validate(length(
645 min = 10,
646 max = "MAX_LENGTH_RESEARCH_FOCUS",
647 message = "Focus is too long, please reduce the length below 150."
648 ))]
649 #[builder(default = "Focus of the research".to_string())]
650 #[serde(deserialize_with = "string_trim")]
651 pub focus: String,
652 #[validate(length(min = 1, max = "MAX_COUNT_RESEARCH_AREAS"), custom(function = "validate_attribute_areas"))]
654 #[builder(default = vec!["Areas of research".to_string()])]
655 #[serde(deserialize_with = "vec_string_trim")]
656 pub areas: Vec<String>,
657}
658impl Default for ContactPoint {
659 fn default() -> Self {
660 Self::init().build()
661 }
662}
663impl Default for Metadata {
664 fn default() -> Self {
665 Metadata::init().build()
666 }
667}
668impl Default for ResearchActivity {
669 fn default() -> Self {
670 ResearchActivity::init().build()
671 }
672}
673impl Default for Sections {
674 fn default() -> Self {
675 Sections::init().build()
676 }
677}
678impl Hash for ResearchActivity {
679 fn hash<H: Hasher>(&self, state: &mut H) {
680 self.meta.identifier.hash(state);
681 }
682}
683impl MediaObject {
684 pub fn content_url(self) -> Option<String> {
686 match self {
687 | MediaObject::Image(ImageObject { content_url, .. }) => content_url,
688 | MediaObject::Video(VideoObject { content_url, .. }) => content_url,
689 }
690 }
691 pub fn description(self) -> String {
693 match self {
694 | MediaObject::Image(ImageObject { caption, .. }) => caption,
695 | MediaObject::Video(VideoObject { description, .. }) => description,
696 }
697 }
698 pub fn is_image(self) -> bool {
700 match self {
701 | MediaObject::Image(_) => true,
702 | _ => false,
703 }
704 }
705}
706impl Metadata {
707 fn first_image(self) -> Option<MediaObject> {
708 match self.media {
709 | Some(values) => values.into_iter().filter(|x| x.clone().is_image()).collect::<Vec<_>>().first().cloned(),
710 | None => None,
711 }
712 }
713 pub fn first_image_content_url(self) -> String {
715 match self.first_image() {
716 | Some(media) => match media {
717 | MediaObject::Image(ImageObject { content_url, .. }) => match content_url {
718 | Some(value) if !value.is_empty() => value.clone().trim().to_string(),
719 | Some(_) | None => DEFAULT_GRAPHIC_HREF.to_string(),
720 },
721 | _ => DEFAULT_GRAPHIC_HREF.to_string(),
722 },
723 | None => DEFAULT_GRAPHIC_HREF.to_string(),
724 }
725 }
726 pub fn first_image_caption(self) -> String {
728 match self.first_image() {
729 | Some(MediaObject::Image(ImageObject { caption, .. })) => match caption.clone() {
730 | value if !value.is_empty() => value.clone(),
731 | _ => DEFAULT_GRAPHIC_CAPTION.to_string(),
732 },
733 | Some(_) | None => DEFAULT_GRAPHIC_CAPTION.to_string(),
734 }
735 }
736}
737impl Organization {
738 pub fn load() -> Vec<Organization> {
740 serde_json::from_str(&Constant::from_asset("organization.json")).unwrap()
741 }
742 pub fn member(self, label: &str) -> Option<Organization> {
744 self.members().into_iter().find(|Organization { name, .. }| name == label)
745 }
746 pub fn members(self) -> Vec<Organization> {
751 let organization = self;
752 let mut items = vec![organization.clone()];
753 let directorates = organization.member.clone();
754 for directorate in &directorates {
755 items.push(directorate.clone());
756 let divisions = directorate.member.clone();
757 for division in &divisions {
758 items.push(division.clone());
759 let groups = division.member.clone();
760 for group in &groups {
761 items.push(group.clone());
762 }
763 }
764 }
765 items
766 }
767 pub fn nearest(self, organization_type: OrganizationType) -> Option<Organization> {
769 let a = self.clone().additional_type.order();
770 let b = organization_type.order();
771 if a > b {
772 None
773 } else {
774 let ornl = Organization::load()[0].clone();
775 let graph = ornl.clone().to_graph();
776 let name = match b - a {
777 | 3 => Some(ornl.clone().name),
778 | 2 => match node_from_label(&graph, &self.name) {
779 | Some(node) => match node_parent(&graph, node) {
780 | Some(parent) => match node_parent(&graph, parent) {
781 | Some(grandparent) => node_name(&graph, grandparent),
782 | None => None,
783 },
784 | None => None,
785 },
786 | None => None,
787 },
788 | 1 => match node_from_label(&graph, &self.name) {
789 | Some(node) => match node_parent(&graph, node) {
790 | Some(parent) => node_name(&graph, parent),
791 | None => None,
792 },
793 | None => None,
794 },
795 | 0 => Some(self.name),
796 | _ => None,
797 };
798 match name {
799 | Some(value) => match ornl.member(&value) {
800 | Some(organization) => Some(organization),
801 | None => None,
802 },
803 | None => None,
804 }
805 }
806 }
807 pub fn to_graph(self) -> Graph<String, u8> {
809 let mut graph: Graph<String, u8, petgraph::Directed> = Graph::new();
810 let organization = &self;
811 let root = graph.add_node(organization.name.clone());
812 for directorate in organization.member.iter() {
813 let a = graph.add_node(directorate.name.clone());
814 graph.add_edge(root, a, 0);
815 for division in directorate.member.iter() {
816 let b = graph.add_node(division.name.clone());
817 graph.add_edge(a, b, 0);
818 for group in division.member.iter() {
819 let c = graph.add_node(group.name.clone());
820 graph.add_edge(b, c, 0);
821 }
822 }
823 }
824 graph
825 }
826}
827impl OrganizationType {
828 pub fn from_string(value: String) -> OrganizationType {
830 match value.to_lowercase().as_str() {
831 | "agency" => OrganizationType::Agency,
832 | "center" => OrganizationType::Center,
833 | "consortium" => OrganizationType::Consortium,
834 | "division" => OrganizationType::Division,
835 | "directorate" => OrganizationType::Directorate,
836 | "group" => OrganizationType::Group,
837 | "office" => OrganizationType::Office,
838 | "program" => OrganizationType::Program,
839 | "facility" => OrganizationType::Facility,
840 | "ffrdc" => OrganizationType::Ffrdc,
841 | _ => unreachable!(),
842 }
843 }
844 pub fn order(self) -> u8 {
846 match self {
847 | OrganizationType::Ffrdc | OrganizationType::Agency | OrganizationType::Consortium | OrganizationType::Office => 4,
848 | OrganizationType::Directorate => 3,
849 | OrganizationType::Division | OrganizationType::Center | OrganizationType::Program | OrganizationType::Facility => 2,
850 | OrganizationType::Group => 1,
851 }
852 }
853}
854impl ResearchActivity {
855 pub fn new() -> Self {
857 ResearchActivity::default()
858 }
859 pub fn to_schema() {
861 let schema = schema_for!(ResearchActivity);
862 println!("{}", serde_json::to_string_pretty(&schema).unwrap());
863 }
864 pub fn analyze(paths: Vec<PathBuf>, is_offline: bool) -> usize {
866 let config = ValeConfig::default().save();
867 let init = Vale::init().build();
868 let vale = if command_exists("vale") {
869 init.with_config(config).with_system_command()
870 } else if is_offline {
871 todo!("Support pointing to local vale executable");
872 } else {
873 init.download(Some(config))
874 };
875 match vale.clone().sync(is_offline) {
876 | Ok(_) => {
877 let results = paths.iter().map(|path| match ResearchActivity::read(path.into()) {
878 | Some(data) => vale
879 .clone()
880 .analyze(data.clone().meta.identifier, data.extract_prose(), Some("JSON".into())),
881 | None => {
882 error!("=> {} Read research activity data", Label::fail());
883 1
884 }
885 });
886 let output = results.collect::<Vec<usize>>();
887 output.into_iter().sum()
888 }
889 | Err(err) => {
890 error!("=> {} Vale sync - {}", Label::fail(), err);
891 1
892 }
893 }
894 }
895 pub fn calculate_readability(paths: Vec<PathBuf>, readability_type: ReadabilityType) -> usize {
897 paths
898 .par_iter()
899 .map(|path| match ResearchActivity::read(path.into()) {
900 | Some(data) => {
901 let index = readability_type.calculate(&data.extract_prose());
902 let maximum = readability_type.maximum_allowed();
903 debug!("{} Readability index = {index}", Label::using());
904 if index > maximum {
905 error!(
906 "=> {} {} has a readability index of {} (less than {} is recommended for {} metric)",
907 Label::fail(),
908 path.display(),
909 index.red().bold(),
910 maximum.cyan(),
911 readability_type.to_string().to_uppercase()
912 );
913 1
914 } else {
915 let score = format!("({} = {}/{})", readability_type.to_string().to_uppercase(), index, maximum);
916 info!(
917 "=> {} {} has {} {}",
918 Label::pass(),
919 path.display(),
920 "no readability issues".green().bold(),
921 score.dimmed()
922 );
923 0
924 }
925 }
926 | None => {
927 error!("=> {} Read research activity data", Label::fail());
928 1
929 }
930 })
931 .sum()
932 }
933 pub fn check(paths: Vec<PathBuf>, is_offline: bool) -> usize {
935 let runtime = tokio_runtime();
936 paths
937 .par_iter()
938 .map(|path| match ResearchActivity::read(path.into()) {
939 | Some(data) => {
940 let offline_issues = data
941 .clone()
942 .validation_issues()
943 .into_iter()
944 .map(|issue| issue.with_path(path.clone()))
945 .collect::<Vec<_>>();
946 let online_issues = runtime.block_on(async {
947 let mut issues: Vec<LinkCheck> = vec![];
948 if !is_offline {
949 let dois = match data.clone().meta.doi {
950 | Some(values) => values.into_iter().map(|doi| format!("https://doi.org/{doi}")).collect(),
951 | None => vec![],
952 };
953 let websites = match data.clone().meta.websites {
954 | Some(values) => values.into_iter().map(|Website { url, .. }| url).collect(),
955 | None => vec![],
956 };
957 for url in dois.into_iter().chain(websites.into_iter()) {
958 let result = LinkCheck::run(Some(url)).await;
959 issues.push(result);
960 }
961 }
962 issues
963 });
964 let offline_issue_count: usize = offline_issues
965 .into_iter()
966 .inspect(|issue| issue.clone().print())
967 .map(|issue| issue.issue_count())
968 .sum();
969 let online_issue_count: usize = online_issues
970 .into_iter()
971 .inspect(|issue| issue.clone().print())
972 .filter(|issue| !issue.success)
973 .count();
974 offline_issue_count + online_issue_count
975 }
976 | None => {
977 error!("=> {} Read research activity data at {}", Label::fail(), path.display());
978 0
979 }
980 })
981 .sum()
982 }
983 pub fn copy(self) -> ResearchActivity {
985 let ResearchActivity {
986 meta,
987 title,
988 subtitle,
989 sections,
990 contact,
991 notes,
992 } = self.clone();
993 ResearchActivity::init()
994 .meta(meta)
995 .title(title)
996 .maybe_subtitle(subtitle)
997 .sections(sections)
998 .contact(contact)
999 .maybe_notes(notes)
1000 .build()
1001 }
1002 pub fn extract_prose(self) -> String {
1004 let Sections {
1005 mission,
1006 challenge,
1007 approach,
1008 impact,
1009 research,
1010 ..
1011 } = self.sections;
1012 let Research { focus, areas } = research;
1013 let sections = format!(
1014 r#"
1015<!-- Introduction -->
1016{}
1017
1018<!-- Challenge -->
1019{}
1020
1021<!-- Approach -->
1022{}
1023
1024<!-- Impact -->
1025{}
1026
1027<!-- Focus -->
1028{}
1029
1030<!-- Areas -->
1031{}"#,
1032 mission,
1033 challenge,
1034 approach.into_iter().map(|x| format!("- {x}")).collect::<Vec<String>>().join("\n"),
1035 impact.into_iter().map(|x| format!("- {x}")).collect::<Vec<String>>().join("\n"),
1036 focus,
1037 areas.into_iter().map(|x| format!("- {x}")).collect::<Vec<String>>().join("\n")
1038 );
1039 match self.subtitle {
1040 | Some(subtitle) => format!(
1041 r#"# {}
1042> {}
1043{}"#,
1044 self.title, subtitle, sections
1045 ),
1046 | None => sections.to_string(),
1047 }
1048 }
1049 pub fn format(self, path: Option<PathBuf>) -> ResearchActivity {
1055 let mut clone = self.clone().copy();
1056 let path_parent = match path {
1057 | Some(value) => parent(value),
1058 | None => PathBuf::from("."),
1059 };
1060 let name = match image_paths(&path_parent) {
1061 | value if !value.is_empty() => Some(value[0].file_name().unwrap().to_string_lossy().to_string()),
1062 | _ => None,
1063 };
1064 debug!(path = path_to_string(path_parent), "=> {} Parent directory", Label::using());
1065 if let Some(value) = name {
1066 debug!(value, "=> {} First image", Label::using());
1067 let first_graphic = match self.meta.clone().media {
1069 | Some(values) if !values.is_empty() => {
1070 let caption = self.meta.clone().first_image_caption();
1071 let image_data = ImageObject::init().caption(caption.to_string()).content_url(value.clone()).build();
1072 MediaObject::Image(image_data)
1073 }
1074 | Some(_) | None => {
1075 let image_data = ImageObject::init().caption("".to_string()).content_url(value.clone()).build();
1076 MediaObject::Image(image_data)
1077 }
1078 };
1079 let rest = match self.clone().meta.media {
1081 | Some(values) if !values.is_empty() => values.into_iter().skip(1).collect::<Vec<_>>(),
1082 | Some(_) | None => vec![],
1083 };
1084 clone.meta.media = Some([vec![first_graphic], rest].concat());
1085 };
1086 clone.meta.keywords = self.clone().resolve(FuzzyValue::Keyword);
1087 clone.meta.technology = self.clone().resolve(FuzzyValue::Technology);
1088 clone.contact.telephone = match format_phone_number(&self.contact.telephone) {
1089 | Ok(value) => value,
1090 | Err(_) => {
1091 error!(value = self.contact.telephone, "=> {} Phone number", Label::invalid());
1092 self.contact.telephone.to_string()
1093 }
1094 };
1095 clone.contact.organization = match resolve_from_organization_json(self.clone().contact.organization) {
1096 | Some(value) => value,
1097 | None => "".to_string(),
1098 };
1099 clone.contact.affiliation = match self.clone().contact.affiliation {
1100 | Some(ref affiliation) => match resolve_from_organization_json(affiliation.to_string()) {
1101 | Some(resolved) => Some(resolved),
1102 | None => {
1103 error!(affiliation, "=> {} Affiliation", Label::not_found());
1104 Some(DEFAULT_AFFILIATION.to_string())
1105 }
1106 },
1107 | None => {
1108 let ornl = &Organization::load()[0];
1109 match ornl.clone().member(&clone.contact.organization) {
1110 | Some(organization) => match organization.nearest(OrganizationType::Directorate) {
1111 | Some(Organization { name, .. }) => Some(name),
1112 | None => Some(DEFAULT_AFFILIATION.to_string()),
1113 },
1114 | None => {
1115 error!("=> {} Nearest directorate", Label::not_found());
1116 Some(DEFAULT_AFFILIATION.to_string())
1117 }
1118 }
1119 }
1120 };
1121 clone.meta.partners = match self.clone().resolve(FuzzyValue::Partner) {
1122 | values if !values.is_empty() => Some(values),
1123 | _ => None,
1124 };
1125 clone.meta.sponsors = match self.clone().resolve(FuzzyValue::Sponsor) {
1126 | values if !values.is_empty() => Some(values),
1127 | _ => None,
1128 };
1129 clone
1130 }
1131 pub fn read(path: PathBuf) -> Option<ResearchActivity> {
1133 let content = match MimeType::from_path(path.clone()) {
1134 | MimeType::Json => match ResearchActivity::read_json(path.clone()) {
1135 | Ok(value) => Some(value),
1136 | Err(_) => None,
1137 },
1138 | MimeType::Yaml => match ResearchActivity::read_yaml(path.clone()) {
1139 | Ok(value) => Some(value),
1140 | Err(_) => None,
1141 },
1142 | _ => unimplemented!("Unsupported research activity data file extension"),
1143 };
1144 let label = match content {
1145 | Some(_) => Label::using(),
1146 | _ => Label::invalid(),
1147 };
1148 match content {
1149 | Some(data) => {
1150 debug!(path = path.to_str().unwrap(), "=> {}", label);
1151 trace!("=> {} Research activity data = {:#?}", label, data.dimmed().cyan());
1152 Some(data)
1153 }
1154 | None => {
1155 error!(path = path.to_str().unwrap(), "=> {}", label);
1156 None
1157 }
1158 }
1159 }
1160 fn read_json(path: PathBuf) -> serde_json::Result<ResearchActivity> {
1162 let content = match read_file(path.clone()) {
1163 | Ok(value) if !value.is_empty() => value,
1164 | Ok(_) | Err(_) => {
1165 error!(path = path.to_str().unwrap(), "=> {} RAD content is not valid", Label::fail());
1166 "{}".to_owned()
1167 }
1168 };
1169 let data: serde_json::Result<ResearchActivity> = serde_json::from_str(&content);
1170 let label = match data {
1171 | Ok(_) => Label::using(),
1172 | Err(_) => Label::invalid(),
1173 };
1174 trace!("=> {} RAD content = {:#?}", label, data.dimmed());
1175 data
1176 }
1177 fn read_yaml(path: PathBuf) -> serde_yml::Result<ResearchActivity> {
1179 let content = match read_file(path.clone()) {
1180 | Ok(value) => value,
1181 | Err(_) => {
1182 error!(path = path.to_str().unwrap(), "=> {} RAD content is not valid", Label::fail());
1183 "".to_owned()
1184 }
1185 };
1186 let data: serde_yml::Result<ResearchActivity> = serde_yml::from_str(&content);
1187 let label = match data {
1188 | Ok(_) => Label::output(),
1189 | Err(_) => Label::fail(),
1190 };
1191 debug!("=> {} RAD content = {:#?}", label, data.dimmed());
1192 data
1193 }
1194 fn resolve(self, value_type: FuzzyValue) -> Vec<String> {
1196 let values: Vec<_> = match value_type {
1197 | FuzzyValue::Keyword => self.meta.keywords,
1198 | FuzzyValue::Partner => match self.meta.partners {
1199 | Some(values) => values,
1200 | None => vec![],
1201 },
1202 | FuzzyValue::Sponsor => match self.meta.sponsors {
1203 | Some(values) => values,
1204 | None => vec![],
1205 },
1206 | FuzzyValue::Technology => self.meta.technology,
1207 };
1208 let mut data: Vec<_> = values
1209 .into_iter()
1210 .flat_map(|x| resolve_from_csv_asset(format!("{value_type}"), x))
1211 .collect();
1212 data.sort();
1213 data.dedup();
1214 data
1215 }
1216 pub fn to_markdown(self) -> String {
1218 let ResearchActivity { title, .. } = self.clone();
1219 format!("# {title}")
1220 }
1221 fn validation_issues(self) -> Vec<SchemaCheck> {
1222 fn errors_collect<T: Validate>(attribute: T) -> Option<Vec<SchemaCheck>> {
1223 match attribute.validate() {
1224 | Ok(_) => None,
1225 | Err(err) => Some(
1226 err.into_errors()
1227 .into_iter()
1228 .map(|(key, value)| SchemaCheck::init().success(false).errors(value).message(key.to_string()).build())
1229 .collect::<Vec<SchemaCheck>>(),
1230 ),
1231 }
1232 }
1233 let mut found = vec![errors_collect::<ResearchActivity>(self.clone())];
1234 match self.meta.media {
1235 | Some(values) => values.iter().for_each(|media| match media {
1236 | MediaObject::Image(x) => found.push(errors_collect::<ImageObject>(x.clone())),
1237 | MediaObject::Video(x) => found.push(errors_collect::<VideoObject>(x.clone())),
1238 }),
1239 | None => {}
1240 }
1241 found.into_iter().flatten().flatten().collect::<Vec<_>>()
1242 }
1243}
1244impl View for ContactPoint {
1245 fn render(&self) -> VirtualNode {
1246 let ContactPoint {
1247 given_name,
1248 family_name,
1249 job_title: role,
1250 email,
1251 telephone,
1252 ..
1253 } = self;
1254 html! {
1255 <section id="contact">
1256 <div>
1257 <span class="label">Contact</span>
1258 <span class="spacer"> </span>
1259 <span class="name">{ format!("{} {}", given_name, family_name) }</span>
1260 <span class="spacer">|</span>
1261 <span class="title">{ role }</span>
1262 <span class="spacer">|</span>
1263 <span class="email">{ email }</span>
1264 <span class="spacer">|</span>
1265 <span class="phone">{ telephone }</span>
1266 </div>
1267 </section>
1268 }
1269 }
1270}
1271fn match_list<I: IntoIterator<Item = String> + Clone>(value: String, values: I) -> Vec<(String, u32)> {
1272 let pattern = Pattern::parse(&value, CaseMatching::Ignore, Normalization::Smart);
1273 let mut matcher = Matcher::new(Config::DEFAULT.match_paths());
1274 pattern.match_list(values.clone(), &mut matcher)
1275}
1276fn print_resolution(output: Option<String>, value: String, name: String) {
1277 let label = name.titlecase();
1278 match output {
1279 | Some(resolved) => {
1280 if resolved.eq(&value.to_string()) {
1281 trace!("=> {} {} = \"{}\"", Label::using(), label, value.clone());
1282 } else {
1283 debug!(input = value.clone(), resolved, "=> {} {}", Label::found(), label);
1284 }
1285 }
1286 | None => {
1287 debug!(value = value.clone(), "=> {} {}", Label::not_found(), label);
1288 }
1289 };
1290}
1291fn resolve_from_csv_asset(name: String, value: String) -> Option<String> {
1292 let data = Constant::csv(&name);
1293 resolve_from_list_of_lists(value, data, name)
1294}
1295fn resolve_from_list_of_lists<I: IntoIterator<Item = Vec<String>>>(value: String, data: I, name: String) -> Option<String> {
1296 let output = data
1297 .into_iter()
1298 .flat_map(|values| {
1299 let sanitized = sanitize(value.clone());
1300 let matched = match_list(sanitized, values.clone());
1301 trace!("{} => {:?}", value.clone(), matched.clone());
1302 if matched.clone().is_empty() {
1303 None
1304 } else {
1305 match values.first() {
1306 | Some(x) => {
1307 if value.eq(x) {
1308 Some((x.into(), 10000))
1309 } else {
1310 let score = matched.into_iter().map(|(_, score)| score).max();
1311 match score {
1312 | Some(value) if value > 0 => Some((x.to_string(), value)),
1313 | Some(_) | None => None,
1314 }
1315 }
1316 }
1317 | None => None,
1318 }
1319 }
1320 })
1321 .max_by_key(|(_, score)| *score)
1322 .map(|(x, _)| x.to_string());
1323 print_resolution(output.clone(), value, name);
1324 output
1325}
1326fn resolve_from_organization_json(value: String) -> Option<String> {
1327 let organization = &Organization::load()[0];
1328 let mut items = vec![organization.clone()];
1329 let directorates = organization.member.clone();
1330 for directorate in &directorates {
1331 items.push(directorate.clone());
1332 let divisions = directorate.member.clone();
1333 for division in &divisions {
1334 items.push(division.clone());
1335 }
1336 }
1337 let data = items
1338 .into_iter()
1339 .map(|x| (x.name.clone(), x.alternative_name.clone()))
1340 .filter(|(name, alias)| !(name.is_empty() && alias.is_none()))
1341 .map(|(name, alias)| {
1342 let alternative_name = match alias {
1343 | Some(x) => x.to_string(),
1344 | None => name.clone(),
1345 };
1346 vec![name, alternative_name]
1347 })
1348 .collect::<Vec<Vec<String>>>();
1349 resolve_from_list_of_lists(value, data, "organization".to_string())
1350}
1351fn sanitize(value: String) -> String {
1352 match Regex::new(r"[-_.,]") {
1353 | Ok(re) => re.replace_all(&value, "").replace("&", "and").trim().to_string(),
1354 | Err(err) => err.to_string(),
1355 }
1356}
1357
1358#[cfg(test)]
1359mod tests;