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).

+## 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"
>
+
+