From c327cf1be83bdf9385579f861201ab6c2a5d2fac Mon Sep 17 00:00:00 2001 From: tombch Date: Mon, 27 Apr 2026 16:30:39 +0200 Subject: [PATCH 01/55] Initial backend endpoint for returning crossref cited by information directly --- .../loculus/backend/api/SeqSetCitations.kt | 13 +++++ .../loculus/backend/config/SecurityConfig.kt | 1 + .../controller/SeqSetCitationsController.kt | 6 +++ .../service/crossref/CrossRefService.kt | 47 +++++++++++++++++++ .../SeqSetCitationsDatabaseService.kt | 6 +++ 5 files changed, 73 insertions(+) diff --git a/backend/src/main/kotlin/org/loculus/backend/api/SeqSetCitations.kt b/backend/src/main/kotlin/org/loculus/backend/api/SeqSetCitations.kt index 86a5b66fb9..29173ffc89 100644 --- a/backend/src/main/kotlin/org/loculus/backend/api/SeqSetCitations.kt +++ b/backend/src/main/kotlin/org/loculus/backend/api/SeqSetCitations.kt @@ -57,6 +57,19 @@ data class SeqSet( val seqSetDOI: String?, ) +data class SeqSetCitationContributor( + val givenName: String, + val surname: String, +) + +data class SeqSetCitation( + val seqSetDOI: String, + val citationDOI: String, + val title: String, + val year: String, + val contributors: List +) + data class ResponseSeqSet(val seqSetId: String, val seqSetVersion: Long) data class CitedBy( diff --git a/backend/src/main/kotlin/org/loculus/backend/config/SecurityConfig.kt b/backend/src/main/kotlin/org/loculus/backend/config/SecurityConfig.kt index 8017e9e188..16197fc5ef 100644 --- a/backend/src/main/kotlin/org/loculus/backend/config/SecurityConfig.kt +++ b/backend/src/main/kotlin/org/loculus/backend/config/SecurityConfig.kt @@ -55,6 +55,7 @@ class SecurityConfig { "/data-use-terms/*", "/get-seqset", "/get-seqset-records", + "/get-seqset-citations", "/get-seqset-cited-by-publication", "/get-author", "/*/get-released-data", diff --git a/backend/src/main/kotlin/org/loculus/backend/controller/SeqSetCitationsController.kt b/backend/src/main/kotlin/org/loculus/backend/controller/SeqSetCitationsController.kt index 7b6ebd35de..5cab11bc5c 100644 --- a/backend/src/main/kotlin/org/loculus/backend/controller/SeqSetCitationsController.kt +++ b/backend/src/main/kotlin/org/loculus/backend/controller/SeqSetCitationsController.kt @@ -6,6 +6,7 @@ import org.loculus.backend.api.AuthorProfile import org.loculus.backend.api.CitedBy import org.loculus.backend.api.ResponseSeqSet import org.loculus.backend.api.SeqSet +import org.loculus.backend.api.SeqSetCitation import org.loculus.backend.api.SeqSetRecord import org.loculus.backend.api.SubmittedSeqSet import org.loculus.backend.api.SubmittedSeqSetRecord @@ -80,6 +81,11 @@ class SeqSetCitationsController( fun getSeqSetRecords(@RequestParam seqSetId: String, @RequestParam version: Long?): List = seqSetCitationsService.getSeqSetRecords(seqSetId, version) + @Operation(description = "Get citations for a SeqSet DOI") + @GetMapping("/get-seqset-citations") + fun getSeqSetCitations(@RequestParam seqSetDOI: String): List = + seqSetCitationsService.getSeqSetCitations(seqSetDOI) + @Operation(description = "Delete a SeqSet") @DeleteMapping("/delete-seqset") fun deleteSeqSet( diff --git a/backend/src/main/kotlin/org/loculus/backend/service/crossref/CrossRefService.kt b/backend/src/main/kotlin/org/loculus/backend/service/crossref/CrossRefService.kt index bd8c78c943..71cf39f066 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/crossref/CrossRefService.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/crossref/CrossRefService.kt @@ -2,6 +2,9 @@ package org.loculus.backend.service.crossref import mu.KotlinLogging import org.jsoup.Jsoup +import org.jsoup.parser.Parser +import org.loculus.backend.api.SeqSetCitation +import org.loculus.backend.api.SeqSetCitationContributor import org.redundent.kotlin.xml.PrintOptions import org.redundent.kotlin.xml.xml import org.springframework.boot.context.properties.ConfigurationProperties @@ -59,6 +62,50 @@ class CrossRefService(final val properties: CrossRefServiceProperties) { } } + fun parseCrossRefCitedByXML(citedByXML: String): List { + val doc = Jsoup.parse(citedByXML, "", Parser.xmlParser()) + + return doc.select("forward_link").mapNotNull { link -> + val cite = link.children().firstOrNull() ?: return@mapNotNull null + val contributors = cite.select("contributor").map { c -> + SeqSetCitationContributor( + givenName = c.selectFirst("given_name")?.text() ?: "", + surname = c.selectFirst("surname")?.text() ?: "", + ) + } + SeqSetCitation( + seqSetDOI = link.attr("doi"), + citationDOI = cite.selectFirst("doi")?.text() ?: "", + title = cite.selectFirst("title")?.text() ?: "", + year = cite.selectFirst("year")?.text() ?: "", + contributors = contributors, + ) + } + } + + fun getCrossRefCitedBy(doi: String): List { + checkIsActive() + + val endDate = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE) + val connection = URI( + properties.endpoint + "/servlet/getForwardLinks?usr=${properties.username}&pwd=${properties.password}&doi=${doi}&endDate=${endDate}&include_postedcontent=true" + ).toURL().openConnection() as HttpURLConnection + connection.requestMethod = "GET" + + val responseCode = connection.responseCode + if (responseCode == HttpURLConnection.HTTP_OK) { + val response = String(connection.inputStream.readAllBytes()) + connection.inputStream.close() + return try { + parseCrossRefCitedByXML(response) + } catch (e: Exception) { + throw RuntimeException("Failed to parse CrossRef citedBy response for DOI $doi", e) + } + } else { + throw RuntimeException("CrossRef citedBy request returned $responseCode") + } + } + fun generateCrossRefXML(entry: DoiEntry): String { checkIsActive() diff --git a/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCitationsDatabaseService.kt b/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCitationsDatabaseService.kt index bb52cd22b1..07c904f687 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCitationsDatabaseService.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCitationsDatabaseService.kt @@ -22,6 +22,7 @@ import org.loculus.backend.api.AuthorProfile import org.loculus.backend.api.CitedBy import org.loculus.backend.api.ResponseSeqSet import org.loculus.backend.api.SeqSet +import org.loculus.backend.api.SeqSetCitation import org.loculus.backend.api.SeqSetCitationsConstants import org.loculus.backend.api.SeqSetRecord import org.loculus.backend.api.Status.APPROVED_FOR_RELEASE @@ -252,6 +253,11 @@ class SeqSetCitationsDatabaseService( return selectedSeqSetRecords } + fun getSeqSetCitations(seqSetDOI: String): List { + log.info { "Get seqSet citations for seqSet DOI $seqSetDOI" } + return crossRefService.getCrossRefCitedBy(seqSetDOI) + } + fun getSeqSets(authenticatedUser: AuthenticatedUser): List { val username = authenticatedUser.username log.info { "Get seqSets for user $username" } From 5e669f6087b4e0d8f7711c4f716bb0040af69c29 Mon Sep 17 00:00:00 2001 From: tombch Date: Mon, 27 Apr 2026 18:37:28 +0200 Subject: [PATCH 02/55] Display citations in frontend --- .../SeqSetCitations/SeqSetCitationsList.tsx | 77 +++++++++++++++++++ .../SeqSetCitations/SeqSetItemActions.tsx | 13 ++++ .../components/Submission/DataUploadForm.tsx | 2 +- website/src/services/seqSetCitationApi.ts | 11 ++- website/src/types/seqSetCitation.ts | 16 ++++ 5 files changed, 117 insertions(+), 2 deletions(-) create mode 100644 website/src/components/SeqSetCitations/SeqSetCitationsList.tsx diff --git a/website/src/components/SeqSetCitations/SeqSetCitationsList.tsx b/website/src/components/SeqSetCitations/SeqSetCitationsList.tsx new file mode 100644 index 0000000000..7f4da0dcc1 --- /dev/null +++ b/website/src/components/SeqSetCitations/SeqSetCitationsList.tsx @@ -0,0 +1,77 @@ +import { type FC } from 'react'; + +import { seqSetCitationClientHooks } from '../../services/serviceHooks'; +import type { ClientConfig } from '../../types/runtimeConfig'; +import { type SeqSet, type SeqSetCitation } from '../../types/seqSetCitation'; + +interface SeqSetCitationsListProps { + clientConfig: ClientConfig; + seqSet: SeqSet; +} + +interface SeqSetCitationItemProps { + seqSetCitation: SeqSetCitation; +} + +const SeqSetCitationItem: FC = ({ seqSetCitation }) => { + return ( +
+
+ {seqSetCitation.contributors + .map((contributor) => `${contributor.givenName} ${contributor.surname}`) + .join(', ')} + . {seqSetCitation.title}, {seqSetCitation.year}. +
+ +
+ ); +}; + +export const SeqSetCitationsList: FC = ({ clientConfig, seqSet }) => { + const seqSetAccessionVersion = `${seqSet.seqSetId}.${seqSet.seqSetVersion}`; + + const { + isLoading: isSeqSetCitationsLoading, + error: isSeqSetCitationsError, + data: seqSetCitations, + } = seqSetCitationClientHooks(clientConfig).useGetSeqSetCitations( + { params: { seqSetDOI: seqSet.seqSetDOI! } }, + { enabled: !!seqSet.seqSetDOI }, + ); + + return ( +
+
+

Citations for {seqSetAccessionVersion}

+
+
+ {!seqSet.seqSetDOI ? ( + This SeqSet does not have a DOI, so no citation data is available. + ) : isSeqSetCitationsLoading ? ( + + ) : isSeqSetCitationsError ? ( + Failed to load citations. + ) : seqSetCitations.length > 0 ? ( +
    + {seqSetCitations.map((seqSetCitation) => ( +
  • + +
  • + ))} +
+ ) : ( + No citations found for this SeqSet. + )} +
+ ); +}; diff --git a/website/src/components/SeqSetCitations/SeqSetItemActions.tsx b/website/src/components/SeqSetCitations/SeqSetItemActions.tsx index 9d8692afdb..e7d6facd48 100644 --- a/website/src/components/SeqSetCitations/SeqSetItemActions.tsx +++ b/website/src/components/SeqSetCitations/SeqSetItemActions.tsx @@ -2,6 +2,7 @@ import { type FC, useState } from 'react'; import { toast } from 'react-toastify'; import { ExportSeqSet } from './ExportSeqSet'; +import { SeqSetCitationsList } from './SeqSetCitationsList.tsx'; import { SeqSetForm } from './SeqSetForm'; import { getClientLogger } from '../../clientLogger'; import { seqSetCitationClientHooks } from '../../services/serviceHooks'; @@ -16,6 +17,7 @@ import { withQueryProvider } from '../common/withQueryProvider.tsx'; import MdiDelete from '~icons/mdi/delete'; import MdiDownload from '~icons/mdi/download'; import MdiPencil from '~icons/mdi/pencil'; +import MdiViewListOutline from '~icons/mdi/view-list-outline'; const logger = getClientLogger('SeqSetItemActions'); @@ -43,6 +45,7 @@ const SeqSetItemActionsInner: FC = ({ const [editModalVisible, setEditModalVisible] = useState(false); const [exportModalVisible, setExportModalVisible] = useState(false); + const [citationsModalVisible, setCitationsModalVisible] = useState(false); const { mutate: deleteSeqSet } = useDeleteSeqSetAction( clientConfig, @@ -70,6 +73,13 @@ const SeqSetItemActionsInner: FC = ({ Export / Cite + {isAdminView ? (