diff --git a/CITATION.cff b/CITATION.cff
index bfc3a960..c92f7d15 100644
--- a/CITATION.cff
+++ b/CITATION.cff
@@ -10,6 +10,6 @@ authors:
given-names: "Sabrine"
orcid: "https://orcid.org/0000-0002-4480-6116"
title: "ro-crate-java"
-version: 1.0.3
+version: 2.1.0-rc3
date-released: 2022-07-19
url: "https://github.com/kit-data-manager/ro-crate-java"
diff --git a/build.gradle b/build.gradle
index f7fcd613..ae540446 100644
--- a/build.gradle
+++ b/build.gradle
@@ -43,7 +43,7 @@ ext {
dependencies {
// JUnit setup for testing
- testImplementation(platform("org.junit:junit-bom:5.12.2"))
+ testImplementation(platform("org.junit:junit-bom:5.13.0"))
testImplementation('org.junit.jupiter:junit-jupiter')
testRuntimeOnly('org.junit.platform:junit-platform-launcher')
// JSON object mapping / (de-)serialization
@@ -64,7 +64,7 @@ dependencies {
// JSON-LD, Zenodo mapping
implementation group: 'com.apicatalog', name: 'titanium-json-ld', version: '1.6.0'
// metadata validation, profiles based on JSON schema
- implementation group: "com.networknt", name: "json-schema-validator", version: "1.5.6"
+ implementation group: "com.networknt", name: "json-schema-validator", version: "1.5.7"
implementation 'org.glassfish:jakarta.json:2.0.1'
//JTE for template processing
implementation('gg.jte:jte:3.2.1')
@@ -111,6 +111,22 @@ configurations {
performanceTestImplementation.extendsFrom implementation
}
+// Task for creating a resource file with the version info
+tasks.register("generateVersionProps", WriteProperties) { t ->
+ def generatedResourcesDir = project.layout.buildDirectory.dir(["resources", "main"].join(File.separator))
+ def outputFile = generatedResourcesDir.map { it.file("version.properties") }
+
+ t.destinationFile = outputFile.get().asFile
+ t.property("version", version)
+}
+
+tasks.register("generateVersionPropsTest", WriteProperties) { t ->
+ def generatedResourcesDir = project.layout.buildDirectory.dir(["resources", "test"].join(File.separator))
+ def outputFile = generatedResourcesDir.map { it.file("version.properties") }
+
+ t.destinationFile = outputFile.get().asFile
+ t.property("version", version)
+}
tasks.register('performanceContextEntitiesBenchmark', JavaExec) {
description = "Run the context entities benchmarks."
@@ -154,8 +170,13 @@ tasks.register('performanceReadWriteMultipleCratesBenchmark', JavaExec) {
mainClass = 'edu.kit.datamanager.ro_crate.multiplecrates.MultipleCratesWriteAndRead'
}
+compileJava {
+ dependsOn generateVersionProps
+}
+
test {
useJUnitPlatform()
+ dependsOn generateVersionPropsTest
finalizedBy jacocoTestReport
}
@@ -178,3 +199,5 @@ if (System.getProperty('profile') == "release") {
println 'Using release profile for building ' + project.getName()
apply from: 'gradle/profile-release.gradle'
}
+
+apply from: 'gradle/updateCff.gradle'
diff --git a/gradle/profile-release.gradle b/gradle/profile-release.gradle
index 3af61472..471eedc8 100644
--- a/gradle/profile-release.gradle
+++ b/gradle/profile-release.gradle
@@ -17,7 +17,7 @@
apply plugin: 'net.researchgate.release'
apply plugin: 'maven-publish'
apply plugin: 'signing'
-
+
//for plugin net.researchgate.release
//see https://github.com/researchgate/gradle-release
release {
diff --git a/gradle/updateCff.gradle b/gradle/updateCff.gradle
new file mode 100644
index 00000000..b4412fc3
--- /dev/null
+++ b/gradle/updateCff.gradle
@@ -0,0 +1,70 @@
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+
+/**
+ * This file defines the updateCff task.
+ *
+ * This task hooks into the workflow of the release task,
+ * which, according to the source code of the release plugin,
+ * seems to be defined like this:
+ *
+ * tasks = [
+ * "${p}createScmAdapter" as String,
+ * "${p}initScmAdapter" as String,
+ * "${p}checkCommitNeeded" as String,
+ * "${p}checkUpdateNeeded" as String,
+ * "${p}checkoutMergeToReleaseBranch" as String,
+ * "${p}unSnapshotVersion" as String,
+ * "${p}confirmReleaseVersion" as String,
+ * -> we insert our task here <-
+ * "${p}checkSnapshotDependencies" as String,
+ * "${p}runBuildTasks" as String,
+ * "${p}preTagCommit" as String,
+ * "${p}createReleaseTag" as String,
+ * "${p}checkoutMergeFromReleaseBranch" as String,
+ * "${p}updateVersion" as String,
+ * "${p}commitNewVersion" as String
+ * ]
+ */
+
+tasks.register('updateCff') {
+ group = 'release'
+ description = 'Updates the version in CITATION.cff file'
+
+ outputs.file("CITATION.cff")
+
+ doLast {
+ def version = project.version.toString()
+ def today = LocalDate.now().format(DateTimeFormatter.ISO_DATE)
+
+ def cffFile = file('CITATION.cff')
+ def content = cffFile.text
+
+ // Update or insert version
+ if (content.contains('version:')) {
+ content = content.replaceAll(/(?m)^version:\s*.+$/, "version: ${version}")
+ } else {
+ content = content + "\nversion: ${version}"
+ }
+
+ // Update or insert date-released
+ if (content.contains('date-released:')) {
+ content = content.replaceAll(/(?m)^date-released:\s*.+$/, "date-released: ${today}")
+ } else {
+ content = content + "\ndate-released: ${today}"
+ }
+
+ cffFile.text = content
+ println "Updated CITATION.cff to version ${version} and date-released ${today}"
+ }
+}
+
+// Make sure your custom task runs after a specific task in the release sequence
+tasks.named('updateCff') {
+ mustRunAfter(tasks.named('confirmReleaseVersion'))
+}
+
+// Ensure subsequent tasks in the release sequence run after your custom task
+tasks.named('checkSnapshotDependencies') {
+ dependsOn('updateCff')
+}
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index ca025c83..ff23a68d 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.2-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/src/main/java/edu/kit/datamanager/ro_crate/Crate.java b/src/main/java/edu/kit/datamanager/ro_crate/Crate.java
index aff1b6fe..782ff601 100644
--- a/src/main/java/edu/kit/datamanager/ro_crate/Crate.java
+++ b/src/main/java/edu/kit/datamanager/ro_crate/Crate.java
@@ -22,6 +22,28 @@
*/
public interface Crate {
+ /**
+ * Mark the crate as imported, i.e. it has been read from a file
+ * or is for other reasons not considered a new crate.
+ *
+ * This is useful mostly for readers to indicate this in case
+ * the crate may not have any provenance information yet and
+ * should still be recognized as an imported crate.
+ *
+ * @return this crate, for convenience.
+ */
+ Crate markAsImported();
+
+ /**
+ * Check if the crate is marked as imported.
+ *
+ * If true, it indicates that the crate has been read from a file
+ * or is for other reasons not considered a new crate.
+ *
+ * @return true if the crate is marked as imported, false otherwise.
+ */
+ boolean isImported();
+
/**
* Read version from the crate descriptor and return it as a class
* representation.
diff --git a/src/main/java/edu/kit/datamanager/ro_crate/RoCrate.java b/src/main/java/edu/kit/datamanager/ro_crate/RoCrate.java
index 356b0159..8ebae01e 100644
--- a/src/main/java/edu/kit/datamanager/ro_crate/RoCrate.java
+++ b/src/main/java/edu/kit/datamanager/ro_crate/RoCrate.java
@@ -50,6 +50,24 @@ public class RoCrate implements Crate {
protected Collection untrackedFiles;
+ /**
+ * Indicates whether this crate has been imported from an external source.
+ * This is used to determine if ro-crate-java should add a CreateAction
+ * or an UpdateAction in the provenance on export.
+ */
+ protected boolean isImported = false;
+
+ @Override
+ public RoCrate markAsImported() {
+ this.isImported = true;
+ return this;
+ }
+
+ @Override
+ public boolean isImported() {
+ return this.isImported;
+ }
+
@Override
public CratePreview getPreview() {
return this.roCratePreview;
@@ -375,12 +393,14 @@ public RoCrateBuilder addDescription(String description) {
* @return returns the builder for further usage.
*/
public RoCrateBuilder addDataEntity(DataEntity dataEntity) {
+ this.metadataContext.checkEntity(dataEntity);
this.payload.addDataEntity(dataEntity);
this.rootDataEntity.addToHasPart(dataEntity.getId());
return this;
}
public RoCrateBuilder addContextualEntity(ContextualEntity contextualEntity) {
+ this.metadataContext.checkEntity(contextualEntity);
this.payload.addContextualEntity(contextualEntity);
return this;
}
diff --git a/src/main/java/edu/kit/datamanager/ro_crate/context/RoCrateMetadataContext.java b/src/main/java/edu/kit/datamanager/ro_crate/context/RoCrateMetadataContext.java
index cce6a7e9..d76cf86b 100644
--- a/src/main/java/edu/kit/datamanager/ro_crate/context/RoCrateMetadataContext.java
+++ b/src/main/java/edu/kit/datamanager/ro_crate/context/RoCrateMetadataContext.java
@@ -12,6 +12,7 @@
import java.io.IOException;
import java.util.*;
import java.util.function.Consumer;
+import java.util.function.Function;
import edu.kit.datamanager.ro_crate.special.IdentifierUtils;
import org.apache.http.client.methods.CloseableHttpResponse;
@@ -38,7 +39,7 @@ public class RoCrateMetadataContext implements CrateMetadataContext {
protected final HashMap other = new HashMap<>();
/**
- * Default constructor for the creation of the default context.
+ * Default constructor for the creation of the v1.1 default context.
*/
public RoCrateMetadataContext() {
this.addToContextFromUrl(DEFAULT_CONTEXT);
@@ -46,6 +47,8 @@ public RoCrateMetadataContext() {
/**
* Constructor for creating the context from a list of url.
+ *
+ * Note: Does NOT contain the default context if not explicitly given!
*
* @param urls the url list with different context.
*/
@@ -55,6 +58,8 @@ public RoCrateMetadataContext(Collection urls) {
/**
* Constructor for creating the context from a json object.
+ *
+ * Note: Does NOT contain the default context if not explicitly given!
*
* @param context the Json object of the context.
*/
@@ -83,6 +88,28 @@ public RoCrateMetadataContext(JsonNode context) {
}
}
+ /**
+ * Converts the context into a JSON-LD representation.
+ *
+ * The resulting JSON structure depends on the content:
+ * - If there's only one URL and no key-value pairs: {"@context": "url"}
+ * - If there are multiple URLs and/or key-value pairs: {"@context": ["url1", "url2", {"key1": "value1", "key2": "value2"}]}
+ *
+ * Example output:
+ *
+ * {
+ * "@context": [
+ * "https://w3id.org/ro/crate/1.1/context",
+ * {
+ * "schema": "http://schema.org/",
+ * "rdfs": "http://www.w3.org/2000/01/rdf-schema#"
+ * }
+ * ]
+ * }
+ *
+ *
+ * @return an ObjectNode containing the JSON-LD context representation
+ */
@Override
public ObjectNode getContextJsonEntity() {
ObjectMapper objectMapper = MyObjectMapper.getMapper();
@@ -106,6 +133,21 @@ public ObjectNode getContextJsonEntity() {
return finalNode;
}
+ /**
+ * Checks if the given entity is valid according to the context.
+ *
+ * - full URLs in the @type and field names are considered valid without further checks.
+ * - The "@id" value is treated as a special case, where it refers to the entity's ID.
+ * - The "@json" type is a linked data built-in type and is always considered valid.
+ * - If a type or field name is not found in the context, it will print an error message and return false.
+ * - This method checks both the types in the @type array and the field names in the entity's properties.
+ * - Prefixes in the context are considered valid if they match the context keys.
+ * - Suffixes after a valid prefix are considered valid in any case. This is not perfect,
+ * but it would be hard to handle correctly.
+ *
+ * @param entity the entity to check
+ * @return true if the entity is valid, false otherwise
+ */
@Override
public boolean checkEntity(AbstractEntity entity) {
ObjectMapper objectMapper = MyObjectMapper.getMapper();
@@ -117,6 +159,11 @@ public boolean checkEntity(AbstractEntity entity) {
entity.getProperties().path("@type"),
new TypeReference<>() {}
);
+
+ final Function isFail = checkMeStr -> this.contextMap.get(checkMeStr) == null
+ && this.contextMap.keySet().stream()
+ .noneMatch(key -> checkMeStr.startsWith(key + ":"));
+
// check if the items in the array of types are present in the context
for (String s : types) {
// special cases:
@@ -134,7 +181,7 @@ public boolean checkEntity(AbstractEntity entity) {
continue;
}
- if (this.contextMap.get(s) == null) {
+ if (isFail.apply(s)) {
System.err.println("type " + s + " is missing from the context!");
return false;
}
@@ -147,7 +194,7 @@ public boolean checkEntity(AbstractEntity entity) {
// full URLs are considered fine
continue;
}
- if (this.contextMap.get(s) == null) {
+ if (isFail.apply(s)) {
System.err.println("attribute name " + s + " is missing from context;");
return false;
}
@@ -155,6 +202,13 @@ public boolean checkEntity(AbstractEntity entity) {
return true;
}
+ /**
+ * Adds a URL to the context.
+ *
+ * It will try to fetch the context from the URL.
+ *
+ * @param url the URL to add
+ */
@Override
public void addToContextFromUrl(String url) {
this.urls.add(url);
@@ -194,18 +248,31 @@ public void addToContextFromUrl(String url) {
}));
}
+ /**
+ * Adds a key-value pair to the context.
+ *
+ * @param key the key to add. It may be a prefix or a term.
+ * @param value the value to add
+ */
@Override
public void addToContext(String key, String value) {
this.contextMap.put(key, value);
this.other.put(key, value);
}
+ /**
+ * @param key the key for the value to retrieve.
+ * @return the value of the key if it exists in the context, null otherwise.
+ */
@Override
public String getValueOf(String key) {
return Optional.ofNullable(this.contextMap.get(key))
.orElseGet(() -> this.other.get(key));
}
+ /**
+ * @return the set of all keys in the context.
+ */
@Override
public Set getKeys() {
List merged = new ArrayList<>();
@@ -214,6 +281,11 @@ public Set getKeys() {
return Set.copyOf(merged);
}
+ /**
+ * @return a map of all key-value pairs in the context. Note that some pairs may come
+ * from URLs or a pair may not be available as a context was not successfully resolved
+ * from a URL.
+ */
@Override
public Map getPairs() {
Map merged = new HashMap<>();
@@ -222,13 +294,18 @@ public Map getPairs() {
return Map.copyOf(merged);
}
-
+ /**
+ * @param key the key to delete from the context.
+ */
@Override
public void deleteValuePairFromContext(String key) {
this.contextMap.remove(key);
this.other.remove(key);
}
+ /**
+ * @param url the URL to delete from the context.
+ */
@Override
public void deleteUrlFromContext(String url) {
this.urls.remove(url);
diff --git a/src/main/java/edu/kit/datamanager/ro_crate/entities/AbstractEntity.java b/src/main/java/edu/kit/datamanager/ro_crate/entities/AbstractEntity.java
index f0c75763..38afcf4e 100644
--- a/src/main/java/edu/kit/datamanager/ro_crate/entities/AbstractEntity.java
+++ b/src/main/java/edu/kit/datamanager/ro_crate/entities/AbstractEntity.java
@@ -112,6 +112,19 @@ public JsonNode getProperty(String propertyKey) {
return this.properties.get(propertyKey);
}
+ /**
+ * Returns the value of the property with the given key as a String.
+ * If the property is not found, it returns null.
+ *
+ * @param propertyKey the key of the property.
+ * @return the value of the property as a String or null if not found.
+ */
+ public String getIdProperty(String propertyKey) {
+ return Optional.ofNullable(this.properties.get(propertyKey))
+ .map(jsonNode -> jsonNode.path("@id").asText(null))
+ .orElse(null);
+ }
+
@JsonIgnore
public String getId() {
JsonNode id = this.properties.get("@id");
@@ -241,9 +254,7 @@ private static boolean addProperty(ObjectNode whereToAdd, String key, JsonNode v
public void addIdProperty(String name, String id) {
if (id == null || id.isBlank()) { return; }
mergeIdIntoValue(id, this.properties.get(name))
- .ifPresent(newValue -> {
- this.properties.set(name, newValue);
- });
+ .ifPresent(newValue -> this.properties.set(name, newValue));
this.linkedTo.add(id);
this.notifyObservers();
}
@@ -356,7 +367,7 @@ private static void checkFormatISO8601(String date) throws IllegalArgumentExcept
/**
* Adds a property with date time format. The property should match the ISO 8601
* date format.
- *
+ *
* Same as {@link #addProperty(String, String)} but with internal check.
*
* @param key key of the property (e.g. datePublished)
@@ -411,7 +422,7 @@ public T setId(String id) {
if (IdentifierUtils.isValidUri(id)) {
this.id = id;
} else {
- this.id = IdentifierUtils.encode(id).get();
+ this.id = IdentifierUtils.encode(id).orElse(this.id);
}
}
return self();
@@ -448,7 +459,7 @@ public T addTypes(Collection types) {
/**
* Adds a property with date time format. The property should match the ISO 8601
* date format.
- *
+ *
* Same as {@link #addProperty(String, String)} but with internal check.
*
* @param key key of the property (e.g. datePublished)
@@ -508,7 +519,7 @@ public T addProperty(String key, boolean value) {
/**
* ID properties are often used when referencing other entities within
* the ROCrate. This method adds automatically such one.
- *
+ *
* Instead of {@code "name": "id" }
* this will add {@code "name" : {"@id": "id"} }
*
diff --git a/src/main/java/edu/kit/datamanager/ro_crate/preview/CratePreview.java b/src/main/java/edu/kit/datamanager/ro_crate/preview/CratePreview.java
index 2459f8b5..e966b4af 100644
--- a/src/main/java/edu/kit/datamanager/ro_crate/preview/CratePreview.java
+++ b/src/main/java/edu/kit/datamanager/ro_crate/preview/CratePreview.java
@@ -40,6 +40,8 @@ default void generate(Crate crate, File targetDir) throws IOException {
// as this is usually called in the process of writing a crate
// (including preview)
new CrateWriter<>(new WriteFolderStrategy().disablePreview())
+ // We assume the caller (e.g. a writer) already stored the provenance.
+ .withAutomaticProvenance(null)
.save(crate, targetDir.getAbsolutePath());
this.saveAllToFolder(targetDir);
try (var stream = Files.list(targetDir.toPath())) {
diff --git a/src/main/java/edu/kit/datamanager/ro_crate/reader/CrateReader.java b/src/main/java/edu/kit/datamanager/ro_crate/reader/CrateReader.java
index 5132e44b..271725b5 100644
--- a/src/main/java/edu/kit/datamanager/ro_crate/reader/CrateReader.java
+++ b/src/main/java/edu/kit/datamanager/ro_crate/reader/CrateReader.java
@@ -97,7 +97,7 @@ public RoCrate readCrate(T location) throws IOException {
usedFiles.add(files.toPath().resolve(FILE_METADATA_JSON).toFile().getPath());
usedFiles.add(files.toPath().resolve(FILE_PREVIEW_HTML).toFile().getPath());
usedFiles.add(files.toPath().resolve(FILE_PREVIEW_FILES).toFile().getPath());
- return rebuildCrate(metadataJson, files, usedFiles);
+ return rebuildCrate(metadataJson, files, usedFiles).markAsImported();
}
private RoCrate rebuildCrate(ObjectNode metadataJson, File files, HashSet usedFiles) {
diff --git a/src/main/java/edu/kit/datamanager/ro_crate/util/ClasspathPropertiesVersionProvider.java b/src/main/java/edu/kit/datamanager/ro_crate/util/ClasspathPropertiesVersionProvider.java
new file mode 100644
index 00000000..812772cd
--- /dev/null
+++ b/src/main/java/edu/kit/datamanager/ro_crate/util/ClasspathPropertiesVersionProvider.java
@@ -0,0 +1,42 @@
+package edu.kit.datamanager.ro_crate.util;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URL;
+import java.util.Properties;
+
+public class ClasspathPropertiesVersionProvider implements VersionProvider {
+ public static final String VERSION_PROPERTIES = "version.properties";
+
+ /**
+ * Cached version to avoid repeated file/resource reads.
+ */
+ private String cachedVersion = null;
+
+ /**
+ * Constructs a ClasspathPropertiesVersionProvider that reads the version from a properties file in the classpath.
+ */
+ public ClasspathPropertiesVersionProvider() {
+ // Lazy initialization - version loaded on first access
+ }
+
+ @Override
+ public String getVersion() {
+ if (cachedVersion != null) {
+ return cachedVersion;
+ }
+
+ URL resource = this.getClass().getResource("/" + VERSION_PROPERTIES);
+ assert resource != null : VERSION_PROPERTIES + " not found in classpath";
+
+ try (InputStream input = resource.openStream()) {
+ Properties properties = new Properties();
+ properties.load(input);
+ String version = properties.getProperty("version");
+ assert version != null : "Version property not found in " + VERSION_PROPERTIES;
+ return version.trim();
+ } catch (IOException e) {
+ throw new IllegalStateException("Failed to read version from properties file", e);
+ }
+ }
+}
diff --git a/src/main/java/edu/kit/datamanager/ro_crate/util/Graph.java b/src/main/java/edu/kit/datamanager/ro_crate/util/Graph.java
new file mode 100644
index 00000000..45161f16
--- /dev/null
+++ b/src/main/java/edu/kit/datamanager/ro_crate/util/Graph.java
@@ -0,0 +1,61 @@
+package edu.kit.datamanager.ro_crate.util;
+
+import com.fasterxml.jackson.databind.JsonNode;
+
+import java.util.stream.StreamSupport;
+
+/**
+ * Utility class for handling operations on RO-Crate graphs.
+ * Provides methods to find entities by ID or type within a graph.
+ *
+ * {@see JsonUtilFunctions}.
+ */
+public class Graph {
+
+ private Graph() {
+ // Private constructor to prevent instantiation
+ }
+
+ /**
+ * Finds an entity in the graph by its ID.
+ *
+ * @param graph The JSON node representing the graph.
+ * @param id The ID of the entity to find.
+ * @return The entity as a JsonNode if found, null otherwise.
+ */
+ public static JsonNode findEntityById(JsonNode graph, String id) {
+ for (JsonNode entity : graph) {
+ if (entity.has("@id") && entity.get("@id").asText().equals(id)) {
+ return entity;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Finds an entity in the graph by its type.
+ *
+ * @param graph The JSON node representing the graph.
+ * @param type The type of the entity to find.
+ * @return The entity as a JsonNode if found, null otherwise.
+ */
+ public static JsonNode findEntityByType(JsonNode graph, String type) {
+ return StreamSupport.stream(graph.spliterator(), false)
+ .filter(entity -> entity.path("@type").asText().equals(type))
+ .findFirst()
+ .orElse(null);
+ }
+
+ /**
+ * Finds all entities in the graph by their type.
+ *
+ * @param graph The JSON node representing the graph.
+ * @param type The type of the entities to find.
+ * @return An array of JsonNode containing all entities of the specified type.
+ */
+ public static JsonNode[] findEntitiesByType(JsonNode graph, String type) {
+ return StreamSupport.stream(graph.spliterator(), false)
+ .filter(entity -> entity.path("@type").asText().equals(type))
+ .toArray(JsonNode[]::new);
+ }
+}
diff --git a/src/main/java/edu/kit/datamanager/ro_crate/util/VersionProvider.java b/src/main/java/edu/kit/datamanager/ro_crate/util/VersionProvider.java
new file mode 100644
index 00000000..cd5c4ad2
--- /dev/null
+++ b/src/main/java/edu/kit/datamanager/ro_crate/util/VersionProvider.java
@@ -0,0 +1,10 @@
+package edu.kit.datamanager.ro_crate.util;
+
+public interface VersionProvider {
+ /**
+ * Returns the version of the ro-crate-java library.
+ *
+ * @return The version string.
+ */
+ String getVersion();
+}
diff --git a/src/main/java/edu/kit/datamanager/ro_crate/writer/CrateWriter.java b/src/main/java/edu/kit/datamanager/ro_crate/writer/CrateWriter.java
index caba67f9..d708ddd7 100644
--- a/src/main/java/edu/kit/datamanager/ro_crate/writer/CrateWriter.java
+++ b/src/main/java/edu/kit/datamanager/ro_crate/writer/CrateWriter.java
@@ -15,11 +15,29 @@
public class CrateWriter {
private final GenericWriterStrategy strategy;
+ protected ProvenanceManager provenanceManager = new ProvenanceManager();
+ /**
+ * Constructs a CrateWriter with a specified strategy for writing crates.
+ *
+ * @param strategy the strategy to use for writing crates.
+ */
public CrateWriter(GenericWriterStrategy strategy) {
this.strategy = strategy;
}
+ /**
+ * Sets the ProvenanceManager to be used for automatic provenance information
+ * generation when saving the crate.
+ *
+ * @param provenanceManager the ProvenanceManager to use. Using null will disable provenance management.
+ * @return this CrateWriter instance for method chaining.
+ */
+ public CrateWriter withAutomaticProvenance(ProvenanceManager provenanceManager) {
+ this.provenanceManager = provenanceManager;
+ return this;
+ }
+
/**
* This method saves the crate to a destination provided.
*
@@ -29,6 +47,9 @@ public CrateWriter(GenericWriterStrategy strategy) {
public void save(Crate crate, DESTINATION_TYPE destination) throws IOException {
Validator defaultValidation = new Validator(new JsonSchemaValidation());
defaultValidation.validate(crate);
+ if (this.provenanceManager != null) {
+ this.provenanceManager.addProvenanceInformation(crate);
+ }
this.strategy.save(crate, destination);
}
}
diff --git a/src/main/java/edu/kit/datamanager/ro_crate/writer/ProvenanceManager.java b/src/main/java/edu/kit/datamanager/ro_crate/writer/ProvenanceManager.java
new file mode 100644
index 00000000..fd4a2c50
--- /dev/null
+++ b/src/main/java/edu/kit/datamanager/ro_crate/writer/ProvenanceManager.java
@@ -0,0 +1,138 @@
+package edu.kit.datamanager.ro_crate.writer;
+
+import edu.kit.datamanager.ro_crate.Crate;
+import edu.kit.datamanager.ro_crate.entities.contextual.ContextualEntity;
+import edu.kit.datamanager.ro_crate.util.ClasspathPropertiesVersionProvider;
+import edu.kit.datamanager.ro_crate.util.VersionProvider;
+
+import java.time.Instant;
+
+import static edu.kit.datamanager.ro_crate.entities.contextual.ContextualEntity.ContextualEntityBuilder;
+
+/**
+ * Manages provenance information for RO-Crates.
+ * Handles the creation and updating of ro-crate-java entity and its actions.
+ */
+public class ProvenanceManager {
+ /**
+ * A record to hold the prefix for the ro-crate-java ID.
+ * This is used to ensure that the ID is consistent across different versions of the library.
+ * Having a type for the prefix avoids using it by accident as a full ID.
+ */
+ protected record IdPrefix(String prefix) {
+ /**
+ * Constructs a String with the given suffix.
+ *
+ * @param suffix The suffix to append to the prefix.
+ * @return A String combining the prefix and suffix, separated by a hyphen.
+ * Like this: "$prefix-$suffix".
+ */
+ public String withSuffix(String suffix) {
+ return prefix + "-" + suffix;
+ }
+
+ @Override
+ public String toString() {
+ return prefix;
+ }
+ }
+
+ /**
+ * The prefix for the ro-crate-java ID.
+ * This is used to identify the ro-crate-java entity in the crate.
+ */
+ protected static final IdPrefix RO_CRATE_JAVA_ID_PREFIX = new IdPrefix("#ro-crate-java");
+
+ /**
+ * The VersionProvider used to retrieve the version of ro-crate-java.
+ * This allows for flexibility in how the version is determined, e.g., from a properties file.
+ */
+ protected VersionProvider versionProvider;
+
+ /**
+ * Constructs a ProvenanceManager with the default ClasspathPropertiesVersionProvider.
+ */
+ public ProvenanceManager() {
+ this(new ClasspathPropertiesVersionProvider());
+ }
+
+ /**
+ * Constructs a ProvenanceManager with a specified VersionProvider.
+ *
+ * @param versionProvider The VersionProvider to use for retrieving the version of ro-crate-java.
+ */
+ public ProvenanceManager(VersionProvider versionProvider) {
+ this.versionProvider = versionProvider;
+ }
+
+ /**
+ * Returns the full ID for the ro-crate-java entity of this library version
+ * to be used for an entity describing it.
+ *
+ * The ID is constructed using the RO_CRATE_JAVA_ID_PREFIX and the version from the VersionProvider.
+ *
+ * @return The ID for the ro-crate-java entity.
+ */
+ public String getLibraryId() {
+ return RO_CRATE_JAVA_ID_PREFIX.withSuffix(versionProvider.getVersion().toLowerCase());
+ }
+
+ /**
+ * Adds provenance information to the given crate.
+ * This includes creating or updating the ro-crate-java entity and its associated action entity.
+ *
+ * @param crate The crate to which provenance information will be added.
+ */
+ public void addProvenanceInformation(Crate crate) {
+ // Determine if this is the first write
+ boolean isFirstWrite = crate.getAllContextualEntities().stream().noneMatch(
+ entity -> entity.getId().startsWith(RO_CRATE_JAVA_ID_PREFIX.toString()))
+ && !crate.isImported();
+
+ String libraryId = this.getLibraryId();
+
+ // Create action entity first
+ ContextualEntity actionEntity = buildNewActionEntity(isFirstWrite, libraryId);
+
+ // Create or update ro-crate-java entity
+ ContextualEntity roCrateJavaEntity = buildRoCrateJavaEntity(crate, actionEntity.getId(), libraryId);
+
+ // Add entities to crate
+ crate.addContextualEntity(roCrateJavaEntity);
+ crate.addContextualEntity(actionEntity);
+ }
+
+ protected ContextualEntity buildNewActionEntity(boolean isFirstWrite, String libraryId) {
+ return new ContextualEntityBuilder()
+ .addType(isFirstWrite ? "CreateAction" : "UpdateAction")
+ .addIdProperty("result", "./")
+ .addProperty("startTime", Instant.now().toString())
+ .addIdProperty("agent", libraryId)
+ .build();
+ }
+
+ protected ContextualEntity buildRoCrateJavaEntity(
+ Crate crate,
+ String newActionId,
+ String libraryId
+ ) {
+ String version = this.versionProvider.getVersion();
+ ContextualEntity self = crate.getAllContextualEntities().stream()
+ .filter(contextualEntity -> libraryId.equals(contextualEntity.getId()))
+ .findFirst()
+ .orElseGet(() -> new ContextualEntityBuilder()
+ .setId(libraryId)
+ .addType("SoftwareApplication")
+ .addProperty("name", "ro-crate-java")
+ .addProperty("url", "https://github.com/kit-data-manager/ro-crate-java")
+ .addProperty("version", version)
+ .addProperty("softwareVersion", version)
+ .addProperty("license", "Apache-2.0")
+ .addProperty("description", "A Java library for creating and manipulating RO-Crates")
+ .addIdProperty("Action", newActionId)
+ .build()
+ );
+ self.addIdProperty("Action", newActionId);
+ return self;
+ }
+}
diff --git a/src/test/java/edu/kit/datamanager/ro_crate/context/ContextTest.java b/src/test/java/edu/kit/datamanager/ro_crate/context/RoCrateMetadataContextTest.java
similarity index 88%
rename from src/test/java/edu/kit/datamanager/ro_crate/context/ContextTest.java
rename to src/test/java/edu/kit/datamanager/ro_crate/context/RoCrateMetadataContextTest.java
index 4995f272..1fb7b574 100644
--- a/src/test/java/edu/kit/datamanager/ro_crate/context/ContextTest.java
+++ b/src/test/java/edu/kit/datamanager/ro_crate/context/RoCrateMetadataContextTest.java
@@ -6,6 +6,7 @@
import com.fasterxml.jackson.databind.node.ObjectNode;
import edu.kit.datamanager.ro_crate.HelpFunctions;
+import edu.kit.datamanager.ro_crate.RoCrate;
import edu.kit.datamanager.ro_crate.entities.AbstractEntity;
import edu.kit.datamanager.ro_crate.entities.contextual.ContextualEntity;
import edu.kit.datamanager.ro_crate.entities.data.DataEntity;
@@ -20,7 +21,7 @@
import static org.junit.jupiter.api.Assertions.*;
-public class ContextTest {
+public class RoCrateMetadataContextTest {
RoCrateMetadataContext context;
RoCrateMetadataContext complexContext;
@@ -32,7 +33,7 @@ void initContext() throws IOException {
final String crateManifestPath = "/crates/extendedContextExample/ro-crate-metadata.json";
ObjectMapper objectMapper = MyObjectMapper.getMapper();
- JsonNode jsonNode = objectMapper.readTree(ContextTest.class.getResourceAsStream(crateManifestPath));
+ JsonNode jsonNode = objectMapper.readTree(RoCrateMetadataContextTest.class.getResourceAsStream(crateManifestPath));
this.complexContext = new RoCrateMetadataContext(jsonNode.get("@context"));
}
@@ -98,8 +99,8 @@ void creationFromPairsJsonTest() {
var objectMapper = MyObjectMapper.getMapper();
ObjectNode rawContext = objectMapper.createObjectNode();
- rawContext.put("house", "www.example.con/house");
- rawContext.put("road", "www.example.con/road");
+ rawContext.put("house", "www.example.com/house");
+ rawContext.put("road", "www.example.com/road");
ObjectNode rawCrate = objectMapper.createObjectNode();
rawCrate.set("@context", rawContext);
@@ -107,6 +108,13 @@ void creationFromPairsJsonTest() {
assertNotNull(newContext);
HelpFunctions.compare(newContext.getContextJsonEntity(), rawCrate, true);
+
+ var entityWithTerms = new ContextualEntity.ContextualEntityBuilder()
+ .setId("dkfaj")
+ .addType("house")
+ .addType("road")
+ .build();
+ assertTrue(newContext.checkEntity(entityWithTerms));
}
@Test
@@ -278,4 +286,21 @@ void testReadPairs() {
// prove immutability
assertThrows(UnsupportedOperationException.class, () -> given.put("newKey", "newValue"));
}
+
+ @Test
+ void checkEntity_withDefinedPrefixedType_succeeds() throws IOException {
+ // assume we read a crate just for demonstration
+ RoCrate crate = new RoCrate();
+ // and we extend the context
+ RoCrateMetadataContext context = new RoCrateMetadataContext();
+ context.addToContext("rdfs", "https://www.w3.org/2000/01/rdf-schema#");
+ crate.setMetadataContext(context);
+ // then we use the new context
+ DataEntity entity = new DataEntity.DataEntityBuilder()
+ .addType("rdfs:Property")
+ .build();
+ crate.addDataEntity(entity);
+ // Then we expect this to work
+ assertTrue(context.checkEntity(entity));
+ }
}
diff --git a/src/test/java/edu/kit/datamanager/ro_crate/reader/CommonReaderTest.java b/src/test/java/edu/kit/datamanager/ro_crate/reader/CommonReaderTest.java
index 1e162a1c..196fe97e 100644
--- a/src/test/java/edu/kit/datamanager/ro_crate/reader/CommonReaderTest.java
+++ b/src/test/java/edu/kit/datamanager/ro_crate/reader/CommonReaderTest.java
@@ -127,8 +127,12 @@ default void TestWithFileWithLocation(@TempDir Path temp) throws IOException {
{
// write raw crate and imported crate to two different directories
CrateWriter writer = Writers.newFolderWriter();
- writer.save(rawCrate, rawCrateTarget.toString());
- writer.save(importedCrate, importedCrateTarget.toString());
+ writer
+ .withAutomaticProvenance(null)
+ .save(rawCrate, rawCrateTarget.toString());
+ writer
+ .withAutomaticProvenance(null)
+ .save(importedCrate, importedCrateTarget.toString());
}
assertTrue(HelpFunctions.compareTwoDir(rawCrateTarget.toFile(), importedCrateTarget.toFile()));
diff --git a/src/test/java/edu/kit/datamanager/ro_crate/reader/FolderReaderTest.java b/src/test/java/edu/kit/datamanager/ro_crate/reader/FolderReaderTest.java
index 83bf4f78..eaee1b46 100644
--- a/src/test/java/edu/kit/datamanager/ro_crate/reader/FolderReaderTest.java
+++ b/src/test/java/edu/kit/datamanager/ro_crate/reader/FolderReaderTest.java
@@ -21,7 +21,9 @@ class FolderReaderTest implements CommonReaderTest
{
@Override
public void saveCrate(Crate crate, Path target) throws IOException {
- Writers.newFolderWriter().save(crate, target.toAbsolutePath().toString());
+ Writers.newFolderWriter()
+ .withAutomaticProvenance(null)
+ .save(crate, target.toAbsolutePath().toString());
assertTrue(target.toFile().isDirectory());
}
diff --git a/src/test/java/edu/kit/datamanager/ro_crate/reader/ZipReaderTest.java b/src/test/java/edu/kit/datamanager/ro_crate/reader/ZipReaderTest.java
index 35b168e1..b6ac3ea4 100644
--- a/src/test/java/edu/kit/datamanager/ro_crate/reader/ZipReaderTest.java
+++ b/src/test/java/edu/kit/datamanager/ro_crate/reader/ZipReaderTest.java
@@ -14,7 +14,9 @@ class ZipReaderTest implements
{
@Override
public void saveCrate(Crate crate, Path target) throws IOException {
- Writers.newZipPathWriter().save(crate, target.toAbsolutePath().toString());
+ Writers.newZipPathWriter()
+ .withAutomaticProvenance(null)
+ .save(crate, target.toAbsolutePath().toString());
assertTrue(target.toFile().isFile());
}
diff --git a/src/test/java/edu/kit/datamanager/ro_crate/reader/ZipStreamReaderTest.java b/src/test/java/edu/kit/datamanager/ro_crate/reader/ZipStreamReaderTest.java
index 11b82d12..7cf10b32 100644
--- a/src/test/java/edu/kit/datamanager/ro_crate/reader/ZipStreamReaderTest.java
+++ b/src/test/java/edu/kit/datamanager/ro_crate/reader/ZipStreamReaderTest.java
@@ -33,7 +33,9 @@ public void saveCrate(Crate crate, Path target) throws IOException {
try (
FileOutputStream fos = new FileOutputStream(target_file)
) {
- Writers.newZipStreamWriter().save(crate, fos);
+ Writers.newZipStreamWriter()
+ .withAutomaticProvenance(null)
+ .save(crate, fos);
}
assertTrue(target_file.isFile());
}
diff --git a/src/test/java/edu/kit/datamanager/ro_crate/writer/FolderWriterTest.java b/src/test/java/edu/kit/datamanager/ro_crate/writer/FolderWriterTest.java
index 0c187029..b6181801 100644
--- a/src/test/java/edu/kit/datamanager/ro_crate/writer/FolderWriterTest.java
+++ b/src/test/java/edu/kit/datamanager/ro_crate/writer/FolderWriterTest.java
@@ -16,6 +16,7 @@ class FolderWriterTest implements CommonWriterTest {
@Override
public void saveCrate(Crate crate, Path target) throws IOException {
Writers.newFolderWriter()
+ .withAutomaticProvenance(null)
.save(crate, target.toAbsolutePath().toString());
}
diff --git a/src/test/java/edu/kit/datamanager/ro_crate/writer/ProvenanceManagerTest.java b/src/test/java/edu/kit/datamanager/ro_crate/writer/ProvenanceManagerTest.java
new file mode 100644
index 00000000..7e26355a
--- /dev/null
+++ b/src/test/java/edu/kit/datamanager/ro_crate/writer/ProvenanceManagerTest.java
@@ -0,0 +1,182 @@
+package edu.kit.datamanager.ro_crate.writer;
+
+import edu.kit.datamanager.ro_crate.Crate;
+import edu.kit.datamanager.ro_crate.RoCrate;
+import edu.kit.datamanager.ro_crate.entities.contextual.ContextualEntity;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+class ProvenanceManagerTest {
+ public final String OLD_VERSION = "1.0.0";
+ private final ProvenanceManager OLD_PROV_MANAGER = new ProvenanceManager(() -> OLD_VERSION);
+ private final String OLD_LIBRARY_ID = OLD_PROV_MANAGER.getLibraryId();
+
+ public final String NEW_VERSION = "2.5.3";
+ private final ProvenanceManager NEW_PROV_MANAGER = new ProvenanceManager(() -> NEW_VERSION);
+ private final String NEW_LIBRARY_ID = NEW_PROV_MANAGER.getLibraryId();
+
+ @Test
+ void should_CreateInitialEntities_WithCorrectVersion() {
+ // Given
+ Crate crate = new RoCrate.RoCrateBuilder().build();
+
+ // When
+ OLD_PROV_MANAGER.addProvenanceInformation(crate);
+
+ // Then
+ var entities = crate.getAllContextualEntities();
+ assertEquals(2, entities.size(), "Should have created two entities");
+
+ // Find ro-crate-java entity
+ var roCrateJavaEntity = entities.stream()
+ .filter(e -> e.getId().equals(OLD_LIBRARY_ID))
+ .findFirst()
+ .orElseThrow();
+
+ assertEquals(OLD_VERSION, roCrateJavaEntity.getProperty("version").asText());
+ assertEquals(OLD_VERSION, roCrateJavaEntity.getProperty("softwareVersion").asText());
+
+ // Find CreateAction and verify it points to correct version
+ var createAction = entities.stream()
+ .filter(e -> e.getTypes().contains("CreateAction"))
+ .findFirst()
+ .orElseThrow();
+
+ assertEquals(OLD_LIBRARY_ID, createAction.getIdProperty("agent"));
+ }
+
+ @Test
+ void should_CreateDifferentEntities_WhenDifferentVersionsModifyCrate() {
+ // Given
+ Crate crate = new RoCrate.RoCrateBuilder().build();
+
+ // When creating with old version
+ OLD_PROV_MANAGER.addProvenanceInformation(crate);
+
+ // And modifying with new version
+ NEW_PROV_MANAGER.addProvenanceInformation(crate);
+
+ // Then
+ var entities = crate.getAllContextualEntities();
+ assertEquals(4, entities.size(), "Should have four entities (2 ro-crate-java + CreateAction + UpdateAction)");
+
+ // Verify both version entities exist
+ var oldVersionEntity = entities.stream()
+ .filter(e -> e.getId().equals(OLD_LIBRARY_ID))
+ .findFirst()
+ .orElseThrow();
+ var newVersionEntity = entities.stream()
+ .filter(e -> e.getId().equals(NEW_LIBRARY_ID))
+ .findFirst()
+ .orElseThrow();
+
+ assertEquals(OLD_VERSION, oldVersionEntity.getProperty("version").asText());
+ assertEquals(NEW_VERSION, newVersionEntity.getProperty("version").asText());
+
+ // Verify actions point to correct versions
+ var createAction = entities.stream()
+ .filter(e -> e.getTypes().contains("CreateAction"))
+ .findFirst()
+ .orElseThrow();
+ var updateAction = entities.stream()
+ .filter(e -> e.getTypes().contains("UpdateAction"))
+ .findFirst()
+ .orElseThrow();
+
+ assertEquals(OLD_LIBRARY_ID, createAction.getIdProperty("agent"),
+ "CreateAction should point to old version");
+ assertEquals(NEW_LIBRARY_ID, updateAction.getIdProperty("agent"),
+ "UpdateAction should point to new version");
+ }
+
+ @Test
+ void should_ReuseExistingVersionEntity_WhenSameVersionModifiesCrateMultipleTimes() {
+ // Given
+ Crate crate = new RoCrate.RoCrateBuilder().build();
+
+ // When modifying multiple times with same version
+ OLD_PROV_MANAGER.addProvenanceInformation(crate);
+ OLD_PROV_MANAGER.addProvenanceInformation(crate);
+ OLD_PROV_MANAGER.addProvenanceInformation(crate);
+
+ // Then
+ var entities = crate.getAllContextualEntities();
+
+ // Should have one ro-crate-java entity and three actions
+ long roCrateJavaCount = entities.stream()
+ .filter(e -> e.getId().startsWith(ProvenanceManager.RO_CRATE_JAVA_ID_PREFIX.toString()))
+ .count();
+ assertEquals(1, roCrateJavaCount, "Should have only one ro-crate-java entity");
+
+ var actions = entities.stream()
+ .filter(e -> e.getTypes().contains("CreateAction") || e.getTypes().contains("UpdateAction"))
+ .toList();
+ assertEquals(3, actions.size(), "Should have three actions");
+
+ // All actions should point to the same version entity
+ for (ContextualEntity action : actions) {
+ assertEquals(OLD_LIBRARY_ID, action.getIdProperty("agent"),
+ "All actions should point to the same version entity");
+ }
+ }
+
+ @Test
+ void should_PreserveVersionSpecificMetadata_WhenModifying() {
+ // Given
+ Crate crate = new RoCrate.RoCrateBuilder().build();
+
+ // When creating with old version
+ new ProvenanceManager(() -> OLD_VERSION).addProvenanceInformation(crate);
+
+ // And modifying with new version
+ new ProvenanceManager(() -> NEW_VERSION).addProvenanceInformation(crate);
+
+ // And modifying again with old version
+ new ProvenanceManager(() -> OLD_VERSION).addProvenanceInformation(crate);
+
+ // Then
+ var entities = crate.getAllContextualEntities();
+
+ // Should have exactly two ro-crate-java entities
+ var roCrateJavaEntities = entities.stream()
+ .filter(e -> e.getId().startsWith(ProvenanceManager.RO_CRATE_JAVA_ID_PREFIX.toString()))
+ .toList();
+ assertEquals(2, roCrateJavaEntities.size(), "Should have exactly two ro-crate-java entities");
+
+ // Each entity should maintain its complete metadata
+ for (ContextualEntity entity : roCrateJavaEntities) {
+ assertNotNull(entity.getProperty("name"), "Should have name");
+ assertNotNull(entity.getProperty("url"), "Should have url");
+ assertNotNull(entity.getProperty("license"), "Should have license");
+ assertEquals(entity.getProperty("version"),
+ entity.getProperty("softwareVersion"),
+ "version and softwareVersion should match");
+ }
+
+ // Actions should point to appropriate versions
+ var actions = entities.stream()
+ .filter(e -> e.getTypes().contains("CreateAction") || e.getTypes().contains("UpdateAction"))
+ .toList();
+ assertEquals(3, actions.size(), "Should have three actions");
+
+ // First action (CreateAction) should point to old version
+ var createAction = actions.stream()
+ .filter(e -> e.getTypes().contains("CreateAction"))
+ .findFirst()
+ .orElseThrow();
+ assertEquals(OLD_LIBRARY_ID, createAction.getIdProperty("agent"),
+ "CreateAction should point to old version");
+
+ // Update actions should point to respective versions
+ var updateActions = actions.stream()
+ .filter(e -> e.getTypes().contains("UpdateAction"))
+ .toList();
+ assertEquals(2, updateActions.size(), "Should have two update actions");
+
+ assertTrue(updateActions.stream()
+ .map(e -> e.getIdProperty("agent"))
+ .allMatch(id -> id.equals(OLD_LIBRARY_ID) || id.equals(NEW_LIBRARY_ID)),
+ "Update actions should point to either old or new version");
+ }
+}
\ No newline at end of file
diff --git a/src/test/java/edu/kit/datamanager/ro_crate/writer/RoCrateMetadataGenerationTest.java b/src/test/java/edu/kit/datamanager/ro_crate/writer/RoCrateMetadataGenerationTest.java
new file mode 100644
index 00000000..3c88ef07
--- /dev/null
+++ b/src/test/java/edu/kit/datamanager/ro_crate/writer/RoCrateMetadataGenerationTest.java
@@ -0,0 +1,406 @@
+package edu.kit.datamanager.ro_crate.writer;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import edu.kit.datamanager.ro_crate.RoCrate;
+import edu.kit.datamanager.ro_crate.HelpFunctions;
+import edu.kit.datamanager.ro_crate.reader.Readers;
+import edu.kit.datamanager.ro_crate.validation.JsonSchemaValidation;
+import edu.kit.datamanager.ro_crate.validation.Validator;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static edu.kit.datamanager.ro_crate.util.Graph.*;
+import static org.junit.jupiter.api.Assertions.*;
+
+class RoCrateMetadataGenerationTest {
+
+ private final String currentVersionId = new ProvenanceManager().getLibraryId();
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ private Validator validator;
+
+ @BeforeEach
+ void setUp() {
+ validator = new Validator(new JsonSchemaValidation());
+ }
+
+ private void validateCrate(RoCrate crate) {
+ assertTrue(validator.validate(crate),
+ "Crate should validate against the JSON schema");
+ }
+
+ @Test
+ void should_ContainRoCrateJavaEntities_When_WritingEmptyCrate(@TempDir Path tempDir) throws IOException {
+ // Create and write crate
+ RoCrate crate = new RoCrate.RoCrateBuilder().build();
+ validateCrate(crate);
+
+ Path outputPath = tempDir.resolve("test-crate");
+ Writers.newFolderWriter().save(crate, outputPath.toString());
+
+ // Re-read the crate to verify it's still valid after writing
+ RoCrate readCrate = Readers.newFolderReader().readCrate(outputPath.toString());
+ validateCrate(readCrate);
+
+ // Read and print metadata for debugging
+ String metadata = Files.readString(outputPath.resolve("ro-crate-metadata.json"));
+ HelpFunctions.prettyPrintJsonString(metadata);
+
+ // Parse metadata file
+ JsonNode rootNode = objectMapper.readTree(metadata);
+ JsonNode graph = rootNode.get("@graph");
+
+ // Find ro-crate-java entity
+ JsonNode roCrateJavaEntity = findEntityById(graph, this.currentVersionId);
+ assertNotNull(roCrateJavaEntity, "ro-crate-java entity should exist");
+ assertEquals("SoftwareApplication", roCrateJavaEntity.get("@type").asText(),
+ "ro-crate-java should be of type SoftwareApplication");
+
+ // Find CreateAction entity
+ JsonNode createActionEntity = findEntityByType(graph, "CreateAction");
+ assertNotNull(createActionEntity, "CreateAction entity should exist");
+ assertNotNull(createActionEntity.get("startTime"), "CreateAction should have startTime");
+ assertEquals(this.currentVersionId, createActionEntity.get("agent").get("@id").asText(),
+ "CreateAction should reference ro-crate-java as agent");
+ }
+
+ @Test
+ void should_HaveRequiredPropertiesInRoCrateJavaEntity_When_WritingCrate(@TempDir Path tempDir) throws IOException {
+ // Create and write crate
+ RoCrate crate = new RoCrate.RoCrateBuilder().build();
+ validateCrate(crate);
+
+ Path outputPath = tempDir.resolve("test-crate");
+ Writers.newFolderWriter().save(crate, outputPath.toString());
+
+ RoCrate readCrate = Readers.newFolderReader().readCrate(outputPath.toString());
+ validateCrate(readCrate);
+
+ // Read and print metadata for debugging
+ String metadata = Files.readString(outputPath.resolve("ro-crate-metadata.json"));
+ HelpFunctions.prettyPrintJsonString(metadata);
+
+ // Parse metadata file
+ JsonNode rootNode = objectMapper.readTree(metadata);
+ JsonNode roCrateJavaEntity = findEntityById(rootNode.get("@graph"), this.currentVersionId);
+
+ assertNotNull(roCrateJavaEntity, "ro-crate-java entity should exist");
+ assertEquals("ro-crate-java", roCrateJavaEntity.get("name").asText(),
+ "should have correct name");
+ assertEquals("https://github.com/kit-data-manager/ro-crate-java",
+ roCrateJavaEntity.get("url").asText(),
+ "should have correct repository URL");
+ assertNotNull(roCrateJavaEntity.get("version"),
+ "should have version property");
+ }
+
+ @Test
+ void should_HaveBidirectionalRelation_Between_RoCrateJavaAndItsAction(@TempDir Path tempDir) throws IOException {
+ // Create and write crate
+ RoCrate crate = new RoCrate.RoCrateBuilder().build();
+ validateCrate(crate);
+
+ Path outputPath = tempDir.resolve("test-crate");
+ Writers.newFolderWriter().save(crate, outputPath.toString());
+
+ RoCrate readCrate = Readers.newFolderReader().readCrate(outputPath.toString());
+ validateCrate(readCrate);
+
+ // Read and print metadata for debugging
+ String metadata = Files.readString(outputPath.resolve("ro-crate-metadata.json"));
+ HelpFunctions.prettyPrintJsonString(metadata);
+
+ // Parse metadata file
+ JsonNode rootNode = objectMapper.readTree(metadata);
+ JsonNode graph = rootNode.get("@graph");
+
+ // Get both entities
+ JsonNode roCrateJavaEntity = findEntityById(graph, this.currentVersionId);
+ JsonNode createActionEntity = findEntityByType(graph, "CreateAction");
+
+ assertNotNull(roCrateJavaEntity, "ro-crate-java entity should exist");
+ assertNotNull(createActionEntity, "CreateAction entity should exist");
+
+ // Test CreateAction -> ro-crate-java reference
+ JsonNode agentRef = createActionEntity.get("agent");
+ assertNotNull(agentRef, "CreateAction should have agent property");
+ assertEquals(this.currentVersionId, agentRef.get("@id").asText(),
+ "CreateAction's agent should reference ro-crate-java");
+
+ // Test ro-crate-java -> CreateAction reference
+ JsonNode actionRef = roCrateJavaEntity.get("Action");
+ assertNotNull(actionRef, "ro-crate-java should have action property");
+ assertEquals(createActionEntity.get("@id").asText(), actionRef.get("@id").asText(),
+ "ro-crate-java's action should reference the CreateAction");
+ }
+
+ @Test
+ void should_AccumulateActions_When_WritingMultipleTimes(@TempDir Path tempDir) throws IOException, InterruptedException {
+ // Create and write crate first time
+ RoCrate crate = new RoCrate.RoCrateBuilder().build();
+ validateCrate(crate);
+
+ Path outputPath = tempDir.resolve("test-crate");
+ Writers.newFolderWriter().save(crate, outputPath.toString());
+ validateCrate(Readers.newFolderReader().readCrate(outputPath.toString()));
+ Thread.sleep(10);
+
+ // Write same crate two more times to simulate updates
+ Writers.newFolderWriter().save(crate, outputPath.toString());
+ Thread.sleep(10);
+ Writers.newFolderWriter().save(crate, outputPath.toString());
+ validateCrate(Readers.newFolderReader().readCrate(outputPath.toString()));
+ Thread.sleep(10);
+
+ // Read and print metadata for debugging
+ String metadata = Files.readString(outputPath.resolve("ro-crate-metadata.json"));
+ HelpFunctions.prettyPrintJsonString(metadata);
+
+ // Parse metadata file
+ JsonNode rootNode = objectMapper.readTree(metadata);
+ JsonNode graph = rootNode.get("@graph");
+
+ // Get ro-crate-java entity
+ JsonNode roCrateJavaEntity = findEntityById(graph, this.currentVersionId);
+ assertNotNull(roCrateJavaEntity, "ro-crate-java entity should exist");
+
+ // Verify actions array exists and has three entries
+ assertTrue(roCrateJavaEntity.get("Action").isArray(),
+ "ro-crate-java should have an array of actions");
+ assertEquals(3, roCrateJavaEntity.get("Action").size(),
+ "should have three actions after three writes");
+
+ // Find all action entities
+ JsonNode createAction = findEntityByType(graph, "CreateAction");
+ assertNotNull(createAction, "should have one CreateAction");
+
+ JsonNode[] createActions = findEntitiesByType(graph, "CreateAction");
+ assertEquals(1, createActions.length, "should have exactly one CreateAction");
+
+ JsonNode[] updateActions = findEntitiesByType(graph, "UpdateAction");
+ assertEquals(2, updateActions.length, "should have two UpdateActions");
+
+ // Verify CreateAction properties
+ assertNotNull(createAction.get("startTime"), "CreateAction should have startTime");
+ assertEquals(this.currentVersionId, createAction.get("agent").get("@id").asText(),
+ "CreateAction should reference ro-crate-java as agent");
+
+ // Verify UpdateAction properties
+ for (JsonNode updateAction : updateActions) {
+ assertNotNull(updateAction.get("startTime"),
+ "UpdateAction should have startTime");
+ assertEquals(this.currentVersionId, updateAction.get("agent").get("@id").asText(),
+ "UpdateAction should reference ro-crate-java as agent");
+ }
+
+ // Verify chronological order of timestamps
+ String createTime = createAction.get("startTime").asText();
+ String updateTime1 = updateActions[0].get("startTime").asText();
+ String updateTime2 = updateActions[1].get("startTime").asText();
+ // The order of updates is not the order in the graph.
+ // But we can check that creation happened before the updates:
+ assertTrue(createTime.compareTo(updateTime1) < 0,
+ "First update should be after creation");
+ assertTrue(createTime.compareTo(updateTime2) < 0,
+ "Second update should be after creation");
+ }
+
+ @Test
+ void should_HaveValidVersionFormat_When_WritingCrate(@TempDir Path tempDir) throws IOException {
+ // Create and write crate
+ RoCrate crate = new RoCrate.RoCrateBuilder().build();
+ validateCrate(crate);
+
+ Path outputPath = tempDir.resolve("test-crate");
+ Writers.newFolderWriter().save(crate, outputPath.toString());
+ validateCrate(Readers.newFolderReader().readCrate(outputPath.toString()));
+
+ // Read and print metadata for debugging
+ String metadata = Files.readString(outputPath.resolve("ro-crate-metadata.json"));
+ HelpFunctions.prettyPrintJsonString(metadata);
+
+ // Parse metadata file
+ JsonNode rootNode = objectMapper.readTree(metadata);
+ JsonNode roCrateJavaEntity = findEntityById(rootNode.get("@graph"), this.currentVersionId);
+
+ // Version format validation
+ @SuppressWarnings("DataFlowIssue")
+ String version = roCrateJavaEntity.get("version").asText();
+
+ // Semantic versioning regex pattern that allows:
+ // - Required: major.minor.patch (e.g., 1.2.3)
+ // - Optional: pre-release identifier (e.g., -rc1, -RC1, -beta.1, -SNAPSHOT)
+ // - Optional: build metadata (e.g., +build.123)
+ String semverPattern = "(?i)^\\d+\\.\\d+\\.\\d+(?:-(?:rc\\d+|alpha|beta|snapshot)(?:\\.\\d+)?)?(?:\\+[0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*)?$";
+
+ assertTrue(version.matches(semverPattern),
+ String.format("Version '%s' should match semantic versioning format: major.minor.patch[-prerelease][+build]%n" +
+ "Examples: 1.2.3, 1.2.3-rc1, 1.2.3-SNAPSHOT, 1.2.3-beta.1, 1.2.3+build.123", version));
+ }
+
+ @SuppressWarnings("DataFlowIssue")
+ @Test
+ void should_HaveCompleteMetadata_When_WritingCrate(@TempDir Path tempDir) throws IOException {
+ // Create and write crate
+ RoCrate crate = new RoCrate.RoCrateBuilder().build();
+ validateCrate(crate);
+
+ Path outputPath = tempDir.resolve("test-crate");
+ Writers.newFolderWriter().save(crate, outputPath.toString());
+ validateCrate(Readers.newFolderReader().readCrate(outputPath.toString()));
+
+ // Read and print metadata for debugging
+ String metadata = Files.readString(outputPath.resolve("ro-crate-metadata.json"));
+ HelpFunctions.prettyPrintJsonString(metadata);
+
+ // Parse metadata file
+ JsonNode rootNode = objectMapper.readTree(metadata);
+ JsonNode roCrateJavaEntity = findEntityById(rootNode.get("@graph"), this.currentVersionId);
+
+ // Required properties with specific values
+ assertEquals("ro-crate-java", roCrateJavaEntity.get("name").asText(),
+ "should have correct name");
+ assertEquals("https://github.com/kit-data-manager/ro-crate-java",
+ roCrateJavaEntity.get("url").asText(),
+ "should have correct repository URL");
+ assertEquals("SoftwareApplication", roCrateJavaEntity.get("@type").asText(),
+ "should have correct type");
+
+ // Optional but recommended properties
+ assertNotNull(roCrateJavaEntity.get("description"),
+ "should have a description");
+ assertFalse(roCrateJavaEntity.get("description").asText().isEmpty(),
+ "description should not be empty");
+
+ assertNotNull(roCrateJavaEntity.get("license"),
+ "should have a license");
+ assertTrue(roCrateJavaEntity.has("softwareVersion"),
+ "should have softwareVersion as an alias for version");
+ assertEquals(roCrateJavaEntity.get("version").asText(),
+ roCrateJavaEntity.get("softwareVersion").asText(),
+ "version and softwareVersion should match");
+ }
+
+ @Test
+ void should_AddProvenanceInfo_When_ModifyingExistingCrateWithoutProvenance(@TempDir Path tempDir) throws IOException {
+ // First create a crate without provenance information
+ RoCrate originalCrate = new RoCrate.RoCrateBuilder().build();
+ validateCrate(originalCrate);
+
+ Path outputPath = tempDir.resolve("test-crate");
+
+ // Use writer with disabled provenance (not implemented yet)
+ Writers.newFolderWriter()
+ .withAutomaticProvenance(null)
+ .save(originalCrate, outputPath.toString());
+
+ // Verify the original crate has no provenance information
+ String originalMetadata = Files.readString(outputPath.resolve("ro-crate-metadata.json"));
+ HelpFunctions.prettyPrintJsonString(originalMetadata);
+
+ JsonNode originalRoot = objectMapper.readTree(originalMetadata);
+ JsonNode originalGraph = originalRoot.get("@graph");
+ assertNull(findEntityById(originalGraph, this.currentVersionId),
+ "Original crate should not have ro-crate-java entity");
+ assertNull(findEntityByType(originalGraph, "CreateAction"),
+ "Original crate should not have CreateAction");
+ assertNull(findEntityByType(originalGraph, "UpdateAction"),
+ "Original crate should not have UpdateAction");
+
+ // Now read and modify the crate
+ RoCrate modifiedCrate = Readers.newFolderReader().readCrate(outputPath.toString());
+ validateCrate(modifiedCrate);
+ modifiedCrate.getRootDataEntity().addProperty("description", "Modified crate");
+ validateCrate(modifiedCrate);
+
+ // Write the modified crate with provenance enabled (default)
+ Path modifiedPath = tempDir.resolve("modified-crate");
+ Writers.newFolderWriter().save(modifiedCrate, modifiedPath.toString());
+
+ // Read and verify the modified crate's metadata
+ String modifiedMetadata = Files.readString(modifiedPath.resolve("ro-crate-metadata.json"));
+ HelpFunctions.prettyPrintJsonString(modifiedMetadata);
+
+ JsonNode modifiedRoot = objectMapper.readTree(modifiedMetadata);
+ JsonNode modifiedGraph = modifiedRoot.get("@graph");
+
+ // Verify ro-crate-java entity was added
+ JsonNode roCrateJavaEntity = findEntityById(modifiedGraph, this.currentVersionId);
+ assertNotNull(roCrateJavaEntity, "ro-crate-java entity should be added");
+
+ // Should only have UpdateAction, no CreateAction
+ assertNull(findEntityByType(modifiedGraph, "CreateAction"),
+ "Modified crate should not have CreateAction");
+
+ JsonNode updateAction = findEntityByType(modifiedGraph, "UpdateAction");
+ assertNotNull(updateAction, "Should have UpdateAction");
+
+ // Verify update action properties
+ assertNotNull(updateAction.get("startTime"),
+ "UpdateAction should have startTime");
+ assertEquals(this.currentVersionId,
+ updateAction.get("agent").get("@id").asText(),
+ "UpdateAction should reference ro-crate-java as agent");
+
+ // Verify ro-crate-java references the action
+ assertTrue(roCrateJavaEntity.get("Action").isObject(),
+ "ro-crate-java should have a single reference to an UpdateAction");
+ assertEquals(updateAction.get("@id").asText(),
+ roCrateJavaEntity.get("Action").get("@id").asText(),
+ "ro-crate-java should reference the UpdateAction");
+ }
+
+ @Test
+ void should_PreserveExistingProvenance_When_ModifyingCrate(@TempDir Path tempDir) throws IOException, InterruptedException {
+ // First create a crate with normal provenance
+ RoCrate originalCrate = new RoCrate.RoCrateBuilder().build();
+ validateCrate(originalCrate);
+
+ Path outputPath = tempDir.resolve("test-crate");
+ Writers.newFolderWriter().save(originalCrate, outputPath.toString());
+ Thread.sleep(10);
+
+ // Now read and modify the crate
+ RoCrate modifiedCrate = Readers.newFolderReader().readCrate(outputPath.toString());
+ validateCrate(modifiedCrate);
+ modifiedCrate.getRootDataEntity().addProperty("description", "Modified crate");
+ validateCrate(modifiedCrate);
+
+ // Write the modified crate
+ Writers.newFolderWriter().save(modifiedCrate, outputPath.toString());
+
+ // Read and verify the metadata
+ String metadata = Files.readString(outputPath.resolve("ro-crate-metadata.json"));
+ HelpFunctions.prettyPrintJsonString(metadata);
+
+ JsonNode root = objectMapper.readTree(metadata);
+ JsonNode graph = root.get("@graph");
+
+ // Should have both CreateAction and UpdateAction
+ JsonNode createAction = findEntityByType(graph, "CreateAction");
+ assertNotNull(createAction, "Original CreateAction should be preserved");
+
+ JsonNode[] updateActions = findEntitiesByType(graph, "UpdateAction");
+ assertEquals(1, updateActions.length, "Should have exactly one UpdateAction");
+
+ // Verify chronological order
+ String createTime = createAction.get("startTime").asText();
+ String updateTime = updateActions[0].get("startTime").asText();
+ assertTrue(createTime.compareTo(updateTime) < 0,
+ "Update should be after creation");
+
+ // Verify ro-crate-java entity references both actions
+ JsonNode roCrateJavaEntity = findEntityById(graph, this.currentVersionId);
+ //noinspection DataFlowIssue
+ assertTrue(roCrateJavaEntity.get("Action").isArray(),
+ "ro-crate-java should have an array of actions");
+ assertEquals(2, roCrateJavaEntity.get("Action").size(),
+ "should have both actions");
+ }
+}
diff --git a/src/test/java/edu/kit/datamanager/ro_crate/writer/RoCrateWriterSpec12Test.java b/src/test/java/edu/kit/datamanager/ro_crate/writer/RoCrateWriterSpec12Test.java
index 90161e4d..f3f57ef7 100644
--- a/src/test/java/edu/kit/datamanager/ro_crate/writer/RoCrateWriterSpec12Test.java
+++ b/src/test/java/edu/kit/datamanager/ro_crate/writer/RoCrateWriterSpec12Test.java
@@ -30,6 +30,7 @@ void writeDoesNotModifyTest(@TempDir Path tempDir) throws IOException, URISyntax
Path targetDir = tempDir.resolve("spec12writeUnmodified");
Writers.newFolderWriter()
+ .withAutomaticProvenance(null)
.save(crate, targetDir.toAbsolutePath().toString());
// compare directories
diff --git a/src/test/java/edu/kit/datamanager/ro_crate/writer/ZipStreamWriterTest.java b/src/test/java/edu/kit/datamanager/ro_crate/writer/ZipStreamWriterTest.java
index 83e311b1..feb87471 100644
--- a/src/test/java/edu/kit/datamanager/ro_crate/writer/ZipStreamWriterTest.java
+++ b/src/test/java/edu/kit/datamanager/ro_crate/writer/ZipStreamWriterTest.java
@@ -17,7 +17,9 @@ class ZipStreamWriterTest implements
@Override
public void saveCrate(Crate crate, Path target) throws IOException {
try (FileOutputStream stream = new FileOutputStream(target.toFile())) {
- Writers.newZipStreamWriter().save(crate, stream);
+ Writers.newZipStreamWriter()
+ .withAutomaticProvenance(null)
+ .save(crate, stream);
}
}
@@ -25,6 +27,7 @@ public void saveCrate(Crate crate, Path target) throws IOException {
public void saveCrateElnStyle(Crate crate, Path target) throws IOException {
try (FileOutputStream stream = new FileOutputStream(target.toFile())) {
new CrateWriter<>(new WriteZipStreamStrategy().usingElnStyle())
+ .withAutomaticProvenance(null)
.save(crate, stream);
}
}
@@ -33,6 +36,7 @@ public void saveCrateElnStyle(Crate crate, Path target) throws IOException {
public void saveCrateSubdirectoryStyle(RoCrate crate, Path target) throws IOException {
try (FileOutputStream stream = new FileOutputStream(target.toFile())) {
new CrateWriter<>(new WriteZipStreamStrategy().withRootSubdirectory())
+ .withAutomaticProvenance(null)
.save(crate, stream);
}
}
diff --git a/src/test/java/edu/kit/datamanager/ro_crate/writer/ZipWriterTest.java b/src/test/java/edu/kit/datamanager/ro_crate/writer/ZipWriterTest.java
index bfb29c3d..d1352f31 100644
--- a/src/test/java/edu/kit/datamanager/ro_crate/writer/ZipWriterTest.java
+++ b/src/test/java/edu/kit/datamanager/ro_crate/writer/ZipWriterTest.java
@@ -13,18 +13,21 @@ class ZipWriterTest implements
@Override
public void saveCrate(Crate crate, Path target) throws IOException {
Writers.newZipPathWriter()
+ .withAutomaticProvenance(null)
.save(crate, target.toAbsolutePath().toString());
}
@Override
public void saveCrateElnStyle(Crate crate, Path target) throws IOException {
new CrateWriter<>(new WriteZipStrategy().usingElnStyle())
+ .withAutomaticProvenance(null)
.save(crate, target.toAbsolutePath().toString());
}
@Override
public void saveCrateSubdirectoryStyle(RoCrate crate, Path target) throws IOException {
new CrateWriter<>(new WriteZipStrategy().withRootSubdirectory())
+ .withAutomaticProvenance(null)
.save(crate, target.toString());
}
}