+
+{% endif %}
+{% endif %}
+{%- endblock record_details -%}
+
+{%- block javascript -%}
+{{ super() }}
+{# Only load JS if we actually rendered the linked records section #}
+{% if record.metadata.related_identifiers %}
+{%- set linkedRecordsQuery = record | get_linked_records_search_query %}
+{% if linkedRecordsQuery %}
+{{ webpack['cds-rdm-linked-records.js'] }}
+{% endif %}
+{% endif %}
+{%- endblock javascript -%}
\ No newline at end of file
diff --git a/site/cds_rdm/views.py b/site/cds_rdm/views.py
index 6ebe4c03..9eec2cd8 100644
--- a/site/cds_rdm/views.py
+++ b/site/cds_rdm/views.py
@@ -15,6 +15,8 @@
from invenio_communities import current_communities
from invenio_i18n import _
+from .schemes import legacy_cds_pattern
+
blueprint = Blueprint("cds-rdm_ext", __name__)
@@ -60,3 +62,62 @@ def inspire_link_render(record):
)
)
return ret
+
+
+def get_linked_records_search_query(record):
+ """Build search query for linked records.
+
+ Returns a search query string to find:
+ 1. Records that this record references in related_identifiers (scheme="cds")
+ - For legacy numeric recids: searches both id and metadata.identifiers
+ - For new alphanumeric PIDs: searches only id
+ 2. Records that reference this record in their related_identifiers (scheme="cds")
+
+ This handles CDS migration where old numeric recids are stored in
+ metadata.identifiers.identifier when records get new PIDs.
+ """
+ # Get CDS identifiers from related_identifiers
+ related_identifiers = record.data["metadata"].get("related_identifiers", [])
+ cds_related_ids = [
+ rel_id.get("identifier")
+ for rel_id in related_identifiers
+ if rel_id.get("scheme") == "cds" and rel_id.get("identifier")
+ ]
+
+ # Build query parts
+ query_parts = []
+
+ # Part 1: Records that this record references (forward)
+ # Search by record id using the CDS identifier
+ for cds_id in cds_related_ids:
+ if legacy_cds_pattern.match(cds_id):
+ # Old numeric recid: Search both by id AND in metadata.identifiers
+ # This handles both non-migrated records (where id = recid)
+ # and migrated records (where recid is stored in identifiers)
+ # Must filter by scheme:cds to avoid matching other identifier types
+ query_parts.append(
+ f'(id:"{cds_id}" OR '
+ f'(metadata.identifiers.scheme:cds AND metadata.identifiers.identifier:"{cds_id}"))'
+ )
+ else:
+ # New alphanumeric PID: Search only by id (current behavior)
+ query_parts.append(f'id:"{cds_id}"')
+
+ # Part 2: Records that reference this record (reverse)
+ # Find records that have this record's CDS PIDs in their related_identifiers
+ record_id = record.data.get("id")
+ query_parts.append(
+ "(metadata.related_identifiers.scheme:cds AND "
+ f'metadata.related_identifiers.identifier:"{record_id}")'
+ )
+
+ if not query_parts:
+ return None
+
+ # Combine all query parts with OR
+ combined_query = " OR ".join(query_parts)
+
+ # Exclude the current record and only show published records
+ final_query = f'({combined_query}) AND is_published:true NOT id:"{record_id}"'
+
+ return final_query
diff --git a/site/cds_rdm/webpack.py b/site/cds_rdm/webpack.py
index 97673b2d..1d880880 100644
--- a/site/cds_rdm/webpack.py
+++ b/site/cds_rdm/webpack.py
@@ -23,6 +23,7 @@
dependencies={
"three": "^0.182.0",
"three-addons": "^1.2.0",
+ "cds-rdm-linked-records": "./js/cds_rdm/linked-records/index.js",
},
),
},
diff --git a/site/tests/test_views.py b/site/tests/test_views.py
new file mode 100644
index 00000000..ea8c736c
--- /dev/null
+++ b/site/tests/test_views.py
@@ -0,0 +1,261 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright (C) 2026 CERN.
+#
+# CDS-RDM is free software; you can redistribute it and/or modify it under
+# the terms of the GPL-2.0 License; see LICENSE file for more details.
+
+"""Views tests."""
+import pytest
+
+from cds_rdm.views import get_linked_records_search_query
+
+
+class MockRecord:
+ """Mock record object for testing."""
+
+ def __init__(self, record_data):
+ """Initialize mock record."""
+ self.data = record_data
+
+
+class TestGetLinkedRecordsSearchQuery:
+ """Test suite for get_linked_records_search_query function."""
+
+ def test_with_legacy_numeric_cds_ids(self):
+ """Test with legacy numeric CDS identifiers."""
+ record = MockRecord({
+ "id": "abc12-def34",
+ "metadata": {
+ "related_identifiers": [
+ {"scheme": "cds", "identifier": "12345"},
+ {"scheme": "cds", "identifier": "67890"},
+ ]
+ }
+ })
+
+ query = get_linked_records_search_query(record)
+
+ # Should search both by id and in metadata.identifiers for legacy IDs
+ assert 'id:"12345"' in query
+ assert 'metadata.identifiers.scheme:cds AND metadata.identifiers.identifier:"12345"' in query
+ assert 'id:"67890"' in query
+ assert 'metadata.identifiers.scheme:cds AND metadata.identifiers.identifier:"67890"' in query
+
+ # Should include reverse lookup
+ assert 'metadata.related_identifiers.scheme:cds AND metadata.related_identifiers.identifier:"abc12-def34"' in query
+
+ # Should exclude current record and only show published
+ assert 'is_published:true' in query
+ assert 'NOT id:"abc12-def34"' in query
+
+ def test_with_new_alphanumeric_pids(self):
+ """Test with new alphanumeric CDS PIDs."""
+ record = MockRecord({
+ "id": "abc12-def34",
+ "metadata": {
+ "related_identifiers": [
+ {"scheme": "cds", "identifier": "xyz98-qrs76"},
+ {"scheme": "cds", "identifier": "mnp43-jkl21"},
+ ]
+ }
+ })
+
+ query = get_linked_records_search_query(record)
+
+ # New PIDs should only search by id (not in metadata.identifiers)
+ assert 'id:"xyz98-qrs76"' in query
+ assert 'metadata.identifiers.identifier:"xyz98-qrs76"' not in query
+ assert 'id:"mnp43-jkl21"' in query
+ assert 'metadata.identifiers.identifier:"mnp43-jkl21"' not in query
+
+ # Should include reverse lookup
+ assert 'metadata.related_identifiers.scheme:cds AND metadata.related_identifiers.identifier:"abc12-def34"' in query
+
+ def test_with_mixed_legacy_and_new_ids(self):
+ """Test with both legacy numeric and new alphanumeric identifiers."""
+ record = MockRecord({
+ "id": "abc12-def34",
+ "metadata": {
+ "related_identifiers": [
+ {"scheme": "cds", "identifier": "12345"}, # legacy
+ {"scheme": "cds", "identifier": "xyz98-qrs76"}, # new
+ ]
+ }
+ })
+
+ query = get_linked_records_search_query(record)
+
+ # Legacy should search both ways
+ assert 'id:"12345"' in query
+ assert 'metadata.identifiers.scheme:cds AND metadata.identifiers.identifier:"12345"' in query
+
+ # New should only search by id
+ assert 'id:"xyz98-qrs76"' in query
+ assert 'metadata.identifiers.identifier:"xyz98-qrs76"' not in query
+
+ def test_with_non_cds_identifiers(self):
+ """Test that non-CDS identifiers are ignored."""
+ record = MockRecord({
+ "id": "abc12-def34",
+ "metadata": {
+ "related_identifiers": [
+ {"scheme": "doi", "identifier": "10.1234/foo"},
+ {"scheme": "inspire", "identifier": "12345"},
+ {"scheme": "cds", "identifier": "67890"},
+ ]
+ }
+ })
+
+ query = get_linked_records_search_query(record)
+
+ # Should only include CDS identifier
+ assert '67890' in query
+ assert '10.1234/foo' not in query
+ assert 'inspire' not in query
+ assert 'doi' not in query
+
+ def test_with_no_related_identifiers(self):
+ """Test with record that has no related_identifiers."""
+ record = MockRecord({
+ "id": "abc12-def34",
+ "metadata": {}
+ })
+
+ query = get_linked_records_search_query(record)
+
+ # Should still include reverse lookup
+ assert 'metadata.related_identifiers.scheme:cds AND metadata.related_identifiers.identifier:"abc12-def34"' in query
+ assert 'is_published:true' in query
+ assert 'NOT id:"abc12-def34"' in query
+
+ def test_with_empty_related_identifiers(self):
+ """Test with empty related_identifiers array."""
+ record = MockRecord({
+ "id": "abc12-def34",
+ "metadata": {
+ "related_identifiers": []
+ }
+ })
+
+ query = get_linked_records_search_query(record)
+
+ # Should still include reverse lookup
+ assert 'metadata.related_identifiers.scheme:cds AND metadata.related_identifiers.identifier:"abc12-def34"' in query
+ assert 'is_published:true' in query
+
+ def test_with_missing_identifier_field(self):
+ """Test with related_identifiers that have missing identifier field."""
+ record = MockRecord({
+ "id": "abc12-def34",
+ "metadata": {
+ "related_identifiers": [
+ {"scheme": "cds"}, # missing identifier
+ {"scheme": "cds", "identifier": "12345"},
+ ]
+ }
+ })
+
+ query = get_linked_records_search_query(record)
+
+ # Should only include the valid identifier
+ assert '12345' in query
+
+ def test_query_uses_or_operator(self):
+ """Test that multiple identifiers are combined with OR."""
+ record = MockRecord({
+ "id": "abc12-def34",
+ "metadata": {
+ "related_identifiers": [
+ {"scheme": "cds", "identifier": "12345"},
+ {"scheme": "cds", "identifier": "67890"},
+ ]
+ }
+ })
+
+ query = get_linked_records_search_query(record)
+
+ # Should use OR to combine query parts
+ assert ' OR ' in query
+
+ def test_query_excludes_current_record(self):
+ """Test that the current record is excluded from results."""
+ record = MockRecord({
+ "id": "abc12-def34",
+ "metadata": {
+ "related_identifiers": [
+ {"scheme": "cds", "identifier": "12345"},
+ ]
+ }
+ })
+
+ query = get_linked_records_search_query(record)
+
+ # Should exclude the current record
+ assert 'NOT id:"abc12-def34"' in query
+
+ def test_query_filters_published_only(self):
+ """Test that query filters for published records only."""
+ record = MockRecord({
+ "id": "abc12-def34",
+ "metadata": {
+ "related_identifiers": [
+ {"scheme": "cds", "identifier": "12345"},
+ ]
+ }
+ })
+
+ query = get_linked_records_search_query(record)
+
+ # Should only include published records
+ assert 'is_published:true' in query
+
+ def test_reverse_lookup_always_included(self):
+ """Test that reverse lookup is always included in the query."""
+ # Test with related identifiers
+ record_with_ids = MockRecord({
+ "id": "abc12-def34",
+ "metadata": {
+ "related_identifiers": [
+ {"scheme": "cds", "identifier": "12345"},
+ ]
+ }
+ })
+
+ query = get_linked_records_search_query(record_with_ids)
+ assert 'metadata.related_identifiers.scheme:cds AND metadata.related_identifiers.identifier:"abc12-def34"' in query
+
+ # Test without related identifiers
+ record_no_ids = MockRecord({
+ "id": "xyz98-qrs76",
+ "metadata": {}
+ })
+
+ query = get_linked_records_search_query(record_no_ids)
+ assert 'metadata.related_identifiers.scheme:cds AND metadata.related_identifiers.identifier:"xyz98-qrs76"' in query
+
+ def test_legacy_id_pattern_matching(self):
+ """Test that only fully numeric IDs are treated as legacy."""
+ record = MockRecord({
+ "id": "abc12-def34",
+ "metadata": {
+ "related_identifiers": [
+ {"scheme": "cds", "identifier": "12345"}, # legacy
+ {"scheme": "cds", "identifier": "abc123"}, # not legacy
+ {"scheme": "cds", "identifier": "123abc"}, # not legacy
+ {"scheme": "cds", "identifier": "98765"}, # legacy
+ ]
+ }
+ })
+
+ query = get_linked_records_search_query(record)
+
+ # Numeric IDs should search both ways
+ assert 'metadata.identifiers.identifier:"12345"' in query
+ assert 'metadata.identifiers.identifier:"98765"' in query
+
+ # Alphanumeric should only search by id
+ assert 'metadata.identifiers.identifier:"abc123"' not in query
+ assert 'metadata.identifiers.identifier:"123abc"' not in query
+ assert 'id:"abc123"' in query
+ assert 'id:"123abc"' in query