diff --git a/docs/fair/examples/project-summary/project-information.docx b/docs/fair/examples/project-summary/project-information.docx new file mode 100644 index 000000000..e69de29bb diff --git a/docs/fair/examples/project-summary/project-information.yml b/docs/fair/examples/project-summary/project-information.yml new file mode 100644 index 000000000..e69de29bb diff --git a/docs/fair/examples/project-summary/ro-crate-metadata.json b/docs/fair/examples/project-summary/ro-crate-metadata.json new file mode 100644 index 000000000..e69de29bb diff --git a/docs/fair/project-information-example-ro-crate.png b/docs/fair/project-information-example-ro-crate.png new file mode 100644 index 000000000..ffdebddb6 Binary files /dev/null and b/docs/fair/project-information-example-ro-crate.png differ diff --git a/docs/fair/research-objects.md b/docs/fair/research-objects.md new file mode 100644 index 000000000..1de26f1ed --- /dev/null +++ b/docs/fair/research-objects.md @@ -0,0 +1,29 @@ +# RO-crates support in the data manager app + +[RO-crates](https://www.researchobject.org/ro-crate/) (RO: Research Object) provide a way structured and machine-actionable way to bundle metadata and files in one single digital object. We will +look into how RO-Crates can be supported in the data manager. + +## RO-Crate structure + +The latest version up to the writing of this document is [RO-Crate 1.2-DRAFT](https://www.researchobject.org/ro-crate/specification/1.2-DRAFT/index.html). The last stable release is [RO-Crate 1.1](https://www.researchobject.org/ro-crate/specification/1.1/index.html). + + +## Use case: project information + +Project information such as title, identifier and contact person(s) are very basic and high-level metadata often +used throughout a research project life cycle. This information is frequently consumed by humans (e.g. project managers, principal investigators, funding agencies) as well as machines to process or re-use it (e.g. in computational workflows). + +How can this metadata be provided in a FAIR compliant manner and serving both: humans and machines optimally. + +### Suggested RO-Crate implementation + +Possible structure for an RO-Crate containing QBiC project information: + +![Example project information RO-Crate](project-information-example-ro-crate.png) + +This simple RO-Crate consists of only three files: + + - _ro-crate-metadata.json_: part of the RO-Crate specification, this file describes the content of the RO-crate object. You will see this file in every RO-Crate. + - _project-information.yml_: some machine-actionable high level metadata about a QBiC project, e.g. title, identifier, contact + - _project-information.docx_: some human-readable, visually appealing information about a QBiC project + diff --git a/pom.xml b/pom.xml index 09089f8ba..895a7cb4e 100644 --- a/pom.xml +++ b/pom.xml @@ -173,6 +173,7 @@ jackson-annotations ${jackson.version} + diff --git a/user-interface/frontend/themes/datamanager/components/button.css b/user-interface/frontend/themes/datamanager/components/button.css index 6de2f303f..cbcb600f0 100644 --- a/user-interface/frontend/themes/datamanager/components/button.css +++ b/user-interface/frontend/themes/datamanager/components/button.css @@ -6,6 +6,11 @@ flex: 1 1; } +.button-bar { + display: inline-flex; + column-gap: var(--lumo-space-s); +} + .navigation-button .button { box-shadow: var(--lumo-box-shadow-xs); } diff --git a/user-interface/pom.xml b/user-interface/pom.xml index 53155b042..b1c42d111 100644 --- a/user-interface/pom.xml +++ b/user-interface/pom.xml @@ -159,6 +159,61 @@ org.springframework.boot spring-boot-starter-oauth2-client + + + + org.docx4j + docx4j-core + 11.5.0 + + + org.slf4j + slf4j-log4j12 + + + log4j + log4j + + + + + + org.glassfish.jaxb + jaxb-runtime + 4.0.5 + + + jakarta.xml.bind + jakarta.xml.bind-api + 4.0.2 + + + org.docx4j + docx4j-JAXB-ReferenceImpl + 11.4.11 + + + + + + edu.kit.datamanager + ro-crate-java + 1.1.1 + + + org.slf4j + slf4j-log4j12 + + + log4j + log4j + + + org.slf4j + slf4j-jdk14 + + + org.springframework.boot spring-boot-starter-test diff --git a/user-interface/src/main/bundles/dev.bundle b/user-interface/src/main/bundles/dev.bundle index edc0c1dae..a76fe8531 100644 Binary files a/user-interface/src/main/bundles/dev.bundle and b/user-interface/src/main/bundles/dev.bundle differ diff --git a/user-interface/src/main/java/life/qbic/datamanager/export/FileFormatSupplier.java b/user-interface/src/main/java/life/qbic/datamanager/export/FileFormatSupplier.java new file mode 100644 index 000000000..dabdffee1 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/export/FileFormatSupplier.java @@ -0,0 +1,26 @@ +package life.qbic.datamanager.export; + +import java.io.File; +import life.qbic.datamanager.export.model.ResearchProject; + +/** + * File format supplier + *

+ * Interface describing the API of suppliers for different file formats. + * + * @since 1.6.0 + */ +public interface FileFormatSupplier { + + File from(String fileName, ResearchProject researchProject) throws FormatException; + + class FormatException extends RuntimeException { + + public FormatException(String message) { + } + + public FormatException(String message, Throwable cause) { + } + } + +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/export/TempDirectory.java b/user-interface/src/main/java/life/qbic/datamanager/export/TempDirectory.java new file mode 100644 index 000000000..67bdf9413 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/export/TempDirectory.java @@ -0,0 +1,52 @@ +package life.qbic.datamanager.export; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * Temporary Director + *

+ * Provides a temporary directory for the application to use when files need to be created for + * exporting them. + * + * @since + */ +@Component +public class TempDirectory { + + private final Path tempDir; + + public TempDirectory(@Value("${service.host.temp.dir}") String tempDirectoryPath) + throws IOException { + Path tempDirPath = Path.of(tempDirectoryPath); + if (!Files.exists(tempDirPath)) { + throw new IOException(tempDirectoryPath + " does not exist"); + } + if (!Files.isWritable(tempDirPath)) { + throw new IOException(tempDirectoryPath + " is not writable"); + } + if (!Files.isExecutable(tempDirPath)) { + throw new IOException(tempDirectoryPath + " is not executable"); + } + tempDir = tempDirPath; + } + + /** + * Creates a new directory in the configured temporary directory location. + *

+ * The method minimises the probability for collisions, so the client does not need to take care + * about potential existing directories. + * + * @return the path to the newly created directory in the app's global temporary directory + * @throws IOException in case the directory could not be created + * @since 1.6.0 + */ + public Path createDirectory() throws IOException { + return Files.createDirectory(tempDir.resolve(UUID.randomUUID().toString())); + } + +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/export/docx/DocxSupplier.java b/user-interface/src/main/java/life/qbic/datamanager/export/docx/DocxSupplier.java new file mode 100644 index 000000000..0cf01a34b --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/export/docx/DocxSupplier.java @@ -0,0 +1,67 @@ +package life.qbic.datamanager.export.docx; + +import java.io.File; +import life.qbic.datamanager.export.FileFormatSupplier; +import life.qbic.datamanager.export.model.ContactPoint; +import life.qbic.datamanager.export.model.ResearchProject; +import org.docx4j.openpackaging.exceptions.Docx4JException; +import org.docx4j.openpackaging.packages.WordprocessingMLPackage; +import org.docx4j.openpackaging.parts.WordprocessingML.MainDocumentPart; + +/** + * DOCX formatter implementation + *

+ * Creates DOCX representations of various content types in a Data Manager's project. + * + * @since 1.0.0 + */ +public class DocxSupplier implements FileFormatSupplier { + + public static DocxSupplier create() { + return new DocxSupplier(); + } + + @Override + public File from(String fileName, ResearchProject researchProject) { + try { + var wordPackage = WordprocessingMLPackage.createPackage(); + var mainDocument = wordPackage.getMainDocumentPart(); + addTitle(mainDocument, researchProject); + addProjectId(mainDocument, researchProject); + addSection(mainDocument, "Description", researchProject.description()); + addSectionTitle(mainDocument, "Contact Points"); + researchProject.contactPoint() + .forEach(contactPoint -> addContactPoint(mainDocument, contactPoint)); + File file = new File(fileName); + wordPackage.save(file); + return file; + } catch (Docx4JException e) { + throw new FormatException("Creating docx package failed. ", e); + } + } + + private void addTitle(MainDocumentPart mainDocumentPart, ResearchProject researchProject) { + mainDocumentPart.addStyledParagraphOfText("Title", researchProject.name()); + } + + private void addProjectId(MainDocumentPart mainDocumentPart, ResearchProject researchProject) { + mainDocumentPart.addStyledParagraphOfText("Subtitle", + "Project ID: " + researchProject.identifier()); + } + + private void addSection(MainDocumentPart mainDocumentPart, String sectionTitle, + String sectionContent) { + mainDocumentPart.addStyledParagraphOfText("Heading1", sectionTitle); + mainDocumentPart.addParagraphOfText(sectionContent); + } + + private void addSectionTitle(MainDocumentPart mainDocumentPart, String title) { + mainDocumentPart.addStyledParagraphOfText("Heading1", title); + } + + private void addContactPoint(MainDocumentPart mainDocumentPart, ContactPoint contactPoint) { + var contactPointFormatted = "%s (%s) - %s".formatted(contactPoint.name(), + contactPoint.contactType(), contactPoint.email()); + mainDocumentPart.addParagraphOfText(contactPointFormatted); + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/export/model/ContactPoint.java b/user-interface/src/main/java/life/qbic/datamanager/export/model/ContactPoint.java new file mode 100644 index 000000000..b9d6699ed --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/export/model/ContactPoint.java @@ -0,0 +1,44 @@ +package life.qbic.datamanager.export.model; + +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * A schema.org ContactPoint representation in Java. + * + * @since 1.6.0. + */ +public class ContactPoint { + + @JsonProperty(value = "@type") + private final String type = "ContactPoint"; + + @JsonProperty(value = "name") + private String name; + + @JsonProperty(value = "email") + private String email; + + @JsonProperty(value = "contactType") + private String contactType; + + public static ContactPoint from(String name, String email, String contactType) { + ContactPoint contactPoint = new ContactPoint(); + contactPoint.name = name; + contactPoint.email = email; + contactPoint.contactType = contactType; + return contactPoint; + } + + public String name() { + return name; + } + + public String email() { + return email; + } + + public String contactType() { + return contactType; + } + +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/export/model/ResearchProject.java b/user-interface/src/main/java/life/qbic/datamanager/export/model/ResearchProject.java new file mode 100644 index 000000000..b8ac517f2 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/export/model/ResearchProject.java @@ -0,0 +1,53 @@ +package life.qbic.datamanager.export.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * A schema.org ResearchProject representation in Java. + * + * @since 1.6.0. + */ +public class ResearchProject { + + @JsonProperty(value = "@type") + private final String type = "ResearchProject"; + + @JsonProperty(value = "name") + private String name; + + @JsonProperty(value = "identifier") + private String identifier; + + @JsonProperty(value = "description") + private String description; + + @JsonProperty(value = "contactPoint") + private List contactPoint; + + public static ResearchProject from(String name, String identifier, String description, List contactPoint) { + ResearchProject project = new ResearchProject(); + project.name = name; + project.identifier = identifier; + project.description = description; + project.contactPoint = contactPoint.stream().toList(); + return project; + } + + public String name() { + return name; + } + + public String identifier() { + return identifier; + } + + public String description() { + return description; + } + + public List contactPoint() { + return contactPoint.stream().toList(); + } + +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/export/rocrate/ROCreateBuilder.java b/user-interface/src/main/java/life/qbic/datamanager/export/rocrate/ROCreateBuilder.java new file mode 100644 index 000000000..aa604a28e --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/export/rocrate/ROCreateBuilder.java @@ -0,0 +1,138 @@ +package life.qbic.datamanager.export.rocrate; + +import static life.qbic.datamanager.export.rocrate.ROCreateBuilder.ResearchProjectConstants.SUMMARY_FILENAME_DOCX; +import static life.qbic.datamanager.export.rocrate.ROCreateBuilder.ResearchProjectConstants.SUMMARY_FILENAME_YAML; + +import edu.kit.datamanager.ro_crate.RoCrate; +import edu.kit.datamanager.ro_crate.entities.data.FileEntity; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Objects; +import life.qbic.datamanager.export.TempDirectory; +import life.qbic.datamanager.export.docx.DocxSupplier; +import life.qbic.datamanager.export.model.ContactPoint; +import life.qbic.datamanager.export.model.ResearchProject; +import life.qbic.datamanager.export.yaml.YamlSupplier; +import life.qbic.projectmanagement.domain.model.project.Contact; +import life.qbic.projectmanagement.domain.model.project.Project; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * RO-Crate Builder + *

+ * Builder class that helps to build a RO-Crate based on various QBiC data manager project + * information. + * + * @since 1.6.0 + */ +@Component +public class ROCreateBuilder { + + private final TempDirectory tempDirectory; + + @Autowired + public ROCreateBuilder(TempDirectory tempDir) { + this.tempDirectory = Objects.requireNonNull(tempDir); + } + + private static RoCrate buildRoCrate(Path buildDir, ResearchProject researchProject) { + var projectInfoDocx = DocxSupplier.create() + .from(buildDir.resolve(SUMMARY_FILENAME_DOCX.value()).toString(), researchProject); + var projectInfoYaml = YamlSupplier.create() + .from(buildDir.resolve(SUMMARY_FILENAME_YAML.value()).toString(), researchProject); + return new RoCrate.RoCrateBuilder( + "QBiC-project-%s-ro-crate".formatted(researchProject.identifier()), + "Description of the project %s with the title '%s', managed on the Data Manager, Quantitative Biology Center, University of Tübingen.".formatted( + researchProject.identifier(), researchProject.name())) + .addDataEntity( + new FileEntity.FileEntityBuilder() + .setSource(projectInfoDocx) + .setId(SUMMARY_FILENAME_DOCX.value()) + .addProperty("name", "Project Summary") + .addProperty("encodingFormat", + MimeTypes.DOCX.value()) + .build()) + .addDataEntity( + new FileEntity.FileEntityBuilder() + .setSource(projectInfoYaml) + .setId(SUMMARY_FILENAME_YAML.value()) + .addProperty("name", "Project Summary") + .addProperty("encodingFormat", MimeTypes.YAML.value()) + .build()) + .build(); + } + + public RoCrate projectSummary(Project project, Path buildDirectory) throws ROCrateBuildException { + var researchProject = convertToResearchProject(project); + if (!buildDirectory.toFile().exists()) { + throw new ROCrateBuildException("File does not exist: " + buildDirectory); + } + return buildRoCrate(buildDirectory, researchProject); + } + + private ResearchProject convertToResearchProject(Project project) { + var contactPoints = new ArrayList(); + contactPoints.add(toContactPoint(project.getPrincipalInvestigator(), "Principal Investigator")); + contactPoints.add(toContactPoint(project.getProjectManager(), "Project Manager")); + if (project.getResponsiblePerson().isPresent()) { + contactPoints.add(toContactPoint(project.getResponsiblePerson().get(), "Responsible Person")); + } + return ResearchProject.from(project.getProjectIntent().projectTitle().title(), + project.getProjectCode().value(), project.getProjectIntent().objective().objective(), + contactPoints); + } + + private ContactPoint toContactPoint(Contact contact, String contactType) { + return ContactPoint.from(contact.fullName(), contact.emailAddress(), contactType); + } + + enum ResearchProjectConstants implements Value { + + SUMMARY_FILENAME_DOCX("project-summary.docx"), + SUMMARY_FILENAME_YAML("project-summary.yml"); + + private final String value; + + ResearchProjectConstants(String value) { + this.value = value; + } + + public String value() { + return value; + } + } + + enum MimeTypes implements Value { + + DOCX("application/vnd.openxmlformats-officedocument.wordprocessingml.document"), + YAML("application/yaml"); + + private final String value; + + MimeTypes(String value) { + this.value = value; + } + + @Override + public String value() { + return value; + } + } + + interface Value { + + String value(); + } + + public static class ROCrateBuildException extends RuntimeException { + + public ROCrateBuildException(String message) { + super(message); + } + + public ROCrateBuildException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/export/yaml/YamlSupplier.java b/user-interface/src/main/java/life/qbic/datamanager/export/yaml/YamlSupplier.java new file mode 100644 index 000000000..fb5c84c83 --- /dev/null +++ b/user-interface/src/main/java/life/qbic/datamanager/export/yaml/YamlSupplier.java @@ -0,0 +1,34 @@ +package life.qbic.datamanager.export.yaml; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import java.io.File; +import java.io.IOException; +import life.qbic.datamanager.export.FileFormatSupplier; +import life.qbic.datamanager.export.model.ResearchProject; + +/** + * YAML formatter implementation + *

+ * Creates YAML representations of various content types in a Data Manager's project. + * + * @since 1.0.0 + */ +public class YamlSupplier implements FileFormatSupplier { + + public static YamlSupplier create() { + return new YamlSupplier(); + } + + @Override + public File from(String fileName, ResearchProject researchProject) throws FormatException { + ObjectMapper mapper = new ObjectMapper(new YAMLFactory()); + File file = new File(fileName); + try { + mapper.writeValue(file, researchProject); + return file; + } catch (IOException e) { + throw new FormatException("Could not write to file " + fileName, e); + } + } +} diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectEditEvent.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectEditEvent.java index 4b51cdad9..d79eed491 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectEditEvent.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectEditEvent.java @@ -8,11 +8,11 @@ * Project Edit Event *

* Event that indicates that the user wants to edit a project via the - * {@link ProjectDetailsComponent} + * {@link ProjectSummaryComponent} * * @since 1.0.0 */ -public class ProjectEditEvent extends ComponentEvent { +public class ProjectEditEvent extends ComponentEvent { @Serial private static final long serialVersionUID = -4045489562991683868L; @@ -27,7 +27,7 @@ public class ProjectEditEvent extends ComponentEvent { * @param fromClient true if the event originated from the client * side, false otherwise */ - public ProjectEditEvent(ProjectDetailsComponent source, ProjectId projectId, + public ProjectEditEvent(ProjectSummaryComponent source, ProjectId projectId, boolean fromClient) { super(source, fromClient); this.projectId = projectId; diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectInformationMain.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectInformationMain.java index 0a5de67df..2042c78a3 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectInformationMain.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectInformationMain.java @@ -84,7 +84,7 @@ public class ProjectInformationMain extends Main implements BeforeEnterObserver private final transient ProjectPurchaseService projectPurchaseService; private final transient QualityControlService qualityControlService; private final transient UserPermissions userPermissions; - private final ProjectDetailsComponent projectDetailsComponent; + private final ProjectSummaryComponent projectSummaryComponent; private final ExperimentListComponent experimentListComponent; private final OfferDownload offerDownload; private final QualityControlDownload qualityControlDownload; @@ -95,7 +95,7 @@ public class ProjectInformationMain extends Main implements BeforeEnterObserver private final TerminologyService terminologyService; private Context context; - public ProjectInformationMain(@Autowired ProjectDetailsComponent projectDetailsComponent, + public ProjectInformationMain(@Autowired ProjectSummaryComponent projectSummaryComponent, @Autowired ExperimentListComponent experimentListComponent, @Autowired UserPermissions userPermissions, @Autowired AddExperimentToProjectService addExperimentToProjectService, @@ -106,7 +106,7 @@ public ProjectInformationMain(@Autowired ProjectDetailsComponent projectDetailsC @Autowired TerminologyService terminologyService, CancelConfirmationDialogFactory cancelConfirmationDialogFactory, MessageSourceNotificationFactory messageSourceNotificationFactory) { - this.projectDetailsComponent = requireNonNull(projectDetailsComponent, + this.projectSummaryComponent = requireNonNull(projectSummaryComponent, "projectDetailsComponent must not be null"); this.experimentListComponent = requireNonNull(experimentListComponent, "experimentListComponent must not be null"); @@ -140,7 +140,7 @@ public ProjectInformationMain(@Autowired ProjectDetailsComponent projectDetailsC this.experimentListComponent.addExperimentSelectionListener(this::onExperimentSelectionEvent); this.experimentListComponent.addAddButtonListener(this::onAddExperimentClicked); addClassName("project"); - add(projectDetailsComponent, offerListComponent, offerDownload, experimentListComponent, + add(projectSummaryComponent, offerListComponent, offerDownload, experimentListComponent, qualityControlListComponent, qualityControlDownload); this.terminologyService = terminologyService; } @@ -300,7 +300,7 @@ private void onExperimentSelectionEvent(ExperimentSelectionEvent event) { private void setContext(Context context) { this.context = context; - projectDetailsComponent.setContext(context); + projectSummaryComponent.setContext(context); experimentListComponent.setContext(context); refreshOffers(projectPurchaseService, context.projectId().orElseThrow().value(), offerListComponent); diff --git a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectDetailsComponent.java b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectSummaryComponent.java similarity index 83% rename from user-interface/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectDetailsComponent.java rename to user-interface/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectSummaryComponent.java index c2ece073d..251fa440d 100644 --- a/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectDetailsComponent.java +++ b/user-interface/src/main/java/life/qbic/datamanager/views/projects/project/info/ProjectSummaryComponent.java @@ -8,11 +8,20 @@ import com.vaadin.flow.component.html.Span; import com.vaadin.flow.spring.annotation.SpringComponent; import com.vaadin.flow.spring.annotation.UIScope; +import edu.kit.datamanager.ro_crate.writer.RoCrateWriter; +import edu.kit.datamanager.ro_crate.writer.ZipWriter; +import java.io.File; +import java.io.IOException; import java.io.Serial; +import java.nio.file.Files; import java.util.ArrayList; import java.util.List; import java.util.Optional; import life.qbic.application.commons.ApplicationException; +import life.qbic.datamanager.download.DownloadContentProvider; +import life.qbic.datamanager.download.DownloadProvider; +import life.qbic.datamanager.export.TempDirectory; +import life.qbic.datamanager.export.rocrate.ROCreateBuilder; import life.qbic.datamanager.security.UserPermissions; import life.qbic.datamanager.views.Context; import life.qbic.datamanager.views.general.PageArea; @@ -42,7 +51,7 @@ */ @UIScope @SpringComponent -public class ProjectDetailsComponent extends PageArea { +public class ProjectSummaryComponent extends PageArea { @Serial private static final long serialVersionUID = -5781313306040217724L; @@ -55,7 +64,8 @@ public class ProjectDetailsComponent extends PageArea { private final Div projectManagerField = new Div(); private final Div principalInvestigatorField = new Div(); private final Div responsiblePersonField = new Div(); - private final InformationComponent projectInformationSection = InformationComponent.create("", ""); + private final InformationComponent projectInformationSection = InformationComponent.create("", + ""); private final InformationComponent fundingInformationSection = InformationComponent.create( "Funding Information", "Information about project funding"); private final InformationComponent collaboratorSection = InformationComponent.create( @@ -65,13 +75,17 @@ public class ProjectDetailsComponent extends PageArea { private final transient ContactRepository contactRepository; private final UserPermissions userPermissions; private final CancelConfirmationDialogFactory cancelConfirmationDialogFactory; + private final ROCreateBuilder roCrateBuilder; + private final TempDirectory tempDirectory; + private DownloadProvider downloadProvider; private Context context; - public ProjectDetailsComponent(@Autowired ProjectInformationService projectInformationService, + public ProjectSummaryComponent(@Autowired ProjectInformationService projectInformationService, @Autowired ExperimentInformationService experimentInformationService, @Autowired ContactRepository contactRepository, @Autowired UserPermissions userPermissions, - CancelConfirmationDialogFactory cancelConfirmationDialogFactory) { + CancelConfirmationDialogFactory cancelConfirmationDialogFactory, + @Autowired ROCreateBuilder rOCreateBuilder, TempDirectory tempDirectory) { this.projectInformationService = requireNonNull(projectInformationService, "projectInformationService must not be null"); this.experimentInformationService = requireNonNull(experimentInformationService, @@ -84,6 +98,10 @@ public ProjectDetailsComponent(@Autowired ProjectInformationService projectInfor layoutComponent(); addListenerForNewEditEvent(); addClassName("project-details-component"); + this.roCrateBuilder = rOCreateBuilder; + this.tempDirectory = tempDirectory; + downloadProvider = new DownloadProvider(null); + add(downloadProvider); } private static List extractProjectInfo(Project project, List experiments) { @@ -120,7 +138,7 @@ private static List extractProjectInfo(Project project, List return entries; } - private static Span createOntologyEntryFrom(OntologyTerm ontologyTerm){ + private static Span createOntologyEntryFrom(OntologyTerm ontologyTerm) { String ontologyLinkName = ontologyTerm.getOboId().replace("_", ":"); Span ontologyEntryLink = new Span(new Anchor(ontologyTerm.getClassIri(), ontologyLinkName)); ontologyEntryLink.addClassName("ontology-link"); @@ -206,6 +224,8 @@ private void layoutComponent() { content.add(projectInformationSection, fundingInformationSection, collaboratorSection); content.addClassName("project-information-content"); + buttonBar.addClassName("button-bar"); + titleField.setText("Project Summary"); header.addClassName("header"); header.add(titleField, buttonBar); @@ -218,11 +238,73 @@ private Button editButton() { return editButton; } + private Button exportButton() { + Button exortButton = new Button("Export as RO-Crate"); + exortButton.addClickListener(event -> { + try { + triggerRoCrateDownload(); + } catch (IOException e) { + throw new ApplicationException("An error occurred while exporting RO-Crate", e); + } + }); + return exortButton; + } + + private void triggerRoCrateDownload() throws IOException { + ProjectId projectId = context.projectId().orElseThrow(); + Project project = projectInformationService.find(projectId).orElseThrow(); + var tempBuildDir = tempDirectory.createDirectory(); + var zippedRoCrateDir = tempDirectory.createDirectory(); + try { + var roCrate = roCrateBuilder.projectSummary(project, tempBuildDir); + var roCrateZipWriter = new RoCrateWriter(new ZipWriter()); + var zippedRoCrateFile = zippedRoCrateDir.resolve( + "%s-project-summary-ro-crate.zip".formatted(project.getProjectCode().value())); + roCrateZipWriter.save(roCrate, zippedRoCrateFile.toString()); + remove(downloadProvider); + var cachedZipContent = Files.readAllBytes(zippedRoCrateFile); + downloadProvider = new DownloadProvider(new DownloadContentProvider() { + @Override + public byte[] getContent() { + return cachedZipContent; + } + + @Override + public String getFileName() { + return zippedRoCrateFile.getFileName().toString(); + } + }); + add(downloadProvider); + downloadProvider.trigger(); + } catch (IOException e) { + throw new ApplicationException("Error exporting ro-crate.zip", e); + } finally { + deleteTempDir(tempBuildDir.toFile()); + deleteTempDir(zippedRoCrateDir.toFile()); + } + + } + + private boolean deleteTempDir(File dir) throws IOException { + File[] files = dir.listFiles(); //null if not a directory + // https://docs.oracle.com/javase/8/docs/api/java/io/File.html#listFiles-- + if (files != null) { + for (File file : files) { + if (!deleteTempDir(file)) { + return false; + } + } + } + return dir.delete(); + } + private void showControls(boolean enabled) { buttonBar.removeAll(); + buttonBar.add(exportButton()); if (enabled) { buttonBar.add(editButton()); } + } private void openProjectInformationDialog() { diff --git a/user-interface/src/main/resources/application.properties b/user-interface/src/main/resources/application.properties index a3ec599fa..f12c448d5 100644 --- a/user-interface/src/main/resources/application.properties +++ b/user-interface/src/main/resources/application.properties @@ -65,6 +65,9 @@ spring.mail.port=${MAIL_PORT:587} service.host.name=${DM_SERVICE_HOST:127.0.0.1} service.host.protocol=${DM_HOST_PROTOCOL:https} service.host.port=${DM_SERVICE_PORT:-1} +# The path to an existing directory on the host's file system where +# the application will create temporary directories, e.g. for preparing download assets +service.host.temp.dir=${DM_TEMP_DIR:./tmp} ############################################################################### ################### OpenBis configuration ##################################### openbis.user.name=${OPENBIS_USER_NAME:}