From bb611c5616270eadb60b7f9137975f9698e72337 Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Fri, 10 Apr 2026 16:32:11 -0400 Subject: [PATCH 1/8] add test to create dataset and local review of it #12313 --- .../harvard/iq/dataverse/api/ReviewsIT.java | 211 ++++++++++++++++++ 1 file changed, 211 insertions(+) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java index 2fc74c4b3f1..db2f53a7137 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java @@ -1,6 +1,9 @@ package edu.harvard.iq.dataverse.api; +import static edu.harvard.iq.dataverse.api.ApiConstants.DS_VERSION_LATEST_PUBLISHED; + import edu.harvard.iq.dataverse.dataset.DatasetType; +import edu.harvard.iq.dataverse.util.StringUtil; import edu.harvard.iq.dataverse.util.json.JsonUtil; import edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder; import io.restassured.RestAssured; @@ -344,4 +347,212 @@ public void testCreateReviewRequiredFields() { } + @Test + public void testLocalReviews() { + + Response createUserDatasetAuthor = UtilIT.createRandomUser(); + createUserDatasetAuthor.prettyPrint(); + createUserDatasetAuthor.then().assertThat() + .statusCode(OK.getStatusCode()); + String usernameDatasetAuthor = UtilIT.getUsernameFromResponse(createUserDatasetAuthor); + String apiTokenDatasetAuthor = UtilIT.getApiTokenFromResponse(createUserDatasetAuthor); + + Response createCollectionOfDatasets = UtilIT.createRandomDataverse(apiTokenDatasetAuthor); + createCollectionOfDatasets.prettyPrint(); + createCollectionOfDatasets.then().assertThat() + .statusCode(CREATED.getStatusCode()); + + String collectionAliasDatasets = UtilIT.getAliasFromResponse(createCollectionOfDatasets); + String datasetJson = """ + { + "http://purl.org/dc/terms/title": "Pediatric Asthma", + "http://purl.org/dc/terms/creator": { + "https://dataverse.org/schema/citation/authorName": "Sullivan, James" + }, + "https://dataverse.org/schema/citation/datasetContact": { + "https://dataverse.org/schema/citation/datasetContactEmail": "sully@mailinator.com" + }, + "https://dataverse.org/schema/citation/dsDescription": { + "https://dataverse.org/schema/citation/dsDescriptionValue": "A dataset about pediatric asthma." + }, + "http://purl.org/dc/terms/subject": "Medicine, Health and Life Sciences" + } + """; + + Response createDataset = UtilIT.createDatasetSemantic(collectionAliasDatasets, datasetJson, + apiTokenDatasetAuthor); + createDataset.prettyPrint(); + createDataset.then().assertThat().statusCode(CREATED.getStatusCode()); + + Integer datasetId = UtilIT.getDatasetIdFromResponse(createDataset); + String datasetPid = UtilIT.getDatasetPersistentIdFromResponse(createDataset); + + Response setLicensetoCC0 = UtilIT.updateLicense(datasetId.toString(), "{ \"name\": \"CC0 1.0\" }", + apiTokenDatasetAuthor); + setLicensetoCC0.prettyPrint(); + setLicensetoCC0.then().assertThat().statusCode(OK.getStatusCode()); + + Response publishCollection = UtilIT.publishDataverseViaNativeApi(collectionAliasDatasets, + apiTokenDatasetAuthor); + // publishCollection.prettyPrint(); + publishCollection.then().assertThat() + .statusCode(OK.getStatusCode()); + + Response publishDataset = UtilIT.publishDatasetViaNativeApi(datasetId, "major", apiTokenDatasetAuthor); + publishDataset.prettyPrint(); + publishDataset.then().assertThat() + .statusCode(OK.getStatusCode()); + + Response createUserReviewer = UtilIT.createRandomUser(); + createUserReviewer.prettyPrint(); + createUserReviewer.then().assertThat() + .statusCode(OK.getStatusCode()); + String usernameReviewer = UtilIT.getUsernameFromResponse(createUserReviewer); + String apiTokenReviewer = UtilIT.getApiTokenFromResponse(createUserReviewer); + + Response getDataset = UtilIT.nativeGetUsingPersistentId(datasetPid, apiTokenReviewer); + getDataset.prettyPrint(); + getDataset.then().assertThat().statusCode(OK.getStatusCode()); + + String datasetPersistentUrl = JsonPath.from(getDataset.body().asString()).getString("data.persistentUrl"); + String datasetTitle = JsonPath.from(getDataset.body().asString()) + .getString("data.latestVersion.metadataBlocks.citation.fields[0].value"); + + Response getCitation = UtilIT.getDatasetVersionCitation(datasetId, DS_VERSION_LATEST_PUBLISHED, false, + apiTokenReviewer); + getCitation.prettyPrint(); + getCitation.then().assertThat().statusCode(OK.getStatusCode()); + String datasetCitationHtml = JsonPath.from(getCitation.getBody().asString()).getString("data.message"); + String datasetCitationText = StringUtil.html2text(datasetCitationHtml); + + Response createCollectionOfReviews = UtilIT.createRandomDataverse(apiTokenReviewer); + createCollectionOfReviews.prettyPrint(); + createCollectionOfReviews.then().assertThat() + .statusCode(CREATED.getStatusCode()); + + String collectionAliasReviews = UtilIT.getAliasFromResponse(createCollectionOfReviews); + + Response setAllowedDatasetTypes = UtilIT.setCollectionAttribute(collectionAliasReviews, "allowedDatasetTypes", + "review", apiTokenSuperuser); + setAllowedDatasetTypes.prettyPrint(); + setAllowedDatasetTypes.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.allowedDatasetTypes[0].name", is("review")) + .body("data.allowedDatasetTypes[0].displayName", is("Review")) + .body("data.allowedDatasetTypes[0].description", + is("A review of a dataset compiled by the expert community.")); + + String itemReviewedTitle = datasetTitle; + String itemReviewedUrl = datasetPersistentUrl; + String itemReviewedCitation = datasetCitationHtml; + String reviewTitle = "Review of " + itemReviewedTitle; + String authorName = "Wazowski, Mike"; + String authorEmail = "mwazowski@mailinator.com"; + JsonObjectBuilder jsonForCreatingReview = Json.createObjectBuilder() + /** + * See above where this type is added to the installation and + * therefore available for use. + */ + .add("datasetType", DatasetType.DATASET_TYPE_REVIEW) + .add("datasetVersion", Json.createObjectBuilder() + .add("license", Json.createObjectBuilder() + .add("name", "CC0 1.0") + .add("uri", "http://creativecommons.org/publicdomain/zero/1.0")) + .add("metadataBlocks", Json.createObjectBuilder() + .add("citation", Json.createObjectBuilder() + .add("fields", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("typeName", "title") + .add("value", reviewTitle) + .add("typeClass", "primitive") + .add("multiple", false)) + .add(Json.createObjectBuilder() + .add("value", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("authorName", + Json.createObjectBuilder() + .add("value", authorName) + .add("typeClass", "primitive") + .add("multiple", false) + .add("typeName", + "authorName")))) + .add("typeClass", "compound") + .add("multiple", true) + .add("typeName", "author")) + .add(Json.createObjectBuilder() + .add("value", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("datasetContactEmail", + Json.createObjectBuilder() + .add("value", authorEmail) + .add("typeClass", "primitive") + .add("multiple", false) + .add("typeName", + "datasetContactEmail")))) + .add("typeClass", "compound") + .add("multiple", true) + .add("typeName", "datasetContact")) + .add(Json.createObjectBuilder() + .add("value", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("dsDescriptionValue", + Json.createObjectBuilder() + .add("value", + "This is a review of a dataset.") + .add("typeClass", "primitive") + .add("multiple", false) + .add("typeName", + "dsDescriptionValue")))) + .add("typeClass", "compound") + .add("multiple", true) + .add("typeName", "dsDescription")) + .add(Json.createObjectBuilder() + .add("value", Json.createArrayBuilder() + .add("Medicine, Health and Life Sciences")) + .add("typeClass", "controlledVocabulary") + .add("multiple", true) + .add("typeName", "subject")) + .add(Json.createObjectBuilder() + .add("value", Json.createObjectBuilder() + .add("itemReviewedUrl", + Json.createObjectBuilder() + .add("value", itemReviewedUrl) + .add("typeClass", "primitive") + .add("multiple", false) + .add("typeName", "itemReviewedUrl")) + .add("itemReviewedType", + Json.createObjectBuilder() + .add("value", "Dataset") + .add("typeClass", + "controlledVocabulary") + .add("multiple", false) + .add("typeName", "itemReviewedType")) + .add("itemReviewedCitation", + Json.createObjectBuilder() + .add("value", itemReviewedCitation) + .add("typeClass", "primitive") + .add("multiple", false) + .add("typeName", + "itemReviewedCitation"))) + .add("typeClass", "compound") + .add("multiple", false) + .add("typeName", "itemReviewed")))))); + + Response createReview = UtilIT.createDataset(collectionAliasReviews, jsonForCreatingReview, apiTokenReviewer); + createReview.prettyPrint(); + createReview.then().assertThat().statusCode(CREATED.getStatusCode()); + Integer reviewId = UtilIT.getDatasetIdFromResponse(createReview); + String reviewPid = JsonPath.from(createReview.getBody().asString()).getString("data.persistentId"); + + Response publishCollectionReviews = UtilIT.publishDataverseViaNativeApi(collectionAliasReviews, + apiTokenReviewer); + publishCollectionReviews.then().assertThat() + .statusCode(OK.getStatusCode()); + + Response publishReview = UtilIT.publishDatasetViaNativeApi(reviewId, "major", apiTokenReviewer); + publishReview.then().assertThat() + .statusCode(OK.getStatusCode()); + + } + } From 1e3fefb2672e2bffeb36cd352ff9c30fd7f83245 Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Mon, 13 Apr 2026 16:10:30 -0400 Subject: [PATCH 2/8] add command and API to get reviews #12313 --- .../harvard/iq/dataverse/api/Datasets.java | 18 +++- .../impl/GetDatasetReviewsCommand.java | 94 +++++++++++++++++++ .../harvard/iq/dataverse/api/ReviewsIT.java | 14 +++ .../edu/harvard/iq/dataverse/api/UtilIT.java | 20 +++- 4 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetReviewsCommand.java diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java index 136b6dbb69b..9a4cc06b0cf 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java @@ -6261,7 +6261,23 @@ public Response updateLicense(@Context ContainerRequestContext crc, } }, getRequestUser(crc)); } - + + @GET + @AuthRequired + @Path("{identifier}/reviews") + @Produces(MediaType.APPLICATION_JSON) + public Response getReviews(@Context ContainerRequestContext crc, @PathParam("identifier") String id) { + return response(req -> { + Dataset dataset = findDatasetOrDie(id); + try { + JsonObjectBuilder job = execCommand(new GetDatasetReviewsCommand(req, dataset)); + return ok(job); + } catch (Exception ex) { + return error(BAD_REQUEST, ex.getMessage()); + } + }, getRequestUser(crc)); + } + /** * Storage quotas and use. Note that these methods replicate the * collection-level equivalents 1:1. Both the quotas and the system for diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetReviewsCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetReviewsCommand.java new file mode 100644 index 00000000000..7e1a9e51fa6 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetReviewsCommand.java @@ -0,0 +1,94 @@ +package edu.harvard.iq.dataverse.engine.command.impl; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import edu.harvard.iq.dataverse.Dataset; +import edu.harvard.iq.dataverse.Dataverse; +import edu.harvard.iq.dataverse.authorization.Permission; +import edu.harvard.iq.dataverse.engine.command.AbstractCommand; +import edu.harvard.iq.dataverse.engine.command.CommandContext; +import edu.harvard.iq.dataverse.engine.command.DataverseRequest; +import edu.harvard.iq.dataverse.engine.command.exception.CommandException; +import edu.harvard.iq.dataverse.search.SearchConstants; +import edu.harvard.iq.dataverse.search.SearchException; +import edu.harvard.iq.dataverse.search.SearchFields; +import edu.harvard.iq.dataverse.search.SolrQueryResponse; +import edu.harvard.iq.dataverse.search.SolrSearchResult; +import edu.harvard.iq.dataverse.search.SortBy; +import jakarta.json.Json; +import jakarta.json.JsonArrayBuilder; +import jakarta.json.JsonObject; +import jakarta.json.JsonObjectBuilder; + +// No annotations here since permissions are dynamically decided +public class GetDatasetReviewsCommand extends AbstractCommand { + + private final Dataset dataset; + + public GetDatasetReviewsCommand(DataverseRequest request, Dataset target) { + super(request, target); + dataset = target; + } + + @Override + public JsonObjectBuilder execute(CommandContext ctxt) throws CommandException { + JsonObjectBuilder reviews = Json.createObjectBuilder(); + List dataverses = new ArrayList<>(); + // Putting PID as URL in quotes to avoid hits we don't want + String query = "itemReviewedUrl:\"" + dataset.getGlobalId().asURL() + "\""; + List filterQueries = new ArrayList<>(); + // Limit to datasets (review datasets) + filterQueries.add(SearchFields.TYPE + ":" + SearchConstants.DATASETS); + String sortField = SearchFields.ID; + String sortOrder = SortBy.ASCENDING; + int paginationStart = 0; + boolean dataRelatedToMe = false; + // We only expect a handful of reviews. This should be plenty. + int numResultsPerPage = 100; + try { + SolrQueryResponse solrQueryResponse = ctxt.search().getDefaultSearchService().search(getRequest(), + dataverses, query, filterQueries, sortField, sortOrder, paginationStart, dataRelatedToMe, + numResultsPerPage); + JsonArrayBuilder itemsArrayBuilder = Json.createArrayBuilder(); + List solrSearchResults = solrQueryResponse.getSolrSearchResults(); + for (SolrSearchResult solrSearchResult : solrSearchResults) { + // Construct a JSON object intentionally rather than simply returning the + // solrSearchResult. This "get reviews" command may be powered by a + // database query in the future and we'll want to preserve the contract + // we're establishing here if we make the switch from Solr. + JsonObjectBuilder searchResultBuilder = solrSearchResult.json(false, true, false); + JsonObject searchResultObject = searchResultBuilder.build(); + String title = searchResultObject.getString("name"); + String citation = searchResultObject.getString("citation"); + String citationHtml = searchResultObject.getString("citationHtml"); + String pid = searchResultObject.getString("global_id"); + String pidUrl = searchResultObject.getString("url"); + long id = searchResultObject.getJsonNumber("entity_id").longValue(); + JsonObjectBuilder review = Json.createObjectBuilder() + .add("title", title) + .add("persistentId", pid) + .add("persistentIdUrl", pidUrl) + .add("id", id) + .add("citation", citation) + .add("citationHtml", citationHtml); + itemsArrayBuilder.add(review); + } + reviews.add("reviews", itemsArrayBuilder); + } catch (SearchException ex) { + throw new CommandException(ex.getMessage(), this); + } + return reviews; + } + + @Override + public Map> getRequiredPermissions() { + return Collections.singletonMap("", + dataset.isReleased() ? Collections.emptySet() + : Collections.singleton(Permission.ViewUnpublishedDataset)); + } + +} diff --git a/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java index db2f53a7137..d235c106b53 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java @@ -553,6 +553,20 @@ public void testLocalReviews() { publishReview.then().assertThat() .statusCode(OK.getStatusCode()); + // Putting PID as URL in quotes to avoid hits we don't want + Response searchForReviews = UtilIT.search("itemReviewedUrl:\"" + itemReviewedUrl + "\"", null); + searchForReviews.prettyPrint(); + searchForReviews.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.items[0].name", is(reviewTitle)); + + Response getReviews = UtilIT.getReviews(datasetPid); + getReviews.prettyPrint(); + getReviews.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.reviews[0].title", is(reviewTitle)) + .body("data.reviews[0].persistentId", is(reviewPid)) + .body("data.reviews[0].id", is(reviewId)); } } diff --git a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java index 561a9bc3b93..8158d428d74 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/UtilIT.java @@ -5417,7 +5417,25 @@ public static Response getTemplate(String templateId, String apiToken) { .header(API_TOKEN_HTTP_HEADER, apiToken) .get("/api/dataverses/" + templateId + "/template"); } - + + static Response getReviews(String datasetIdOrPersistentId) { + return getReviews(datasetIdOrPersistentId, null); + } + + static Response getReviews(String datasetIdOrPersistentId, String apiToken) { + String idInPath = datasetIdOrPersistentId; // Assume it's a number. + String optionalQueryParam = ""; // If idOrPersistentId is a number we'll just put it in the path. + if (!NumberUtils.isCreatable(datasetIdOrPersistentId)) { + idInPath = ":persistentId"; + optionalQueryParam = "?persistentId=" + datasetIdOrPersistentId; + } + RequestSpecification responseSpec = given(); + if (apiToken != null) { + responseSpec.header(API_TOKEN_HTTP_HEADER, apiToken); + } + return responseSpec.get("/api/datasets/" + idInPath + "/reviews" + optionalQueryParam); + } + /** * Gets the tool URL for a dataset with optional parameters * @param datasetId The ID of the dataset From 658ce90834f4006500aa55f06a176b853e80d7a1 Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Mon, 13 Apr 2026 16:35:19 -0400 Subject: [PATCH 3/8] add tests for unpublished local reviews #12313 --- .../harvard/iq/dataverse/api/ReviewsIT.java | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java index d235c106b53..a084cf993f8 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java @@ -20,6 +20,7 @@ import java.nio.file.Files; import java.nio.file.Paths; +import org.hamcrest.Matchers; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -544,6 +545,28 @@ public void testLocalReviews() { Integer reviewId = UtilIT.getDatasetIdFromResponse(createReview); String reviewPid = JsonPath.from(createReview.getBody().asString()).getString("data.persistentId"); + UtilIT.sleepForReindex(String.valueOf(datasetId), apiTokenReviewer, 5); + + Response getReviewsPrePubReviewer = UtilIT.getReviews(datasetPid, apiTokenReviewer); + getReviewsPrePubReviewer.prettyPrint(); + getReviewsPrePubReviewer.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.reviews[0].title", is(reviewTitle)) + .body("data.reviews[0].persistentId", is(reviewPid)) + .body("data.reviews[0].id", is(reviewId)); + + Response getReviewsPrePubGuest = UtilIT.getReviews(datasetPid); + getReviewsPrePubGuest.prettyPrint(); + getReviewsPrePubGuest.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.reviews", Matchers.empty()); + + Response getReviewsPrePubDatasetAuthor = UtilIT.getReviews(datasetPid, apiTokenDatasetAuthor); + getReviewsPrePubDatasetAuthor.prettyPrint(); + getReviewsPrePubDatasetAuthor.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("data.reviews", Matchers.empty()); + Response publishCollectionReviews = UtilIT.publishDataverseViaNativeApi(collectionAliasReviews, apiTokenReviewer); publishCollectionReviews.then().assertThat() From e06e1de683967aa99a7fd02973afd08d9fcabbe9 Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Mon, 13 Apr 2026 16:49:08 -0400 Subject: [PATCH 4/8] add docs for "list reviews" API endpoint #12313 --- doc/sphinx-guides/source/api/native-api.rst | 23 +++++++++++++++++++ .../source/user/dataset-management.rst | 2 ++ 2 files changed, 25 insertions(+) diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index 1a1604886c6..a47e85194c9 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -4835,6 +4835,29 @@ The fully expanded example above (without environment variables) looks like this curl -H "X-Dataverse-key:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" -X PUT "https://demo.dataverse.org/api/datasets/3/license" -H "Content-type:application/json" --upload-file license.json +.. _api-list-reviews: + +List Reviews +~~~~~~~~~~~~ + +Datasets can have reviews. Specifically, if a :ref:`review dataset ` points at (using the ``itemReviewedUrl`` field) the URL form of a persistent ID of a dataset (e.g. https://doi.org/10.5072/FK2/ABCDEF) that is in the same Dataverse installation as the review dataset, the review dataset will be included in the list of reviews for the dataset. It is considered a local review. + +An API token is optional if the review dataset has been published. + +.. code-block:: bash + + export API_TOKEN=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + export SERVER_URL=https://demo.dataverse.org + export PERSISTENT_IDENTIFIER=doi:10.5072/FK2/ABCDEF + + curl -H "X-Dataverse-key:$API_TOKEN" "$SERVER_URL/api/datasets/:persistentId/reviews?persistentId=$PERSISTENT_IDENTIFIER" + +The fully expanded example above (without environment variables) looks like this: + +.. code-block:: bash + + curl -H "X-Dataverse-key:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" "https://demo.dataverse.org/api/datasets/:persistentId/reviews?persistentId=doi:10.5072/FK2/ABCDEF" + Files ----- diff --git a/doc/sphinx-guides/source/user/dataset-management.rst b/doc/sphinx-guides/source/user/dataset-management.rst index 2f7fb2c0d6e..671dd326e86 100755 --- a/doc/sphinx-guides/source/user/dataset-management.rst +++ b/doc/sphinx-guides/source/user/dataset-management.rst @@ -936,6 +936,8 @@ Review Datasets can only be created via API. You have the following options: When creating a review dataset you will likely need to fill in required fields like ``itemReviewedUrl`` as well as fields from one or more "rubric" metadata blocks, as described above under :ref:`review-datasets-overview`. +If you point ``itemReviewedUrl`` at the URL form of a dataset (e.g. https://doi.org/10.5072/FK2/ABCDEF) that is in the same Dataverse installation as the review dataset, the review dataset is considered a local review and can be listed using the :ref:`api-list-reviews` API endpoint. + .. _dataset-types-datacite: Dataset Types and DataCite From 36c7b86a8bb63dead6aa3742a686bd62698cdce3 Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Mon, 13 Apr 2026 16:52:51 -0400 Subject: [PATCH 5/8] add release note #12313 --- doc/release-notes/12313-local-reviews.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 doc/release-notes/12313-local-reviews.md diff --git a/doc/release-notes/12313-local-reviews.md b/doc/release-notes/12313-local-reviews.md new file mode 100644 index 00000000000..4f28dba0924 --- /dev/null +++ b/doc/release-notes/12313-local-reviews.md @@ -0,0 +1,5 @@ +### Local Reviews + +Datasets can have reviews. Specifically, if a review dataset points at (using the itemReviewedUrl field) the URL form of a persistent ID of a dataset (e.g. https://doi.org/10.5072/FK2/ABCDEF) that is in the same Dataverse installation as the review dataset, the review dataset will be included in the list of reviews for the dataset. It is considered a local review. + +See [the guides](https://dataverse-guide--12327.org.readthedocs.build/en/12327/api/native-api.html#list-reviews), #12313 and #12327. From b1bcb30b7e03fb4ed1c9eec927acb7c1340cfc06 Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Tue, 2 Jun 2026 17:58:52 -0400 Subject: [PATCH 6/8] croissant with local reviews, more review fields, test rubric #12314 --- doc/release-notes/12313-local-reviews.md | 12 ++- .../source/_static/api/list-reviews.json | 52 ++++++++++ .../source/admin/dataverses-datasets.rst | 7 +- .../source/admin/discoverability.rst | 2 + doc/sphinx-guides/source/api/native-api.rst | 4 +- .../source/installation/config.rst | 7 ++ .../source/user/dataset-management.rst | 2 +- ...ubric_trusteddatadimensionsintensities.tsv | 28 ++++++ .../edu/harvard/iq/dataverse/DatasetPage.java | 17 ++++ .../harvard/iq/dataverse/api/Datasets.java | 14 +++ .../impl/GetDatasetReviewsCommand.java | 44 ++++++++- .../export/croissant/CroissantExportUtil.java | 95 +++++++++++++++++++ .../iq/dataverse/settings/FeatureFlags.java | 6 ++ ...rusteddatadimensionsintensities.properties | 41 ++++++++ .../harvard/iq/dataverse/api/ReviewsIT.java | 84 +++++++++++++++- .../croissant/CroissantExportUtilTest.java | 95 +++++++++++++++++++ 16 files changed, 502 insertions(+), 8 deletions(-) create mode 100644 doc/sphinx-guides/source/_static/api/list-reviews.json create mode 100644 scripts/api/data/metadatablocks/rubric_trusteddatadimensionsintensities.tsv create mode 100644 src/main/java/propertyFiles/rubric_trusteddatadimensionsintensities.properties create mode 100644 src/test/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtilTest.java diff --git a/doc/release-notes/12313-local-reviews.md b/doc/release-notes/12313-local-reviews.md index 4f28dba0924..0b17fbccbe4 100644 --- a/doc/release-notes/12313-local-reviews.md +++ b/doc/release-notes/12313-local-reviews.md @@ -1,5 +1,13 @@ ### Local Reviews -Datasets can have reviews. Specifically, if a review dataset points at (using the itemReviewedUrl field) the URL form of a persistent ID of a dataset (e.g. https://doi.org/10.5072/FK2/ABCDEF) that is in the same Dataverse installation as the review dataset, the review dataset will be included in the list of reviews for the dataset. It is considered a local review. +Datasets can have local reviews, listable via API. A local review is a review dataset ("review" for short) that points at the URL form of a persistent ID of a dataset (e.g. itemReviewedUrl:https://doi.org/10.5072/FK2/ABCDEF) that is in the same Dataverse installation. Local reviews of a dataset can be listed via API (and we plan to build a UI for it some day). -See [the guides](https://dataverse-guide--12327.org.readthedocs.build/en/12327/api/native-api.html#list-reviews), #12313 and #12327. +A new metadata block called "Trusted Data Dimensions and Intensities" has been added for testing. This is described in the setup instructions for review datasets. + +If you set `dataverse.feature.croissant-with-local-reviews` to true, local reviews will appear in the croissant and croissantSlim metadata export formats for any dataset that has local reviews. This feature is experiemental, which is why it is hidden behind a feature flag. + +See the guides for the new [list reviews](https://preview.guides.gdcc.io/en/develop/api/native-api.html#list-reviews) API endpoint, #12313, and #12314. + +## New Settings + +- dataverse.feature.croissant-with-local-reviews diff --git a/doc/sphinx-guides/source/_static/api/list-reviews.json b/doc/sphinx-guides/source/_static/api/list-reviews.json new file mode 100644 index 00000000000..c69b51aa793 --- /dev/null +++ b/doc/sphinx-guides/source/_static/api/list-reviews.json @@ -0,0 +1,52 @@ +{ + "status": "OK", + "data": { + "reviews": [ + { + "title": "Review of Pediatric Asthma", + "authors": [ + "Wazowski, Mike" + ], + "persistentId": "doi:10.5072/FK2/1WD6BX", + "persistentIdUrl": "https://doi.org/10.5072/FK2/1WD6BX", + "id": 13, + "citation": "Wazowski, Mike, 2026, \"Review of Pediatric Asthma\", https://doi.org/10.5072/FK2/1WD6BX, Root, DRAFT VERSION", + "citationHtml": "Wazowski, Mike, 2026, \"Review of Pediatric Asthma\", https://doi.org/10.5072/FK2/1WD6BX, Root, DRAFT VERSION", + "datePublished": "", + "description": "This is a review of a dataset.", + "rubricMetadataBlocks": [ + { + "name": "rubric_trusteddatadimensionsintensities", + "displayName": "Trusted Data Dimensions and Intensities", + "fields": [ + { + "typeName": "licensingAndLegalClarity", + "value": "High" + }, + { + "typeName": "authorAndProvenance", + "value": "Medium" + }, + { + "typeName": "biasEquityAndRepresentativeness", + "value": "Low" + }, + { + "typeName": "integrityAndUsability", + "value": "High" + }, + { + "typeName": "fitnessForScopeAndContextualRelevance", + "value": "Medium" + }, + { + "typeName": "transparencyOfMethodsAndDocumentation", + "value": "Low" + } + ] + } + ] + } + ] + } +} diff --git a/doc/sphinx-guides/source/admin/dataverses-datasets.rst b/doc/sphinx-guides/source/admin/dataverses-datasets.rst index 90bee375d9c..c5820422953 100644 --- a/doc/sphinx-guides/source/admin/dataverses-datasets.rst +++ b/doc/sphinx-guides/source/admin/dataverses-datasets.rst @@ -135,10 +135,15 @@ The Review metadata block gives you a few basic fields common to all reviews suc You probably will want to create your own metadata blocks specific to the resources you are reviewing, your own "rubric". See :doc:`metadatacustomization` for details on creating and enabling custom metadata blocks. -Instead of creating a new custom metadata block from scratch (if you simply want to evaluate the feature, for example), you can use the metadata blocks at https://github.com/IQSS/dataverse.harvard.edu +Instead of creating a new custom metadata block from scratch (if you simply want to evaluate the feature, for example), in a test environment, you can use the "Trusted Data Dimensions and Intensities" for testing. (A test environment is advised because metadata blocks cannot be deleted once they are loaded (https://github.com/IQSS/dataverse/issues/9628).) These are the files to download: + +- :download:`rubric_trusteddatadimensionsintensities.tsv <../../../../scripts/api/data/metadatablocks/rubric_trusteddatadimensionsintensities.tsv>` +- :download:`rubric_trusteddatadimensionsintensities.properties <../../../../src/main/java/propertyFiles/rubric_trusteddatadimensionsintensities.properties>` (optional) After loading the block, don't forget to update the Solr schema! +As in the example above, the metadata block must start with ``rubric_`` (the "metadataBlock name" in the tsv itself) to be included in the output of the :ref:`api-list-reviews` API endpoint. + Create a Review Dataset Type ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/doc/sphinx-guides/source/admin/discoverability.rst b/doc/sphinx-guides/source/admin/discoverability.rst index 3db42101e27..dd0c776726a 100644 --- a/doc/sphinx-guides/source/admin/discoverability.rst +++ b/doc/sphinx-guides/source/admin/discoverability.rst @@ -47,6 +47,8 @@ We include Croissant in the ```` because it's `recommended ``, which was the behavior in older versions of Dataverse, see :ref:`dataverse.legacy.schemaorg-in-html-head`. +See also the :ref:`dataverse.feature.croissant-with-local-reviews` feature flag. + .. _discovery-sign-posting: Signposting diff --git a/doc/sphinx-guides/source/api/native-api.rst b/doc/sphinx-guides/source/api/native-api.rst index a47e85194c9..78231886366 100644 --- a/doc/sphinx-guides/source/api/native-api.rst +++ b/doc/sphinx-guides/source/api/native-api.rst @@ -4840,7 +4840,7 @@ The fully expanded example above (without environment variables) looks like this List Reviews ~~~~~~~~~~~~ -Datasets can have reviews. Specifically, if a :ref:`review dataset ` points at (using the ``itemReviewedUrl`` field) the URL form of a persistent ID of a dataset (e.g. https://doi.org/10.5072/FK2/ABCDEF) that is in the same Dataverse installation as the review dataset, the review dataset will be included in the list of reviews for the dataset. It is considered a local review. +Datasets can have reviews. Specifically, if a :ref:`review dataset ` points at (using the ``itemReviewedUrl`` field) the URL form of a persistent ID of a dataset (e.g. https://doi.org/10.5072/FK2/ABCDEF) that is in the same Dataverse installation as the review dataset, the review dataset will be included in the list of reviews for the dataset. It is considered a local review. If additional "rubric" metadata blocks are enabled (see :ref:`review-datasets-setup`) the "metadataBlock name" must start with ``rubric_`` for the fields to be included in the output of this API endpoint. An API token is optional if the review dataset has been published. @@ -4858,6 +4858,8 @@ The fully expanded example above (without environment variables) looks like this curl -H "X-Dataverse-key:xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" "https://demo.dataverse.org/api/datasets/:persistentId/reviews?persistentId=doi:10.5072/FK2/ABCDEF" +:download:`list-reviews.json <../_static/api/list-reviews.json>` contains sample output of how the API response might look. + Files ----- diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index f2a6fdfa324..f97c5e09d29 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -4127,6 +4127,13 @@ dataverse.feature.require-embargo-reason Require an embargo reason when a user creates an embargo on one or more files. See :ref:`embargoes`. +.. _dataverse.feature.croissant-with-local-reviews: + +dataverse.feature.croissant-with-local-reviews +++++++++++++++++++++++++++++++++++++++++++++++ + +Have the croissant and croissantSlim metadata export formats include an extra "reviews" array if local reviews exist. See :ref:`croissant-head`, :ref:`review-datasets-user`, :ref:`creating-a-review-dataset`, and :ref:`api-list-reviews`. + .. _:ApplicationServerSettings: Application Server Settings diff --git a/doc/sphinx-guides/source/user/dataset-management.rst b/doc/sphinx-guides/source/user/dataset-management.rst index 671dd326e86..5afa21a61e4 100755 --- a/doc/sphinx-guides/source/user/dataset-management.rst +++ b/doc/sphinx-guides/source/user/dataset-management.rst @@ -936,7 +936,7 @@ Review Datasets can only be created via API. You have the following options: When creating a review dataset you will likely need to fill in required fields like ``itemReviewedUrl`` as well as fields from one or more "rubric" metadata blocks, as described above under :ref:`review-datasets-overview`. -If you point ``itemReviewedUrl`` at the URL form of a dataset (e.g. https://doi.org/10.5072/FK2/ABCDEF) that is in the same Dataverse installation as the review dataset, the review dataset is considered a local review and can be listed using the :ref:`api-list-reviews` API endpoint. +If you point ``itemReviewedUrl`` at the URL form of a dataset (e.g. https://doi.org/10.5072/FK2/ABCDEF) that is in the same Dataverse installation as the review dataset, the review dataset is considered a local review and can be listed using the :ref:`api-list-reviews` API endpoint. These reviews appear in the Croissant metadata export if you enable the :ref:`dataverse.feature.croissant-with-local-reviews` feature flag. .. _dataset-types-datacite: diff --git a/scripts/api/data/metadatablocks/rubric_trusteddatadimensionsintensities.tsv b/scripts/api/data/metadatablocks/rubric_trusteddatadimensionsintensities.tsv new file mode 100644 index 00000000000..80b31c6ffcc --- /dev/null +++ b/scripts/api/data/metadatablocks/rubric_trusteddatadimensionsintensities.tsv @@ -0,0 +1,28 @@ +#metadataBlock name dataverseAlias displayName blockURI + rubric_trusteddatadimensionsintensities Trusted Data Dimensions and Intensities +#datasetField name title description watermark fieldType displayOrder displayFormat advancedSearchField allowControlledVocabulary allowmultiples facetable displayoncreate required parent metadatablock_id termURI + authorAndProvenance Author and Provenance The level of trust in the data creators and in other provenance information text 1 TRUE TRUE FALSE TRUE FALSE FALSE rubric_trusteddatadimensionsintensities + integrityAndUsability Integrity and Usability The level of trust in the accuracy, completeness, and ease of use of the data text 2 TRUE TRUE FALSE TRUE FALSE FALSE rubric_trusteddatadimensionsintensities + fitnessForScopeAndContextualRelevance Fitness for Scope and Contextual Relevance The level of trust in the suitability of the data for specific contexts, questions, or policy applications text 3 TRUE TRUE FALSE TRUE FALSE FALSE rubric_trusteddatadimensionsintensities + licensingAndLegalClarity Licensing and Legal Clarity The level of trust in the explicitness of the data’s usage rights and their compliance with relevant laws and regulations text 4 TRUE TRUE FALSE TRUE FALSE FALSE rubric_trusteddatadimensionsintensities + transparencyOfMethodsAndDocumentation Transparency of Methods and Documentation The level of trust in the clarity of the descriptions of data collection and processing methods text 5 TRUE TRUE FALSE TRUE FALSE FALSE rubric_trusteddatadimensionsintensities + biasEquityAndRepresentativeness Bias, Equity, and Representativeness The level of trust in the inclusivity and fairness of the coverage of the data text 6 TRUE TRUE FALSE TRUE FALSE FALSE rubric_trusteddatadimensionsintensities +#controlledVocabulary DatasetField Value identifier displayOrder + authorAndProvenance Low 0 + authorAndProvenance Medium 1 + authorAndProvenance High 2 + integrityAndUsability Low 0 + integrityAndUsability Medium 1 + integrityAndUsability High 2 + fitnessForScopeAndContextualRelevance Low 0 + fitnessForScopeAndContextualRelevance Medium 1 + fitnessForScopeAndContextualRelevance High 2 + licensingAndLegalClarity Low 0 + licensingAndLegalClarity Medium 1 + licensingAndLegalClarity High 2 + transparencyOfMethodsAndDocumentation Low 0 + transparencyOfMethodsAndDocumentation Medium 1 + transparencyOfMethodsAndDocumentation High 2 + biasEquityAndRepresentativeness Low 0 + biasEquityAndRepresentativeness Medium 1 + biasEquityAndRepresentativeness High 2 diff --git a/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java b/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java index 20c16c5d9b4..b60633fca5f 100644 --- a/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java +++ b/src/main/java/edu/harvard/iq/dataverse/DatasetPage.java @@ -32,6 +32,7 @@ import edu.harvard.iq.dataverse.engine.command.impl.DeleteDatasetVersionCommand; import edu.harvard.iq.dataverse.engine.command.impl.DeletePrivateUrlCommand; import edu.harvard.iq.dataverse.engine.command.impl.DestroyDatasetCommand; +import edu.harvard.iq.dataverse.engine.command.impl.GetDatasetReviewsCommand; import edu.harvard.iq.dataverse.engine.command.impl.GetPrivateUrlCommand; import edu.harvard.iq.dataverse.engine.command.impl.LinkDatasetCommand; import edu.harvard.iq.dataverse.engine.command.impl.PublishDatasetCommand; @@ -104,6 +105,7 @@ import jakarta.inject.Inject; import jakarta.inject.Named; import jakarta.json.Json; +import jakarta.json.JsonObject; import jakarta.json.JsonObjectBuilder; import jakarta.persistence.OptimisticLockException; @@ -133,6 +135,7 @@ import edu.harvard.iq.dataverse.externaltools.ExternalToolServiceBean; import edu.harvard.iq.dataverse.globus.GlobusServiceBean; import edu.harvard.iq.dataverse.export.SchemaDotOrgExporter; +import edu.harvard.iq.dataverse.export.croissant.CroissantExportUtil; import edu.harvard.iq.dataverse.externaltools.ExternalToolHandler; import edu.harvard.iq.dataverse.license.License; import edu.harvard.iq.dataverse.makedatacount.MakeDataCountLoggingServiceBean; @@ -6110,6 +6113,20 @@ public String getCroissant() { final String CROISSANT_SCHEMA_NAME = "croissantSlim"; ExportService instance = ExportService.getInstance(); String croissant = instance.getLatestPublishedAsString(dataset, CROISSANT_SCHEMA_NAME); + if (FeatureFlags.CROISSANT_WITH_LOCAL_REVIEWS.enabled()) { + // Rewrite the export on the fly and insert local reviews until we have a solution for https://github.com/gdcc/dataverse-spi/issues/5 + JsonObjectBuilder reviewsJsonObj = null; + try { + reviewsJsonObj = commandEngine.submit(new GetDatasetReviewsCommand(dvRequestService.getDataverseRequest(), dataset)); + JsonObjectBuilder reviews = CroissantExportUtil.getReviews(reviewsJsonObj); + JsonObject croissantJson = JsonUtil.getJsonObject(croissant); + String updatedContent = Json.createObjectBuilder(croissantJson) + .add("reviews", reviews.build().getJsonArray("reviews")).build().toString(); + return updatedContent; + } catch (CommandException e) { + logger.fine("Couldn't get reviews"); + } + } if (croissant != null && !croissant.isEmpty()) { logger.fine("Returning cached CROISSANT."); return croissant; diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java index 9a4cc06b0cf..5bedd978f39 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Datasets.java @@ -36,6 +36,7 @@ import edu.harvard.iq.dataverse.engine.command.exception.UnforcedCommandException; import edu.harvard.iq.dataverse.engine.command.impl.*; import edu.harvard.iq.dataverse.export.ExportService; +import edu.harvard.iq.dataverse.export.croissant.CroissantExportUtil; import edu.harvard.iq.dataverse.externaltools.ExternalTool; import edu.harvard.iq.dataverse.externaltools.ExternalToolHandler; import edu.harvard.iq.dataverse.globus.GlobusServiceBean; @@ -87,9 +88,11 @@ import org.glassfish.jersey.media.multipart.FormDataParam; import software.amazon.awssdk.services.s3.model.CompletedPart; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.net.URI; +import java.nio.charset.StandardCharsets; import java.sql.Timestamp; import java.text.MessageFormat; import java.text.SimpleDateFormat; @@ -275,6 +278,17 @@ public Response exportDataset(@Context ContainerRequestContext crc, @QueryParam( ExportService instance = ExportService.getInstance(); InputStream is = instance.getExport(datasetVersion, exporter); + if (FeatureFlags.CROISSANT_WITH_LOCAL_REVIEWS.enabled() + && (exporter.equals("croissant") || exporter.equals("croissantSlim"))) { + // Rewrite the export on the fly and insert local reviews until we have a solution for https://github.com/gdcc/dataverse-spi/issues/5 + JsonObjectBuilder reviews = CroissantExportUtil + .getReviews(commandEngine.submit(new GetDatasetReviewsCommand(req, dataset))); + String content = new String(is.readAllBytes(), StandardCharsets.UTF_8); + JsonObject croissantJson = JsonUtil.getJsonObject(content); + String updatedContent = Json.createObjectBuilder(croissantJson) + .add("reviews", reviews.build().getJsonArray("reviews")).build().toString(); + is = new ByteArrayInputStream(updatedContent.getBytes(StandardCharsets.UTF_8)); + } String mediaType = instance.getMediaType(exporter); diff --git a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetReviewsCommand.java b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetReviewsCommand.java index 7e1a9e51fa6..582b890777e 100644 --- a/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetReviewsCommand.java +++ b/src/main/java/edu/harvard/iq/dataverse/engine/command/impl/GetDatasetReviewsCommand.java @@ -8,6 +8,7 @@ import edu.harvard.iq.dataverse.Dataset; import edu.harvard.iq.dataverse.Dataverse; +import edu.harvard.iq.dataverse.MetadataBlock; import edu.harvard.iq.dataverse.authorization.Permission; import edu.harvard.iq.dataverse.engine.command.AbstractCommand; import edu.harvard.iq.dataverse.engine.command.CommandContext; @@ -20,9 +21,11 @@ import edu.harvard.iq.dataverse.search.SolrSearchResult; import edu.harvard.iq.dataverse.search.SortBy; import jakarta.json.Json; +import jakarta.json.JsonArray; import jakarta.json.JsonArrayBuilder; import jakarta.json.JsonObject; import jakarta.json.JsonObjectBuilder; +import jakarta.json.JsonValue; // No annotations here since permissions are dynamically decided public class GetDatasetReviewsCommand extends AbstractCommand { @@ -60,21 +63,58 @@ public JsonObjectBuilder execute(CommandContext ctxt) throws CommandException { // solrSearchResult. This "get reviews" command may be powered by a // database query in the future and we'll want to preserve the contract // we're establishing here if we make the switch from Solr. - JsonObjectBuilder searchResultBuilder = solrSearchResult.json(false, true, false); + List metadataFields = new ArrayList<>(); + // Retrieve only the blocks that are rubrics, that have fields for reviews + for (MetadataBlock mdb : ctxt.metadataBlocks().listMetadataBlocks()) { + String name = mdb.getName(); + if (name.startsWith("rubric_")) { + metadataFields.add(name + ":*"); + } + } + JsonObjectBuilder searchResultBuilder = solrSearchResult.json(false, true, false, metadataFields); JsonObject searchResultObject = searchResultBuilder.build(); String title = searchResultObject.getString("name"); + JsonArray authors = searchResultObject.getJsonArray("authors"); String citation = searchResultObject.getString("citation"); String citationHtml = searchResultObject.getString("citationHtml"); String pid = searchResultObject.getString("global_id"); String pidUrl = searchResultObject.getString("url"); long id = searchResultObject.getJsonNumber("entity_id").longValue(); + // Drafts don't have a published timestamp + String datePublished = searchResultObject.getString("published_at", ""); + String description = searchResultObject.getString("description"); + JsonObject rubricMetadataBlocksFromSolr = searchResultObject.getJsonObject("metadataBlocks"); + JsonArrayBuilder rubricMetadataBlocks = Json.createArrayBuilder(); + for (String key : rubricMetadataBlocksFromSolr.keySet()) { + String displayName = rubricMetadataBlocksFromSolr.getJsonObject(key).getString("displayName"); + JsonArray fieldsFromJson = rubricMetadataBlocksFromSolr.getJsonObject(key).getJsonArray("fields"); + JsonObjectBuilder block = Json.createObjectBuilder(); + block.add("name", key); + block.add("displayName", displayName); + JsonArrayBuilder fieldAccumulator = Json.createArrayBuilder(); + for (JsonValue fieldJsonValue : fieldsFromJson) { + JsonObject fieldObject = fieldJsonValue.asJsonObject(); + String typeName = fieldObject.getString("typeName"); + String value = fieldObject.getString("value"); + JsonObjectBuilder fieldToAdd = Json.createObjectBuilder(); + fieldToAdd.add("typeName", typeName); + fieldToAdd.add("value", value); + fieldAccumulator.add(fieldToAdd); + } + block.add("fields", fieldAccumulator); + rubricMetadataBlocks.add(block); + } JsonObjectBuilder review = Json.createObjectBuilder() .add("title", title) + .add("authors", authors) .add("persistentId", pid) .add("persistentIdUrl", pidUrl) .add("id", id) .add("citation", citation) - .add("citationHtml", citationHtml); + .add("citationHtml", citationHtml) + .add("datePublished", datePublished) + .add("description", description) + .add("rubricMetadataBlocks", rubricMetadataBlocks); itemsArrayBuilder.add(review); } reviews.add("reviews", itemsArrayBuilder); diff --git a/src/main/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtil.java b/src/main/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtil.java index 694f077ab1f..06b249552d5 100644 --- a/src/main/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtil.java +++ b/src/main/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtil.java @@ -16,6 +16,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; + +import org.apache.commons.lang3.StringUtils; import org.apache.commons.text.StringEscapeUtils; // Validate with src/test/resources/croissant/validate.sh @@ -563,4 +565,97 @@ private static String getNumericType(String variableIntervalType) { default -> "sc:Text"; }; } + + // This "reviews" object is modeled off slide 10 of + // https://docs.google.com/presentation/d/1dQinlXazxq3XLtzUNpG2-l0MAXQQArQU9PQ20uhRYVU/edit?slide=id.g3dfc9e1fbe0_0_127#slide=id.g3dfc9e1fbe0_0_127 + // which looks like this: + // "reviews": [{ + // "@context": "https://schema.org/", + // "@type": "CriticReview", + // "itemReviewed": { + // "@type": "Dataset", + // "name": "Dataset" + // }, + // "author": { + // "@type": "Organization", + // "name": "Association of Data Reusers" + // }, + // "positiveNotes": + // { + // "@type": "ItemList", + // "itemListElement": [ + // { + // "@type": "StructuredValue", + // "position": 1, + // "name": "The dataset is well-documented and easy to understand.", + // "value":{ + // "@type": "QuantitativeValue", + // "value": 10, + // "minValue": 0, + // "maxValue": 10 + // } + // }, + // ] + // }] + public static JsonObjectBuilder getReviews(JsonObjectBuilder reviewsIn) { + JsonObjectBuilder reviewsOut = Json.createObjectBuilder(); + JsonArrayBuilder jab = Json.createArrayBuilder(); + JsonArray reviews = reviewsIn.build().getJsonArray("reviews"); + + for (JsonValue jsonValue : reviews) { + JsonObject jsonObject = (JsonObject) jsonValue; + String title = jsonObject.getString("title"); + JsonArray authors = jsonObject.getJsonArray("authors"); + JsonArrayBuilder creators = Json.createArrayBuilder(); + for (JsonValue author : authors) { + JsonObjectBuilder job = Json.createObjectBuilder(); + // TODO add @type for "Person" or "Organization" + job.add("name", author); + creators.add(job); + } + String datePublished = jsonObject.getString("datePublished"); + JsonObjectBuilder positiveNotesObj = Json.createObjectBuilder(); + positiveNotesObj.add("@type", "ItemList"); + JsonArrayBuilder positiveNotesArray = Json.createArrayBuilder(); + JsonArray rubricMetadataBlocks = jsonObject.getJsonArray("rubricMetadataBlocks"); + for (JsonValue rmb : rubricMetadataBlocks) { + JsonObject rubricMetadataBlock = rmb.asJsonObject(); + JsonArray fields = rubricMetadataBlock.getJsonArray("fields"); + for (JsonValue fieldJsonValue : fields) { + JsonObject field = fieldJsonValue.asJsonObject(); + String typeName = field.getString("typeName"); + String value = field.getString("value"); + // Flatten all positive notes into a single array, regardless of which block + // they came from. + positiveNotesArray.add(Json.createObjectBuilder() + .add("@type", "StructuredValue") + .add("name", typeName) + .add("value", Json.createObjectBuilder() + .add("@type", StringUtils.isNumeric(value) ? "QuantitativeValue" : "QualitativeValue") + // We are aware that the value might be "Low", which is a bit strange for a positive note! We are constrained by what's allowed by https://schema.org/CriticReview + .add("value", value))); + } + } + positiveNotesObj.add("itemListElement", positiveNotesArray); + jab.add( + Json.createObjectBuilder() + .add("@context", "https://schema.org/") + .add("@type", "CriticReview") + .add( + "itemReviewed", + Json.createObjectBuilder() + // TODO don't hard code this to "Dataset" + .add("@type", "Dataset") + .add("name", title)) + // We use "creator" instead of "author" here for consistency with Croissant. + .add("creator", creators) + .add("positiveNotes", positiveNotesObj) + // TODO Instead of "" consider not emitting datePublished when we don't have a date to show. + .add("datePublished", datePublished != null && datePublished != "" ? datePublished : "") + .add("reviewBody", jsonObject.getString("description")) + .add("id", jsonObject.getJsonNumber("id"))); + } + reviewsOut.add("reviews", jab); + return reviewsOut; + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java index e1c7e69f7db..2e6be614e5e 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/FeatureFlags.java @@ -254,6 +254,12 @@ public enum FeatureFlags { * flag makes a reason required, both in the UI and API. */ REQUIRE_EMBARGO_REASON("require-embargo-reason"), + + /** + * The croissant and croissantSlim metadata export formats can include an extra + * "reviews" array if local reviews exist. + */ + CROISSANT_WITH_LOCAL_REVIEWS("croissant-with-local-reviews"), ; final String flag; diff --git a/src/main/java/propertyFiles/rubric_trusteddatadimensionsintensities.properties b/src/main/java/propertyFiles/rubric_trusteddatadimensionsintensities.properties new file mode 100644 index 00000000000..e3a9c66b2a9 --- /dev/null +++ b/src/main/java/propertyFiles/rubric_trusteddatadimensionsintensities.properties @@ -0,0 +1,41 @@ +metadatablock.name=trusteddatadimensionsintensities +metadatablock.displayName=Trusted Data Dimensions and Intensities +metadatablock.displayFacet= +datasetfieldtype.authorAndProvenance.title=Author and Provenance +datasetfieldtype.integrityAndUsability.title=Integrity and Usability +datasetfieldtype.fitnessForScopeAndContextualRelevance.title=Fitness for Scope and Contextual Relevance +datasetfieldtype.licensingAndLegalClarity.title=Licensing and Legal Clarity +datasetfieldtype.transparencyOfMethodsAndDocumentation.title=Transparency of Methods and Documentation +datasetfieldtype.biasEquityAndRepresentativeness.title=Bias, Equity, and Representativeness +datasetfieldtype.reviewTarget.description=The type of research object reviewed +datasetfieldtype.authorAndProvenance.description=The level of trust in the data creators and in other provenance information +datasetfieldtype.integrityAndUsability.description=The level of trust in the accuracy, completeness, and ease of use of the data +datasetfieldtype.fitnessForScopeAndContextualRelevance.description=The level of trust in the suitability of the data for specific contexts, questions, or policy applications +datasetfieldtype.licensingAndLegalClarity.description=The level of trust in the explicitness of the data’s usage rights and their compliance with relevant laws and regulations +datasetfieldtype.transparencyOfMethodsAndDocumentation.description=The level of trust in the clarity of the descriptions of data collection and processing methods +datasetfieldtype.biasEquityAndRepresentativeness.description=The level of trust in the inclusivity and fairness of the coverage of the data +datasetfieldtype.reviewTarget.watermark= +datasetfieldtype.authorAndProvenance.watermark= +datasetfieldtype.integrityAndUsability.watermark= +datasetfieldtype.fitnessForScopeAndContextualRelevance.watermark= +datasetfieldtype.licensingAndLegalClarity.watermark= +datasetfieldtype.transparencyOfMethodsAndDocumentation.watermark= +datasetfieldtype.biasEquityAndRepresentativeness.watermark= +controlledvocabulary.authorAndProvenance.low=Low +controlledvocabulary.authorAndProvenance.medium=Medium +controlledvocabulary.authorAndProvenance.high=High +controlledvocabulary.integrityAndUsability.low=Low +controlledvocabulary.integrityAndUsability.medium=Medium +controlledvocabulary.integrityAndUsability.high=High +controlledvocabulary.fitnessForScopeAndContextualRelevance.low=Low +controlledvocabulary.fitnessForScopeAndContextualRelevance.medium=Medium +controlledvocabulary.fitnessForScopeAndContextualRelevance.high=High +controlledvocabulary.licensingAndLegalClarity.low=Low +controlledvocabulary.licensingAndLegalClarity.medium=Medium +controlledvocabulary.licensingAndLegalClarity.high=High +controlledvocabulary.transparencyOfMethodsAndDocumentation.low=Low +controlledvocabulary.transparencyOfMethodsAndDocumentation.medium=Medium +controlledvocabulary.transparencyOfMethodsAndDocumentation.high=High +controlledvocabulary.biasEquityAndRepresentativeness.low=Low +controlledvocabulary.biasEquityAndRepresentativeness.medium=Medium +controlledvocabulary.biasEquityAndRepresentativeness.high=High \ No newline at end of file diff --git a/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java b/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java index a084cf993f8..5dd977b301f 100644 --- a/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java +++ b/src/test/java/edu/harvard/iq/dataverse/api/ReviewsIT.java @@ -14,6 +14,7 @@ import static jakarta.ws.rs.core.Response.Status.CREATED; import static jakarta.ws.rs.core.Response.Status.OK; import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.startsWith; import static org.junit.jupiter.api.Assertions.assertEquals; import java.io.IOException; @@ -67,6 +68,21 @@ public static void setUpClass() { response.then().assertThat().statusCode(OK.getStatusCode()); } + byte[] rubric1Tsv = null; + try { + rubric1Tsv = Files.readAllBytes(Paths.get("scripts/api/data/metadatablocks/rubric_trusteddatadimensionsintensities.tsv")); + } catch (IOException e) { + } + + // See warnings above. If you enable this, don't forget to update Solr. + boolean loadRubric1Tsv = false; + if (loadRubric1Tsv) { + Response response = UtilIT.loadMetadataBlock(apiTokenSuperuser, rubric1Tsv); + response.prettyPrint(); + assertEquals(200, response.getStatusCode()); + response.then().assertThat().statusCode(OK.getStatusCode()); + } + String datasetDescription = "A study, experiment, set of observations, or publication that is uploaded by a user. A dataset can comprise a single file or multiple files."; ensureDatasetTypeIsPresent(DatasetType.DATASET_TYPE_DATASET, "Dataset", datasetDescription, apiTokenSuperuser); @@ -404,6 +420,12 @@ public void testLocalReviews() { publishDataset.then().assertThat() .statusCode(OK.getStatusCode()); + Response exportDatasetNoReviews = UtilIT.exportDataset(datasetPid, "croissant"); + exportDatasetNoReviews.prettyPrint(); + exportDatasetNoReviews.then().assertThat() + .statusCode(OK.getStatusCode()); + // .body("reviews", Matchers.nullValue()); + Response createUserReviewer = UtilIT.createRandomUser(); createUserReviewer.prettyPrint(); createUserReviewer.then().assertThat() @@ -443,6 +465,10 @@ public void testLocalReviews() { .body("data.allowedDatasetTypes[0].description", is("A review of a dataset compiled by the expert community.")); + Response setMetadataBlocks = UtilIT.setMetadataBlocks(collectionAliasReviews, Json.createArrayBuilder().add("citation").add("rubric_trusteddatadimensionsintensities"), apiTokenReviewer); + setMetadataBlocks.prettyPrint(); + setMetadataBlocks.then().assertThat().statusCode(OK.getStatusCode()); + String itemReviewedTitle = datasetTitle; String itemReviewedUrl = datasetPersistentUrl; String itemReviewedCitation = datasetCitationHtml; @@ -537,7 +563,39 @@ public void testLocalReviews() { "itemReviewedCitation"))) .add("typeClass", "compound") .add("multiple", false) - .add("typeName", "itemReviewed")))))); + .add("typeName", "itemReviewed")))) + .add("rubric_trusteddatadimensionsintensities", Json.createObjectBuilder() + .add("fields", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("typeName", "authorAndProvenance") + .add("value", "Medium") + .add("typeClass", "controlledVocabulary") + .add("multiple", false)) + .add(Json.createObjectBuilder() + .add("typeName", "integrityAndUsability") + .add("value", "High") + .add("typeClass", "controlledVocabulary") + .add("multiple", false)) + .add(Json.createObjectBuilder() + .add("typeName", "fitnessForScopeAndContextualRelevance") + .add("value", "Medium") + .add("typeClass", "controlledVocabulary") + .add("multiple", false)) + .add(Json.createObjectBuilder() + .add("typeName", "licensingAndLegalClarity") + .add("value", "High") + .add("typeClass", "controlledVocabulary") + .add("multiple", false)) + .add(Json.createObjectBuilder() + .add("typeName", "transparencyOfMethodsAndDocumentation") + .add("value", "Low") + .add("typeClass", "controlledVocabulary") + .add("multiple", false)) + .add(Json.createObjectBuilder() + .add("typeName", "biasEquityAndRepresentativeness") + .add("value", "Low") + .add("typeClass", "controlledVocabulary") + .add("multiple", false)))))); Response createReview = UtilIT.createDataset(collectionAliasReviews, jsonForCreatingReview, apiTokenReviewer); createReview.prettyPrint(); @@ -553,6 +611,7 @@ public void testLocalReviews() { .statusCode(OK.getStatusCode()) .body("data.reviews[0].title", is(reviewTitle)) .body("data.reviews[0].persistentId", is(reviewPid)) + .body("data.reviews[0].datePublished", is("")) .body("data.reviews[0].id", is(reviewId)); Response getReviewsPrePubGuest = UtilIT.getReviews(datasetPid); @@ -577,12 +636,14 @@ public void testLocalReviews() { .statusCode(OK.getStatusCode()); // Putting PID as URL in quotes to avoid hits we don't want + System.out.println("searchForReviews"); Response searchForReviews = UtilIT.search("itemReviewedUrl:\"" + itemReviewedUrl + "\"", null); searchForReviews.prettyPrint(); searchForReviews.then().assertThat() .statusCode(OK.getStatusCode()) .body("data.items[0].name", is(reviewTitle)); + System.out.println("getReviews"); Response getReviews = UtilIT.getReviews(datasetPid); getReviews.prettyPrint(); getReviews.then().assertThat() @@ -590,6 +651,27 @@ public void testLocalReviews() { .body("data.reviews[0].title", is(reviewTitle)) .body("data.reviews[0].persistentId", is(reviewPid)) .body("data.reviews[0].id", is(reviewId)); + + System.out.println("exportDatasetHasReviews"); + Response exportDatasetHasReviews = UtilIT.exportDataset(datasetPid, "croissant"); + exportDatasetHasReviews.prettyPrint(); + String noteBias = "reviews[0].positiveNotes.itemListElement.find { it.name == 'biasEquityAndRepresentativeness' }"; + exportDatasetHasReviews.then().assertThat() + .statusCode(OK.getStatusCode()) + .body("reviews[0].@context", is("https://schema.org/")) + .body("reviews[0].@type", is("CriticReview")) + .body("reviews[0].itemReviewed.@type", is("Dataset")) + .body("reviews[0].itemReviewed.name", is("Review of Pediatric Asthma")) + // .body("reviews[0].author.@type", is("Person")) + .body("reviews[0].creator[0].name", is("Wazowski, Mike")) + .body("reviews[0].positiveNotes.@type", is("ItemList")) + .body(noteBias + ".@type", is("StructuredValue")) + .body(noteBias + ".value.@type", is("QualitativeValue")) + .body(noteBias + ".value.value", is("Low")) + .body("reviews[0].reviewBody", is("This is a review of a dataset.")) + // starting with 2 for 2026, for example + .body("reviews[0].datePublished", startsWith("2")) + .body("reviews[0].id", is(reviewId)); } } diff --git a/src/test/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtilTest.java b/src/test/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtilTest.java new file mode 100644 index 00000000000..4c6bf5b269c --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtilTest.java @@ -0,0 +1,95 @@ +package edu.harvard.iq.dataverse.export.croissant; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.StringWriter; +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import edu.harvard.iq.dataverse.util.json.JsonUtil; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.json.JsonObjectBuilder; +import jakarta.json.JsonWriter; +import jakarta.json.JsonWriterFactory; +import jakarta.json.stream.JsonGenerator; + +public class CroissantExportUtilTest { + + @Test + void testGetReviews() { + // TODO consider reading this instead: doc/sphinx-guides/source/_static/api/list-reviews.json + String content = """ +{ + "reviews": [ + { + "title": "Review of Pediatric Asthma", + "authors": [ + "Wazowski, Mike" + ], + "persistentId": "doi:10.5072/FK2/UWVWPY", + "persistentIdUrl": "https://doi.org/10.5072/FK2/UWVWPY", + "id": 243, + "citation": "Wazowski, Mike, 2026, \\\"Review of Pediatric Asthma\\\", https://doi.org/10.5072/FK2/UWVWPY, Root, V1", + "citationHtml": "Wazowski, Mike, 2026, \\\"Review of Pediatric Asthma\\\", https://doi.org/10.5072/FK2/UWVWPY, Root, V1", + "datePublished": "2026-05-27T19:45:20Z", + "description": "This is a review of a dataset.", + "rubricMetadataBlocks": [ + { + "name": "rubric_trusteddatadimensionsintensities", + "displayName": "Trusted Data Dimensions and Intensities", + "fields": [ + { + "typeName": "biasEquityAndRepresentativeness", + "value": "Low" + }, + { + "typeName": "authorAndProvenance", + "value": "Medium" + }, + { + "typeName": "fitnessForScopeAndContextualRelevance", + "value": "Medium" + }, + { + "typeName": "integrityAndUsability", + "value": "High" + }, + { + "typeName": "transparencyOfMethodsAndDocumentation", + "value": "Low" + }, + { + "typeName": "licensingAndLegalClarity", + "value": "High" + } + ] + } + ] + } + ] +} + """; + JsonObject croissantJson = JsonUtil.getJsonObject(content); + JsonObjectBuilder job = Json.createObjectBuilder(croissantJson); + JsonObject result = CroissantExportUtil.getReviews(job).build(); + System.out.println(prettyPrint(result)); + assertTrue(result.getJsonArray("reviews").size() == 1); + assertEquals("CriticReview", result.getJsonArray("reviews").get(0).asJsonObject().getString("@type")); + } + + public static String prettyPrint(JsonObject jsonObject) { + Map config = new HashMap<>(); + config.put(JsonGenerator.PRETTY_PRINTING, true); + JsonWriterFactory jsonWriterFactory = Json.createWriterFactory(config); + StringWriter stringWriter = new StringWriter(); + try (JsonWriter jsonWriter = jsonWriterFactory.createWriter(stringWriter)) { + jsonWriter.writeObject(jsonObject); + } + return stringWriter.toString(); + } + +} From 03aaefe6ac1c7ba0c4719e3153abe5f0ff44ed1b Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Wed, 3 Jun 2026 09:44:56 -0400 Subject: [PATCH 7/8] remove duplicate JSON, use JSON from guides instead --- .../croissant/CroissantExportUtilTest.java | 63 +++---------------- 1 file changed, 8 insertions(+), 55 deletions(-) diff --git a/src/test/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtilTest.java b/src/test/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtilTest.java index 4c6bf5b269c..aa544f7a930 100644 --- a/src/test/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtilTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/export/croissant/CroissantExportUtilTest.java @@ -3,7 +3,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.IOException; import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.HashMap; import java.util.Map; @@ -20,61 +24,10 @@ public class CroissantExportUtilTest { @Test - void testGetReviews() { - // TODO consider reading this instead: doc/sphinx-guides/source/_static/api/list-reviews.json - String content = """ -{ - "reviews": [ - { - "title": "Review of Pediatric Asthma", - "authors": [ - "Wazowski, Mike" - ], - "persistentId": "doi:10.5072/FK2/UWVWPY", - "persistentIdUrl": "https://doi.org/10.5072/FK2/UWVWPY", - "id": 243, - "citation": "Wazowski, Mike, 2026, \\\"Review of Pediatric Asthma\\\", https://doi.org/10.5072/FK2/UWVWPY, Root, V1", - "citationHtml": "Wazowski, Mike, 2026, \\\"Review of Pediatric Asthma\\\", https://doi.org/10.5072/FK2/UWVWPY, Root, V1", - "datePublished": "2026-05-27T19:45:20Z", - "description": "This is a review of a dataset.", - "rubricMetadataBlocks": [ - { - "name": "rubric_trusteddatadimensionsintensities", - "displayName": "Trusted Data Dimensions and Intensities", - "fields": [ - { - "typeName": "biasEquityAndRepresentativeness", - "value": "Low" - }, - { - "typeName": "authorAndProvenance", - "value": "Medium" - }, - { - "typeName": "fitnessForScopeAndContextualRelevance", - "value": "Medium" - }, - { - "typeName": "integrityAndUsability", - "value": "High" - }, - { - "typeName": "transparencyOfMethodsAndDocumentation", - "value": "Low" - }, - { - "typeName": "licensingAndLegalClarity", - "value": "High" - } - ] - } - ] - } - ] -} - """; - JsonObject croissantJson = JsonUtil.getJsonObject(content); - JsonObjectBuilder job = Json.createObjectBuilder(croissantJson); + void testGetReviews() throws IOException { + String content = Files.readString(Path.of("doc/sphinx-guides/source/_static/api/list-reviews.json"), StandardCharsets.UTF_8); + JsonObject apiResponseJson = JsonUtil.getJsonObject(content); + JsonObjectBuilder job = Json.createObjectBuilder(apiResponseJson.getJsonObject("data")); JsonObject result = CroissantExportUtil.getReviews(job).build(); System.out.println(prettyPrint(result)); assertTrue(result.getJsonArray("reviews").size() == 1); From 613d4523dbb04fb93053753d06db4fa5fc3a4a15 Mon Sep 17 00:00:00 2001 From: Philip Durbin Date: Wed, 3 Jun 2026 09:45:54 -0400 Subject: [PATCH 8/8] reference pull request in release note snippet --- doc/release-notes/12313-local-reviews.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/release-notes/12313-local-reviews.md b/doc/release-notes/12313-local-reviews.md index 0b17fbccbe4..4ca22a784ff 100644 --- a/doc/release-notes/12313-local-reviews.md +++ b/doc/release-notes/12313-local-reviews.md @@ -6,7 +6,7 @@ A new metadata block called "Trusted Data Dimensions and Intensities" has been a If you set `dataverse.feature.croissant-with-local-reviews` to true, local reviews will appear in the croissant and croissantSlim metadata export formats for any dataset that has local reviews. This feature is experiemental, which is why it is hidden behind a feature flag. -See the guides for the new [list reviews](https://preview.guides.gdcc.io/en/develop/api/native-api.html#list-reviews) API endpoint, #12313, and #12314. +See the guides for the new [list reviews](https://preview.guides.gdcc.io/en/develop/api/native-api.html#list-reviews) API endpoint, #12313, #12314, and #12425. ## New Settings