diff --git a/core/src/main/java/org/fao/geonet/constants/Geonet.java b/core/src/main/java/org/fao/geonet/constants/Geonet.java index 4f25bd259ea0..7e6d5b232062 100644 --- a/core/src/main/java/org/fao/geonet/constants/Geonet.java +++ b/core/src/main/java/org/fao/geonet/constants/Geonet.java @@ -662,6 +662,7 @@ public static class IndexFieldNames { public static final String IS_PUBLISHED_TO_ALL = "isPublishedToAll"; public static final String IS_PUBLISHED_TO_INTRANET = "isPublishedToIntranet"; public static final String IS_PUBLISHED_TO_GUEST = "isPublishedToGuest"; + public static final String AGG_ASSOCIATED_REVISION_OF = "agg_associated_revisionOf"; public static final String FEEDBACKCOUNT = "feedbackCount"; public static final String DRAFT = "draft"; public static final String DRAFT_ID = "draftId"; diff --git a/core/src/main/java/org/fao/geonet/kernel/search/EsSearchManager.java b/core/src/main/java/org/fao/geonet/kernel/search/EsSearchManager.java index 57538017531f..1e6ff2643775 100644 --- a/core/src/main/java/org/fao/geonet/kernel/search/EsSearchManager.java +++ b/core/src/main/java/org/fao/geonet/kernel/search/EsSearchManager.java @@ -133,6 +133,8 @@ public class EsSearchManager implements ISearchManager { .add("link") .add("format") .add("resourceType") + .add("resourceEdition") + .add("resourceDate") .add("cl_status.key") .add(Geonet.IndexFieldNames.OP_PREFIX + "*") .add(Geonet.IndexFieldNames.GROUP_OWNER) @@ -796,6 +798,40 @@ public Map getDocument(String uuid) throws Exception { return client.getDocument(defaultIndex, uuid); } + public static Set extractFieldValues(Object fieldValue) { + Set values = new LinkedHashSet<>(); + if (fieldValue == null) { + return values; + } + + if (fieldValue instanceof Collection) { + for (Object value : (Collection) fieldValue) { + addFieldValue(values, value); + } + } else if (fieldValue.getClass().isArray()) { + int length = java.lang.reflect.Array.getLength(fieldValue); + for (int i = 0; i < length; i++) { + addFieldValue(values, java.lang.reflect.Array.get(fieldValue, i)); + } + } else { + addFieldValue(values, fieldValue); + } + + return values; + } + + private static void addFieldValue(Set values, Object value) { + if (value == null) { + return; + } + + String trimmed = String.valueOf(value).trim(); + if (StringUtils.isNotEmpty(trimmed)) { + values.add(trimmed); + } + } + + public SearchResponse query(String luceneQuery, String filterQuery, int startPosition, int maxRecords) throws Exception { return client.query(defaultIndex, luceneQuery, filterQuery, new HashSet<>(), new HashMap<>(), startPosition, maxRecords); } diff --git a/docs/manual/docs/user-guide/associating-resources/linking-others.md b/docs/manual/docs/user-guide/associating-resources/linking-others.md index 74f4eab67fcc..97a9509ac9e7 100644 --- a/docs/manual/docs/user-guide/associating-resources/linking-others.md +++ b/docs/manual/docs/user-guide/associating-resources/linking-others.md @@ -1,6 +1,7 @@ -# Other types of resources (eg. sensor, publication) {#linking-others} +# Other types of resources (eg. sensor, publication, revision) {#linking-others} In ISO, associated resource allows to link to record defining the type of relation with: + * association type (mandatory) * initiative type @@ -16,8 +17,45 @@ Codelist values for association types are: * Dependency * Revision Of -In ISO19115-3, by default parent relations are defined with association type set to `partOfSeamlessDatabase`. This can be customized with [the panel configuration](linking-panel-configuration.md). ![](img/iso-associated-resources.png) +## Grouping records together + +When having a series of records related together, it may make sense to group them under a same parent record. + +In ISO19115-3, by default parent relations are defined with association type set to `partOfSeamlessDatabase` to indicate that a set of datasets are stored in same database. +This can be customized with [the panel configuration](linking-panel-configuration.md). + +To promote some kind of "data products" or "collections", it may be interesting to create parent records with association type pointing to the child records with association type `isComposedOf`. This is the approach used at EEA (https://sdi.eea.europa.eu/catalogue) where all datasets are grouped in series. The series provide general information and quick access to all child records (eg. temporal series, current and archived versions). + + + +## Linking to previous version + +When a record is updated, it may be interesting to link the new version to the previous version. +This can be done with the association type `revisionOf` and linking to the previous version of the record: + +```xml + + + + + + + + +``` + +This will allow users to navigate between versions of the record in the record view or using the related record API. + +It is recommended to use the same privileges for all versions of the record to be able to navigate between all versions. Usually when a revision is created, the previous version status is updated to "archived" or "superseded". + + + +## Linking to scientific publications + +When a dataset is linked to a scientific publication, an option is to use a link with the association type `cross reference` and initiative type set to `study`. +Those type of links usually point to a DOI or a remote URL (See [link to remote URL](linking-remote-records.md) and [using DataCite and Crossref for DOI](linking-panel-configuration.md)). diff --git a/docs/manual/docs/user-guide/associating-resources/linking-panel-configuration.md b/docs/manual/docs/user-guide/associating-resources/linking-panel-configuration.md index d3dde18f61eb..ae2d5db5eb0f 100644 --- a/docs/manual/docs/user-guide/associating-resources/linking-panel-configuration.md +++ b/docs/manual/docs/user-guide/associating-resources/linking-panel-configuration.md @@ -55,6 +55,7 @@ Example: ## Associated resources For associated resources, configuration has the following properties: + * `type` is the type of association (eg. parent, child, sibling) * `label` is the label key of the resource type (defined in JSON loc file or in database translations) * `config` defines the configuration for the association type, which has the following properties: @@ -98,6 +99,7 @@ When `metadataStore` is defined as a source, the user can search for metadata re } ``` In another plugin: + * `catalog-*` association type will only search for records with a `catalog` resource type * `nextResource-*` association type will only search for `dataset` or `service` @@ -120,6 +122,7 @@ In another plugin: #### DOI Allows to select metadata from a DOI endpoint: + * DataCite * Crossref diff --git a/docs/manual/docs/user-guide/associating-resources/linking-parent.md b/docs/manual/docs/user-guide/associating-resources/linking-parent.md index 8c567c3332e6..3ab20227020f 100644 --- a/docs/manual/docs/user-guide/associating-resources/linking-parent.md +++ b/docs/manual/docs/user-guide/associating-resources/linking-parent.md @@ -7,7 +7,9 @@ In some situations, a dataset is part of a temporal or spatial collections of re - Corine Land Cover 2012 - \... -Parent/child relations can be set on ISO19139 and Dublin Core records. In ISO19139, the link to a parent record is encoded in the child record using the following: +Parent/child relations can be set on ISO and Dublin Core records. + +In ISO19139, the link to a parent record is encoded in the child record using the following: ``` xml @@ -15,7 +17,8 @@ Parent/child relations can be set on ISO19139 and Dublin Core records. In ISO191 ``` -Parent/Child relation in ISO19139 may also be encoded using aggregates (see [Other types of resources (eg. sensor, publication)](linking-others.md)). +Parent/Child relation in ISO may also be encoded using aggregates to clarify the type of the association (see [Other types of resources (eg. sensor, publication)](linking-others.md)). By default, ISO19115-3 is using the `associatedResource` element with association type set to `partOfSeamlessDatabase` for parent/child relation. + When creating such relationship, users will be able to navigate between the records in the search and record view. diff --git a/services/src/main/java/org/fao/geonet/api/records/MetadataAssociatedApi.java b/services/src/main/java/org/fao/geonet/api/records/MetadataAssociatedApi.java index 5a62a945f15d..af37a80454f0 100644 --- a/services/src/main/java/org/fao/geonet/api/records/MetadataAssociatedApi.java +++ b/services/src/main/java/org/fao/geonet/api/records/MetadataAssociatedApi.java @@ -91,8 +91,8 @@ public synchronized void setApplicationContext(ApplicationContext context) { @io.swagger.v3.oas.annotations.Operation( summary = "Get record associated resources", - description = "Retrieve related services, datasets, sources, ... " + - "to this records.
" + + description = "Retrieve related resources for a record. " + + "There are 2 options to access related resources. One is using this operation, the other is to use the `_search` endpoint and using the `relatedType` parameter.\n\n" + "More info") @RequestMapping(value = "/{metadataUuid:.+}/associated", method = RequestMethod.GET, @@ -111,7 +111,26 @@ public Map> getAssociatedResources( required = true) @PathVariable String metadataUuid, - @Parameter(description = "Type of related resource. If none, all resources are returned.", + @Parameter(description = "Type of related resource. If none, all types are returned.\n\n" + + "Allowed values:\n" + + "- `parent`: parent records declared by schema associations.\n" + + "- `children`: records having parent relation pointing to this record.\n" + + "- `brothersAndSisters`: records sharing the same parent(s).\n" + + "- `siblings`: aggregated/associated resources linked by this record.\n" + + "- `associated`: reverse siblings relation (records referencing this record).\n" + + "- `versions`: full revision chain for this record (does not support remote records). If the current record is the only version, empty list is returned.\n" + + "- `nextVersion`: immediate next item in version ordering.\n" + + "- `previousVersion`: immediate previous item in version ordering.\n" + + "- `services`: services publishing this dataset record.\n" + + "- `datasets`: datasets published (operated) by a service record.\n" + + "- `fcats`: feature catalog references.\n" + + "- `hasfeaturecats`: records referencing this record as feature catalog.\n" + + "- `sources`: this record is derived from those.\n" + + "- `hassources`: records derived from this record.\n\n" + + "Deprecated values:\n" + + "- `related`: legacy relation table links.\n" + + "- `onlines`: online links extracted from metadata. Use record links property instead.\n" + + "- `thumbnails`: thumbnails extracted from metadata. Use record overview property instead.", required = false ) @RequestParam(defaultValue = "") diff --git a/services/src/main/java/org/fao/geonet/api/records/MetadataUtils.java b/services/src/main/java/org/fao/geonet/api/records/MetadataUtils.java index 8660f17c831e..05afcccf2be8 100644 --- a/services/src/main/java/org/fao/geonet/api/records/MetadataUtils.java +++ b/services/src/main/java/org/fao/geonet/api/records/MetadataUtils.java @@ -31,6 +31,17 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.base.Joiner; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; import jeeves.server.context.ServiceContext; import org.apache.commons.lang.StringUtils; import org.apache.commons.text.StringEscapeUtils; @@ -43,6 +54,9 @@ import org.fao.geonet.api.records.model.related.RelatedItemOrigin; import org.fao.geonet.api.records.model.related.RelatedItemType; import org.fao.geonet.constants.Geonet; + +import static org.fao.geonet.api.records.MetadataVersionsUtils.*; + import org.fao.geonet.domain.AbstractMetadata; import org.fao.geonet.domain.ReservedOperation; import org.fao.geonet.domain.Source; @@ -53,7 +67,13 @@ import org.fao.geonet.kernel.schema.AssociatedResource; import org.fao.geonet.kernel.schema.AssociatedResourcesSchemaPlugin; import org.fao.geonet.kernel.schema.SchemaPlugin; +import static org.fao.geonet.kernel.search.EsFilterBuilder.buildPermissionsFilter; import org.fao.geonet.kernel.search.EsSearchManager; +import static org.fao.geonet.kernel.search.EsSearchManager.FIELDLIST_CORE; +import static org.fao.geonet.kernel.search.EsSearchManager.FIELDLIST_RELATED; +import static org.fao.geonet.kernel.search.EsSearchManager.FIELDLIST_RELATED_SCRIPTED; +import static org.fao.geonet.kernel.search.EsSearchManager.FIELDLIST_UUID; +import static org.fao.geonet.kernel.search.EsSearchManager.RELATED_INDEX_FIELDS; import org.fao.geonet.kernel.setting.SettingInfo; import org.fao.geonet.kernel.setting.SettingManager; import org.fao.geonet.lib.Lib; @@ -71,12 +91,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.ApplicationContext; - -import java.util.*; -import java.util.stream.Collectors; - -import static org.fao.geonet.kernel.search.EsFilterBuilder.buildPermissionsFilter; -import static org.fao.geonet.kernel.search.EsSearchManager.*; import org.w3c.dom.Node; @@ -95,6 +109,7 @@ public static class RelatedTypeDetails { private Set expectedRecords = new HashSet<>(); private Set remoteRecords = new HashSet<>(); private Map> recordsProperties = new HashMap<>(); + private List orderedRecords = new ArrayList<>(); public RelatedTypeDetails(String query) { this.query = query; @@ -116,6 +131,15 @@ public RelatedTypeDetails(String query, Set expectedRecords, Map expectedRecords, Map> recordsProperties, + Set remoteRecords, List orderedRecords) { + this.query = query; + this.expectedRecords = expectedRecords; + this.recordsProperties = recordsProperties; + this.remoteRecords = remoteRecords; + this.orderedRecords = orderedRecords; + } + public String getQuery() { return query; } @@ -143,6 +167,10 @@ public void setRecordsProperties(Map> recordsPropert public Set getRemoteRecords() { return remoteRecords; } + + public List getOrderedRecords() { + return orderedRecords; + } } @@ -157,37 +185,41 @@ public static Node getAssociatedAsXml(String metadataUuid) { try { Map> associated = MetadataUtils.getAssociated(context, metadataEntity, RelatedItemType.values(), 0, 100); for (Map.Entry> entry : associated.entrySet()) { - for (AssociatedRecord record : entry.getValue()) { + for (AssociatedRecord associatedRecord : entry.getValue()) { Element relation = new Element(entry.getKey().name()); - relation.setAttribute("uuid", record.getUuid()); - relation.setAttribute("origin", record.getOrigin()); - if (record.getProperties() != null) { - if (record.getProperties().get("associationType") != null) { - relation.setAttribute("associationType", record.getProperties().get("associationType")); + relation.setAttribute("uuid", associatedRecord.getUuid()); + relation.setAttribute("origin", associatedRecord.getOrigin()); + if (associatedRecord.getProperties() != null) { + if (associatedRecord.getProperties().get("associationType") != null) { + relation.setAttribute("associationType", associatedRecord.getProperties().get("associationType")); } - if (record.getProperties().get("initiativeType") != null) { - relation.setAttribute("initiativeType", record.getProperties().get("initiativeType")); + if (associatedRecord.getProperties().get("initiativeType") != null) { + relation.setAttribute("initiativeType", associatedRecord.getProperties().get("initiativeType")); } - if (record.getProperties().get("resourceTitle") != null) { - relation.setAttribute("resourceTitle", record.getProperties().get("resourceTitle")); + if (associatedRecord.getProperties().get("resourceTitle") != null) { + relation.setAttribute("resourceTitle", associatedRecord.getProperties().get("resourceTitle")); } - if (record.getProperties().get("url") != null) { - relation.setAttribute("url", record.getProperties().get("url")); + if (associatedRecord.getProperties().get("url") != null) { + relation.setAttribute("url", associatedRecord.getProperties().get("url")); } } - relation.addContent(Xml.getXmlFromJSON(record.getRecord().toPrettyString())); + relation.addContent(Xml.getXmlFromJSON(associatedRecord.getRecord().toPrettyString())); relations.addContent(relation); } } } catch (Exception e) { - throw new RuntimeException(e); + LOGGER.warn(String.format("An error occurred when getting associated records for metadata with uuid %s: %s", + metadataUuid, e.getMessage()), e); + return null; } DOMOutputter outputter = new DOMOutputter(); try { return outputter.output(new Document(relations)); } catch (JDOMException e) { - throw new RuntimeException(e); + LOGGER.warn(String.format("An error occurred when converting associated records as XML for metadata with uuid %s: %s", + metadataUuid, e.getMessage()), e); + return null; } } @@ -213,6 +245,7 @@ public static Map> getAssociated( // For each type, store a query and expected list of uuids. Map queries = new HashMap<>(); Set allSearchedUuids = new HashSet<>(); + RelatedTypeDetails versionsDetails = null; // We have 3 types of links @@ -227,8 +260,19 @@ public static Map> getAssociated( // brothers&sisters // // * All of them could be remote records - Arrays.stream(types).forEach(type -> { - if (type == RelatedItemType.associated + for (RelatedItemType type : types) { + if (type == RelatedItemType.versions) { + if (versionsDetails == null) { + versionsDetails = getAllVersions(searchMan, md.getUuid()); + } + queries.put(type, versionsDetails); + } else if (type == RelatedItemType.nextVersion || type == RelatedItemType.previousVersion) { + if (versionsDetails == null) { + versionsDetails = getAllVersions(searchMan, md.getUuid()); + } + queries.put(type, getNextOrPrevious( + versionsDetails, md.getUuid(), type == RelatedItemType.nextVersion)); + } else if (type == RelatedItemType.associated || type == RelatedItemType.hasfeaturecats || type == RelatedItemType.services || type == RelatedItemType.hassources) { @@ -325,7 +369,7 @@ public static Map> getAssociated( )); allSearchedUuids.addAll(isComposedOfList); } - }); + } Map> associated = @@ -395,6 +439,15 @@ public static Map> getAssociated( } buildRemoteRecords(mapper, relatedTypeDetails, records); + records = reorderIndexDocBasedOnOrderedRecords(records, relatedTypeDetails.getOrderedRecords()); + + // If the current record is the only version, empty list is returned. + if (entry.getKey() == RelatedItemType.versions + && records.size() == 1 + && md.getUuid().equals(records.get(0).getUuid())) { + records = new ArrayList<>(); + } + associated.put(entry.getKey(), records); } diff --git a/services/src/main/java/org/fao/geonet/api/records/MetadataVersionsUtils.java b/services/src/main/java/org/fao/geonet/api/records/MetadataVersionsUtils.java new file mode 100644 index 000000000000..225ee8b3d9a7 --- /dev/null +++ b/services/src/main/java/org/fao/geonet/api/records/MetadataVersionsUtils.java @@ -0,0 +1,231 @@ +/* + * Copyright (C) 2001-2026 Food and Agriculture Organization of the + * United Nations (FAO-UN), United Nations World Food Programme (WFP) + * and United Nations Environment Programme (UNEP) + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or (at + * your option) any later version. + * + * This program is distributed in the hope that it will be useful, but + * WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301, USA + * + * Contact: Jeroen Ticheler - FAO - Viale delle Terme di Caracalla 2, + * Rome - Italy. email: geonetwork@osgeo.org + */ + +package org.fao.geonet.api.records; + +import co.elastic.clients.elasticsearch.core.SearchResponse; +import co.elastic.clients.elasticsearch.core.search.Hit; +import org.fao.geonet.api.records.model.related.AssociatedRecord; +import org.fao.geonet.kernel.search.EsSearchManager; + +import java.util.*; +import java.util.stream.Collectors; + +import static org.fao.geonet.constants.Geonet.IndexFieldNames.AGG_ASSOCIATED_REVISION_OF; +import static org.fao.geonet.kernel.search.EsSearchManager.*; + + +public class MetadataVersionsUtils { + + /** + * For versions, order is based on the version graph. + */ + protected static List reorderIndexDocBasedOnOrderedRecords(List records, List orderedRecords) { + if (orderedRecords == null || orderedRecords.isEmpty()) { + return records; + } + + Map recordsByUuid = new LinkedHashMap<>(); + for (AssociatedRecord associatedRecord : records) { + recordsByUuid.put(associatedRecord.getUuid(), associatedRecord); + } + + List reordered = new ArrayList<>(); + for (String uuid : orderedRecords) { + AssociatedRecord associatedRecord = recordsByUuid.remove(uuid); + if (associatedRecord != null) { + reordered.add(associatedRecord); + } + // else, remote records are not supported in version list. + } + reordered.addAll(recordsByUuid.values()); + return reordered; + } + + /** + * Retrieve all versions of a record. + * Remote records are not supported because they may not be XML document + * so we can't assume that we will be able to extract revisionOf records. + */ + protected static MetadataUtils.RelatedTypeDetails getAllVersions(EsSearchManager searchMan, String metadataUuid) throws Exception { + Set allVersionUuids = new LinkedHashSet<>(); + Map> previousVersionsByUuid = new LinkedHashMap<>(); + Deque uuidsToProcess = new ArrayDeque<>(); + + allVersionUuids.add(metadataUuid); + uuidsToProcess.add(metadataUuid); + + while (!uuidsToProcess.isEmpty()) { + String currentUuid = uuidsToProcess.removeFirst(); + + Map document; + try { + document = searchMan.getDocument(currentUuid); + } catch (Exception e) { + // Referenced versions may point to records not indexed in this catalogue. + // And we need the document to extract relation to next item. + // This can happen on remote record link. This is not supported for now. + // previousVersionsByUuid.putIfAbsent(currentUuid, new LinkedHashSet<>()); + allVersionUuids.remove(currentUuid); + continue; + } + + Set previousVersions = EsSearchManager.extractFieldValues(document.get(AGG_ASSOCIATED_REVISION_OF)); + previousVersionsByUuid.computeIfAbsent(currentUuid, key -> new LinkedHashSet<>()).addAll(previousVersions); + for (String previousUuid : previousVersions) { + if (allVersionUuids.add(previousUuid)) { + uuidsToProcess.add(previousUuid); + } + } + + int from = 0; + final int pageSize = 100; + while (true) { + SearchResponse searchResponse = searchMan.query( + String.format("+agg_associated_revisionOf:\"%s\" AND (draft:\"n\" OR draft:\"e\")", currentUuid), + null, + FIELDLIST_UUID, + from, + pageSize); + List hits = searchResponse.hits().hits(); + if (hits.isEmpty()) { + break; + } + + for (Hit hit : hits) { + previousVersionsByUuid.computeIfAbsent(hit.id(), key -> new LinkedHashSet<>()).add(currentUuid); + if (allVersionUuids.add(hit.id())) { + uuidsToProcess.add(hit.id()); + } + } + + if (hits.size() < pageSize) { + break; + } + from += pageSize; + } + } + + List orderedRecords = orderVersionsNewestToOldest(allVersionUuids, previousVersionsByUuid); + + + return new MetadataUtils.RelatedTypeDetails( + String.format("(uuid:(%s)) AND (draft:\"n\" OR draft:\"e\")", + orderedRecords.stream().collect(Collectors.joining("\" OR \"", "\"", "\""))), + new LinkedHashSet<>(orderedRecords), + new HashMap<>(), + new HashSet<>(), + orderedRecords); + } + + protected static MetadataUtils.RelatedTypeDetails getNextOrPrevious( + MetadataUtils.RelatedTypeDetails versionDetails, String currentUuid, boolean isNext) { + List orderedRecords = versionDetails.getOrderedRecords(); + + if (orderedRecords == null || orderedRecords.isEmpty()) { + return new MetadataUtils.RelatedTypeDetails("(uuid:(\"\")) AND (draft:\"n\" OR draft:\"e\")", + new HashSet<>(), new HashMap<>(), new HashSet<>(), new ArrayList<>()); + } + + // Find current record in the ordered list (newest to oldest) + int currentIndex = orderedRecords.indexOf(currentUuid); + + // For next: we need the previous in the list (towards newer versions) + // For previous: we need the next in the list (towards older versions) + int targetIndex = isNext ? currentIndex - 1 : currentIndex + 1; + + if (targetIndex < 0 || targetIndex >= orderedRecords.size()) { + // No next or previous version exists + return new MetadataUtils.RelatedTypeDetails("(uuid:(\"\")) AND (draft:\"n\" OR draft:\"e\")", + new HashSet<>(), new HashMap<>(), new HashSet<>(), new ArrayList<>()); + } + + String targetUuid = orderedRecords.get(targetIndex); + List result = new ArrayList<>(); + result.add(targetUuid); + + return new MetadataUtils.RelatedTypeDetails( + String.format("(uuid:\"%s\") AND (draft:\"n\" OR draft:\"e\")", targetUuid), + new LinkedHashSet<>(result), + new HashMap<>(), + new HashSet<>(), + result); + } + + private static List orderVersionsNewestToOldest(Set versionUuids, + Map> previousVersionsByUuid) { + Map incomingEdges = new LinkedHashMap<>(); + Map> edges = new LinkedHashMap<>(); + + for (String uuid : versionUuids) { + incomingEdges.put(uuid, 0); + edges.put(uuid, new LinkedHashSet<>()); + } + + for (Map.Entry> entry : previousVersionsByUuid.entrySet()) { + if (!versionUuids.contains(entry.getKey())) { + continue; + } + + for (String previousUuid : entry.getValue()) { + if (!versionUuids.contains(previousUuid)) { + continue; + } + + if (edges.get(entry.getKey()).add(previousUuid)) { + incomingEdges.put(previousUuid, incomingEdges.get(previousUuid) + 1); + } + } + } + + Deque queue = new ArrayDeque<>(); + incomingEdges.entrySet().stream() + .filter(entry -> entry.getValue() == 0) // i.e. no newer version + .map(Map.Entry::getKey) + .sorted() + .forEach(queue::addLast); + + List ordered = new ArrayList<>(); + while (!queue.isEmpty()) { + String uuid = queue.removeFirst(); + ordered.add(uuid); + + for (String previousUuid : edges.getOrDefault(uuid, Collections.emptySet())) { + int newIndegree = incomingEdges.get(previousUuid) - 1; + incomingEdges.put(previousUuid, newIndegree); + if (newIndegree == 0) { + queue.addLast(previousUuid); + } + } + } + + if (ordered.size() != versionUuids.size()) { + versionUuids.stream() + .filter(uuid -> !ordered.contains(uuid)) + .sorted() + .forEach(ordered::add); + } + + return ordered; + } +} diff --git a/services/src/main/java/org/fao/geonet/api/records/model/related/AssociatedRecord.java b/services/src/main/java/org/fao/geonet/api/records/model/related/AssociatedRecord.java index 2756a8219755..49f6cbd5a362 100644 --- a/services/src/main/java/org/fao/geonet/api/records/model/related/AssociatedRecord.java +++ b/services/src/main/java/org/fao/geonet/api/records/model/related/AssociatedRecord.java @@ -65,7 +65,7 @@ public void setUuid(String uuid) { @JsonProperty("_id") public String getUuid() { - return uuid; + return uuid == null ? "" : uuid; } public Map getProperties() { diff --git a/services/src/main/java/org/fao/geonet/api/records/model/related/RelatedItemType.java b/services/src/main/java/org/fao/geonet/api/records/model/related/RelatedItemType.java index 396f26d65135..2fe207796092 100644 --- a/services/src/main/java/org/fao/geonet/api/records/model/related/RelatedItemType.java +++ b/services/src/main/java/org/fao/geonet/api/records/model/related/RelatedItemType.java @@ -19,21 +19,43 @@ public enum RelatedItemType { */ parent, /** - * When 2 records share the same parents, they are siblings. - * Siblings are records having parentUuid index field set to the same parent UUID. + * When 2 records share the same parents. */ brothersAndSisters, /** * Aggregation info in ISO19139, Associated resources in ISO19115-3, - * isPartOf in Dublin core + * isPartOf in Dublin core. */ siblings, /** * Associated is reverse direction of siblings. * Record having agg_associated index field set to the record of interest. - * The relation does not contains details about association and initiative type. + * The relation does not contain details about association and initiative type. */ associated, + /** + * Return all revisions for a record. Revision link is made using + * associated records in ISO19115-3 with revisionOf codelist value + * for association type. + *
+     *    
+     *      
+     *         
+     *            
+     *         
+     *         
+     *      
+     *   
+     * 
+ * + * In the index document, the field agg_associated_revisionOf + * is pointing to the previous version of a record. + */ + versions, + nextVersion, + previousVersion, /** * All services having recordOperateOn index field pointing to record of interest. */ diff --git a/services/src/test/java/org/fao/geonet/api/records/MetadataAssociatedApiTest.java b/services/src/test/java/org/fao/geonet/api/records/MetadataAssociatedApiTest.java index 9dc4ad51c30c..72738f37f1b2 100644 --- a/services/src/test/java/org/fao/geonet/api/records/MetadataAssociatedApiTest.java +++ b/services/src/test/java/org/fao/geonet/api/records/MetadataAssociatedApiTest.java @@ -218,4 +218,123 @@ public void getAssociated() throws Exception { .andExpect(jsonPath("$.associated", hasSize(1))) .andExpect(jsonPath("$.associated[0]._source.uuid").value(SERIE_UUID)); } + + @Test + public void getAssociatedVersionsReturnsWholeChain() throws Exception { + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build(); + MockHttpSession mockHttpSession = loginAsAdmin(); + + final MEFLibIntegrationTest.ImportMetadata importMetadata = + new MEFLibIntegrationTest.ImportMetadata(this, context); + importMetadata.getMefFilesToLoad().add( + "/org/fao/geonet/api/records/samples/related-versions-test.zip"); + importMetadata.invoke(); + + final String VERSION_OLDEST = "11111111-1111-4111-8111-111111111111"; + final String VERSION_MIDDLE = "22222222-2222-4222-8222-222222222222"; + final String VERSION_LATEST = "33333333-3333-4333-8333-333333333333"; + + final String[] versions = {VERSION_LATEST, VERSION_MIDDLE, VERSION_OLDEST}; + + for (String versionUuid : versions) { + mockMvc.perform(get("/srv/api/records/" + versionUuid + "/associated?type=versions") + .session(mockHttpSession) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(API_JSON_EXPECTED_ENCODING)) + .andExpect(jsonPath("$.versions", hasSize(3))) + .andExpect(jsonPath("$.versions[0]._source.uuid").value(VERSION_LATEST)) + .andExpect(jsonPath("$.versions[1]._source.uuid").value(VERSION_MIDDLE)) + .andExpect(jsonPath("$.versions[2]._source.uuid").value(VERSION_OLDEST)); + } + } + + @Test + public void getAssociatedNextPreviousVersions() throws Exception { + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build(); + MockHttpSession mockHttpSession = loginAsAdmin(); + + final MEFLibIntegrationTest.ImportMetadata importMetadata = + new MEFLibIntegrationTest.ImportMetadata(this, context); + importMetadata.getMefFilesToLoad().add( + "/org/fao/geonet/api/records/samples/related-versions-test.zip"); + importMetadata.invoke(); + + final String VERSION_OLDEST = "11111111-1111-4111-8111-111111111111"; + final String VERSION_MIDDLE = "22222222-2222-4222-8222-222222222222"; + final String VERSION_LATEST = "33333333-3333-4333-8333-333333333333"; + + // From VERSION_LATEST, nextVersion should be empty (no newer version) + mockMvc.perform(get("/srv/api/records/" + VERSION_LATEST + "/associated?type=nextVersion") + .session(mockHttpSession) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(API_JSON_EXPECTED_ENCODING)) + .andExpect(jsonPath("$.nextVersion", hasSize(0))); + + // From VERSION_MIDDLE, nextVersion should be VERSION_LATEST + mockMvc.perform(get("/srv/api/records/" + VERSION_MIDDLE + "/associated?type=nextVersion") + .session(mockHttpSession) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(API_JSON_EXPECTED_ENCODING)) + .andExpect(jsonPath("$.nextVersion", hasSize(1))) + .andExpect(jsonPath("$.nextVersion[0]._source.uuid").value(VERSION_LATEST)); + + // From VERSION_OLDEST, nextVersion should be VERSION_MIDDLE + mockMvc.perform(get("/srv/api/records/" + VERSION_OLDEST + "/associated?type=nextVersion") + .session(mockHttpSession) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(API_JSON_EXPECTED_ENCODING)) + .andExpect(jsonPath("$.nextVersion", hasSize(1))) + .andExpect(jsonPath("$.nextVersion[0]._source.uuid").value(VERSION_MIDDLE)); + + // From VERSION_LATEST, previousVersion should be VERSION_MIDDLE + mockMvc.perform(get("/srv/api/records/" + VERSION_LATEST + "/associated?type=previousVersion") + .session(mockHttpSession) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(API_JSON_EXPECTED_ENCODING)) + .andExpect(jsonPath("$.previousVersion", hasSize(1))) + .andExpect(jsonPath("$.previousVersion[0]._source.uuid").value(VERSION_MIDDLE)); + + // From VERSION_MIDDLE, previousVersion should be VERSION_OLDEST + mockMvc.perform(get("/srv/api/records/" + VERSION_MIDDLE + "/associated?type=previousVersion") + .session(mockHttpSession) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(API_JSON_EXPECTED_ENCODING)) + .andExpect(jsonPath("$.previousVersion", hasSize(1))) + .andExpect(jsonPath("$.previousVersion[0]._source.uuid").value(VERSION_OLDEST)); + + // From VERSION_OLDEST, previousVersion should be empty (no newer version) + mockMvc.perform(get("/srv/api/records/" + VERSION_OLDEST + "/associated?type=previousVersion") + .session(mockHttpSession) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(API_JSON_EXPECTED_ENCODING)) + .andExpect(jsonPath("$.previousVersion", hasSize(0))); + } + + @Test + public void getAssociatedVersionsReturnsEmptyWhenNoOtherVersionExists() throws Exception { + MockMvc mockMvc = MockMvcBuilders.webAppContextSetup(this.wac).build(); + MockHttpSession mockHttpSession = loginAsAdmin(); + + final MEFLibIntegrationTest.ImportMetadata importMetadata = + new MEFLibIntegrationTest.ImportMetadata(this, context); + importMetadata.getMefFilesToLoad().add( + "/org/fao/geonet/api/records/samples/related-test.zip"); + importMetadata.invoke(); + + final String SERIE_UUID = "87e54d56-323f-4201-88ac-7ac7f9d8ee25"; + + mockMvc.perform(get("/srv/api/records/" + SERIE_UUID + "/associated?type=versions") + .session(mockHttpSession) + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(content().contentType(API_JSON_EXPECTED_ENCODING)) + .andExpect(jsonPath("$.versions", hasSize(0))); + } } diff --git a/services/src/test/resources/org/fao/geonet/api/records/samples/related-versions-test.zip b/services/src/test/resources/org/fao/geonet/api/records/samples/related-versions-test.zip new file mode 100644 index 000000000000..8b9eb5fbcae3 Binary files /dev/null and b/services/src/test/resources/org/fao/geonet/api/records/samples/related-versions-test.zip differ diff --git a/web-ui/src/main/resources/catalog/components/metadataactions/partials/related.html b/web-ui/src/main/resources/catalog/components/metadataactions/partials/related.html index c142ef5fe8fc..1a422a491635 100644 --- a/web-ui/src/main/resources/catalog/components/metadataactions/partials/related.html +++ b/web-ui/src/main/resources/catalog/components/metadataactions/partials/related.html @@ -11,6 +11,7 @@

{{::title}}

data-ng-repeat="(type, items) in relations track by $index" data-ng-if="layout !== 'card' && layout !== 'title' + && layout !== 'versions' && type && type !== 'thumbnails'" >
+ +
+

+ + {{'versions' | translate}} +

+ + +
diff --git a/web-ui/src/main/resources/catalog/components/search/mdview/mdviewService.js b/web-ui/src/main/resources/catalog/components/search/mdview/mdviewService.js index 0a0ca7ace4e9..6077bdc021e1 100644 --- a/web-ui/src/main/resources/catalog/components/search/mdview/mdviewService.js +++ b/web-ui/src/main/resources/catalog/components/search/mdview/mdviewService.js @@ -244,7 +244,7 @@ types || "parent|children|sources|hassources|" + "brothersAndSisters|services|datasets|" + - "siblings|associated|fcats|hasfeaturecats|related"; + "siblings|associated|fcats|hasfeaturecats|related|versions"; return "relatedType=" + types.split("|").join("&relatedType="); }; diff --git a/web-ui/src/main/resources/catalog/components/utility/partials/associated-record-label.html b/web-ui/src/main/resources/catalog/components/utility/partials/associated-record-label.html index 53b56746ac94..208eda935efc 100644 --- a/web-ui/src/main/resources/catalog/components/utility/partials/associated-record-label.html +++ b/web-ui/src/main/resources/catalog/components/utility/partials/associated-record-label.html @@ -6,8 +6,10 @@ {{customLabel}} - - {{type.associationType | translate}} + + + {{type.associationType | translate}} + > diff --git a/web-ui/src/main/resources/catalog/locales/en-core.json b/web-ui/src/main/resources/catalog/locales/en-core.json index 5a31ae7c0296..3fd2eb516d93 100644 --- a/web-ui/src/main/resources/catalog/locales/en-core.json +++ b/web-ui/src/main/resources/catalog/locales/en-core.json @@ -247,6 +247,8 @@ "selectAll": "All", "selectNone": "None", "send": "Send", + "associated": "Associated resources", + "versions": "Versions", "startVersioning": "Start Versioning", "startVersioning-help": "Save version history (beta)", "setMetadataGroup": "Change metadata group", diff --git a/web-ui/src/main/resources/catalog/style/gn_icons.less b/web-ui/src/main/resources/catalog/style/gn_icons.less index 2fd57538a1c1..76a0be2f7a4e 100644 --- a/web-ui/src/main/resources/catalog/style/gn_icons.less +++ b/web-ui/src/main/resources/catalog/style/gn_icons.less @@ -377,6 +377,7 @@ .gn-icon-association-dependency:before { content: @fa-var-diagram-next; } +.gn-icon-association-versions:before, .gn-icon-association-revisionOf:before { content: @fa-var-clock-rotate-left; } diff --git a/web-ui/src/main/resources/catalog/views/default/templates/recordView/lineage.html b/web-ui/src/main/resources/catalog/views/default/templates/recordView/lineage.html index 23fbd5843165..8f655e018619 100644 --- a/web-ui/src/main/resources/catalog/views/default/templates/recordView/lineage.html +++ b/web-ui/src/main/resources/catalog/views/default/templates/recordView/lineage.html @@ -20,3 +20,11 @@

supplementalInformation

data-ng-bind-html="mdView.current.record.supplementalInformation | linky | newlines" >

+ +