diff --git a/backend/docs/db/schema.sql b/backend/docs/db/schema.sql index eb1f0cef44..1dc7aae8c3 100644 --- a/backend/docs/db/schema.sql +++ b/backend/docs/db/schema.sql @@ -4,8 +4,8 @@ \restrict dummy --- Dumped from database version 15.17 (Debian 15.17-1.pgdg13+1) --- Dumped by pg_dump version 16.13 (Debian 16.13-1.pgdg13+1) +-- Dumped from database version 15.18 (Debian 15.18-1.pgdg13+1) +-- Dumped by pg_dump version 16.14 (Debian 16.14-1.pgdg13+1) SET statement_timeout = 0; SET lock_timeout = 0; @@ -311,6 +311,44 @@ CREATE TABLE public.metadata_upload_aux_table ( ALTER TABLE public.metadata_upload_aux_table OWNER TO postgres; +-- +-- Name: seqset_citation_source; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.seqset_citation_source ( + citation_source_id bigint NOT NULL, + source_doi text NOT NULL, + origin text NOT NULL, + title text NOT NULL, + year integer NOT NULL, + contributors jsonb NOT NULL, + CONSTRAINT seqset_citation_source_origin_check CHECK ((origin = ANY (ARRAY['CROSSREF'::text, 'CURATED'::text]))) +); + + +ALTER TABLE public.seqset_citation_source OWNER TO postgres; + +-- +-- Name: seqset_citation_source_citation_source_id_seq; Type: SEQUENCE; Schema: public; Owner: postgres +-- + +CREATE SEQUENCE public.seqset_citation_source_citation_source_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER SEQUENCE public.seqset_citation_source_citation_source_id_seq OWNER TO postgres; + +-- +-- Name: seqset_citation_source_citation_source_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: postgres +-- + +ALTER SEQUENCE public.seqset_citation_source_citation_source_id_seq OWNED BY public.seqset_citation_source.citation_source_id; + + -- -- Name: seqset_id_sequence; Type: SEQUENCE; Schema: public; Owner: postgres -- @@ -360,6 +398,19 @@ ALTER SEQUENCE public.seqset_records_seqset_record_id_seq OWNER TO postgres; ALTER SEQUENCE public.seqset_records_seqset_record_id_seq OWNED BY public.seqset_records.seqset_record_id; +-- +-- Name: seqset_to_citation_source; Type: TABLE; Schema: public; Owner: postgres +-- + +CREATE TABLE public.seqset_to_citation_source ( + citation_source_id bigint NOT NULL, + seqset_id text NOT NULL, + seqset_version bigint NOT NULL +); + + +ALTER TABLE public.seqset_to_citation_source OWNER TO postgres; + -- -- Name: seqset_to_records; Type: TABLE; Schema: public; Owner: postgres -- @@ -581,6 +632,13 @@ ALTER TABLE ONLY public.audit_log ALTER COLUMN id SET DEFAULT nextval('public.au ALTER TABLE ONLY public.groups_table ALTER COLUMN group_id SET DEFAULT nextval('public.groups_table_group_id_seq'::regclass); +-- +-- Name: seqset_citation_source citation_source_id; Type: DEFAULT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.seqset_citation_source ALTER COLUMN citation_source_id SET DEFAULT nextval('public.seqset_citation_source_citation_source_id_seq'::regclass); + + -- -- Name: seqset_records seqset_record_id; Type: DEFAULT; Schema: public; Owner: postgres -- @@ -682,6 +740,22 @@ ALTER TABLE ONLY public.metadata_upload_aux_table ADD CONSTRAINT metadata_upload_aux_table_upload_id_accession_key UNIQUE (upload_id, accession); +-- +-- Name: seqset_citation_source seqset_citation_source_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.seqset_citation_source + ADD CONSTRAINT seqset_citation_source_pkey PRIMARY KEY (citation_source_id); + + +-- +-- Name: seqset_citation_source seqset_citation_source_source_doi_key; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.seqset_citation_source + ADD CONSTRAINT seqset_citation_source_source_doi_key UNIQUE (source_doi); + + -- -- Name: seqset_records seqset_records_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- @@ -690,6 +764,14 @@ ALTER TABLE ONLY public.seqset_records ADD CONSTRAINT seqset_records_pkey PRIMARY KEY (seqset_record_id); +-- +-- Name: seqset_to_citation_source seqset_to_citation_source_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.seqset_to_citation_source + ADD CONSTRAINT seqset_to_citation_source_pkey PRIMARY KEY (citation_source_id, seqset_id, seqset_version); + + -- -- Name: seqset_to_records seqset_to_records_pkey; Type: CONSTRAINT; Schema: public; Owner: postgres -- @@ -881,6 +963,22 @@ ALTER TABLE ONLY public.files ADD CONSTRAINT files_group_id_fkey FOREIGN KEY (group_id) REFERENCES public.groups_table(group_id); +-- +-- Name: seqset_to_citation_source foreign_key_citation_source; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.seqset_to_citation_source + ADD CONSTRAINT foreign_key_citation_source FOREIGN KEY (citation_source_id) REFERENCES public.seqset_citation_source(citation_source_id) ON DELETE CASCADE; + + +-- +-- Name: seqset_to_citation_source foreign_key_seqset; Type: FK CONSTRAINT; Schema: public; Owner: postgres +-- + +ALTER TABLE ONLY public.seqset_to_citation_source + ADD CONSTRAINT foreign_key_seqset FOREIGN KEY (seqset_id, seqset_version) REFERENCES public.seqsets(seqset_id, seqset_version) ON DELETE CASCADE; + + -- -- Name: seqset_to_records foreign_key_seqset_id; Type: FK CONSTRAINT; Schema: public; Owner: postgres -- 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..df5e546d6f 100644 --- a/backend/src/main/kotlin/org/loculus/backend/api/SeqSetCitations.kt +++ b/backend/src/main/kotlin/org/loculus/backend/api/SeqSetCitations.kt @@ -3,7 +3,6 @@ package org.loculus.backend.api import io.swagger.v3.oas.annotations.media.Schema import org.loculus.backend.utils.Accession import java.sql.Timestamp -import java.util.* data class SubmittedSeqSetRecord( @Schema( @@ -57,6 +56,24 @@ data class SeqSet( val seqSetDOI: String?, ) +data class CitationContributor(val givenName: String, val surname: String) + +enum class CitationOrigin { + CROSSREF, + CURATED, +} + +data class CitationSource( + val sourceDOI: String, + val title: String, + val year: Int, + val contributors: List, +) + +data class SeqSetCitationSource(val source: CitationSource, val seqSetDOIs: Set = emptySet()) + +data class SeqSetCitation(val source: CitationSource) + data class ResponseSeqSet(val seqSetId: String, val seqSetVersion: Long) data class CitedBy( 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..433c1f43b9 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 @@ -103,9 +104,9 @@ class SeqSetCitationsController( submissionDatabaseService.getApprovedUserAccessionVersions(authenticatedUser), ) - @Operation(description = "Get count of SeqSet cited by publications") + @Operation(description = "Get SeqSet citations from publications") @GetMapping("/get-seqset-cited-by-publication") - fun getSeqSetCitedByPublication(@RequestParam seqSetId: String, @RequestParam version: Long): CitedBy = + fun getSeqSetCitedByPublication(@RequestParam seqSetId: String, @RequestParam version: Long): List = seqSetCitationsService.getSeqSetCitedByPublication(seqSetId, version) @Operation(description = "Get an author") 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..5452d7f71d 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,11 +2,17 @@ package org.loculus.backend.service.crossref import mu.KotlinLogging import org.jsoup.Jsoup +import org.jsoup.parser.Parser +import org.loculus.backend.api.CitationContributor +import org.loculus.backend.api.CitationSource +import org.loculus.backend.api.SeqSetCitationSource +import org.loculus.backend.utils.DateProvider import org.redundent.kotlin.xml.PrintOptions import org.redundent.kotlin.xml.xml import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.stereotype.Service import java.io.DataOutputStream +import java.io.IOException import java.io.OutputStreamWriter import java.io.PrintWriter import java.net.HttpURLConnection @@ -40,7 +46,7 @@ data class DoiEntry( ) @Service -class CrossRefService(final val properties: CrossRefServiceProperties) { +class CrossRefService(private val properties: CrossRefServiceProperties, private val dateProvider: DateProvider) { val isActive = properties.endpoint != null && properties.username != null && properties.password != null && @@ -49,9 +55,10 @@ class CrossRefService(final val properties: CrossRefServiceProperties) { properties.email != null && properties.organization != null && properties.hostUrl != null - val dateTimeFormatterMM = DateTimeFormatter.ofPattern("MM") - val dateTimeFormatterdd = DateTimeFormatter.ofPattern("dd") - val dateTimeFormatteryyyy = DateTimeFormatter.ofPattern("yyyy") + val doiPrefix: String? = properties.doiPrefix + val dateTimeFormatterMM: DateTimeFormatter = DateTimeFormatter.ofPattern("MM") + val dateTimeFormatterdd: DateTimeFormatter = DateTimeFormatter.ofPattern("dd") + val dateTimeFormatteryyyy: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy") private fun checkIsActive() { if (!isActive) { @@ -59,6 +66,95 @@ class CrossRefService(final val properties: CrossRefServiceProperties) { } } + fun parseCrossRefCitedByXML(citedByXML: String): List { + val parser = Parser.xmlParser().setTrackErrors(1) + val doc = Jsoup.parse(citedByXML, "", parser) + + if (parser.errors.isNotEmpty()) { + throw IllegalStateException("Invalid XML: ${parser.errors}") + } + + val crossRefResult = doc.children().firstOrNull() + if (crossRefResult?.tagName() != "crossref_result") { + throw IllegalStateException("Invalid CrossRef root element: ${crossRefResult?.tagName()}") + } + + return crossRefResult.select("forward_link").map { forwardLink -> + val seqSetDOI = forwardLink.attr("doi").takeIf { it.isNotBlank() } + ?: throw IllegalStateException("CrossRef forward_link missing SeqSet DOI: $forwardLink") + + val citationElement = + forwardLink.children().firstOrNull() + ?: throw IllegalStateException( + "CrossRef forward_link has no citation element under SeqSet $seqSetDOI: $forwardLink", + ) + + val sourceDOI = citationElement.selectFirst("doi")?.text()?.takeIf { it.isNotBlank() } + ?: throw IllegalStateException( + "CrossRef citation source missing DOI for SeqSet $seqSetDOI: $citationElement", + ) + val title = citationElement.selectFirst("title")?.text()?.takeIf { it.isNotBlank() } + ?: throw IllegalStateException( + "CrossRef citation source missing title for SeqSet $seqSetDOI: $citationElement", + ) + val year = citationElement.selectFirst("year")?.text()?.toIntOrNull() + ?: throw IllegalStateException( + "CrossRef citation source missing or non-numeric year for SeqSet $seqSetDOI: $citationElement", + ) + val contributors = citationElement.select("contributor").mapNotNull { c -> + val givenName = c.selectFirst("given_name")?.text().orEmpty() + val surname = c.selectFirst("surname")?.text().orEmpty() + if (givenName.isEmpty() && surname.isEmpty()) { + null + } else { + CitationContributor(givenName, surname) + } + } + + SeqSetCitationSource( + source = CitationSource( + sourceDOI = sourceDOI, + title = title, + year = year, + contributors = contributors, + ), + seqSetDOIs = setOf(seqSetDOI), + ) + } + } + + fun getCrossRefCitedBy(doiPrefix: String): List { + checkIsActive() + + // End date is the current date at time of request + val endDate = dateProvider.getCurrentDate() + val connection = URI( + properties.endpoint + + "/servlet/getForwardLinks?usr=${properties.username}&pwd=${properties.password}&doi=$doiPrefix&endDate=$endDate&include_postedcontent=true", + ).toURL().openConnection() as HttpURLConnection + connection.connectTimeout = 10_000 + connection.readTimeout = 30_000 + connection.requestMethod = "GET" + + val response = try { + val responseCode = connection.responseCode + if (responseCode != HttpURLConnection.HTTP_OK) { + throw RuntimeException("CrossRef citedBy request returned $responseCode") + } + connection.inputStream.use { String(it.readAllBytes()) } + } catch (e: IOException) { + throw RuntimeException("CrossRef citedBy request failed for DOI $doiPrefix", e) + } finally { + connection.disconnect() + } + + return try { + parseCrossRefCitedByXML(response) + } catch (e: Exception) { + throw RuntimeException("Failed to parse CrossRef citedBy response for DOI $doiPrefix", e) + } + } + 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..6c89f41631 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 @@ -7,10 +7,13 @@ import kotlinx.datetime.toLocalDateTime import mu.KotlinLogging import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.JoinType +import org.jetbrains.exposed.sql.SortOrder import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq import org.jetbrains.exposed.sql.alias import org.jetbrains.exposed.sql.and import org.jetbrains.exposed.sql.andWhere +import org.jetbrains.exposed.sql.batchInsert +import org.jetbrains.exposed.sql.batchUpsert import org.jetbrains.exposed.sql.deleteWhere import org.jetbrains.exposed.sql.insert import org.jetbrains.exposed.sql.max @@ -19,9 +22,13 @@ import org.jetbrains.exposed.sql.update import org.keycloak.representations.idm.UserRepresentation import org.loculus.backend.api.AccessionVersion import org.loculus.backend.api.AuthorProfile +import org.loculus.backend.api.CitationOrigin +import org.loculus.backend.api.CitationSource 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.SeqSetCitationSource import org.loculus.backend.api.SeqSetCitationsConstants import org.loculus.backend.api.SeqSetRecord import org.loculus.backend.api.Status.APPROVED_FOR_RELEASE @@ -43,6 +50,10 @@ import javax.sql.DataSource private val log = KotlinLogging.logger { } +data class CitationSourcesUpdateResult(val updatedCitationSourceDOIs: Set) + +data class SeqSetToCitationSourceEntry(val citationSourceId: Long, val seqSetId: String, val seqSetVersion: Long) + @Service @Transactional class SeqSetCitationsDatabaseService( @@ -333,7 +344,7 @@ class SeqSetCitationsDatabaseService( validateCreateSeqSetDOI(username, seqSetId, version) - val doiPrefix = crossRefService.properties.doiPrefix ?: "no-prefix-configured" + val doiPrefix = crossRefService.doiPrefix ?: "no-prefix-configured" val seqSetDOI = "$doiPrefix/$seqSetId.$version" val seqSet = getSeqSet(seqSetId, version) @@ -367,6 +378,55 @@ class SeqSetCitationsDatabaseService( ) } + fun updateCitationSourcesFromCrossRef(citationSources: Set): CitationSourcesUpdateResult { + // Map of database seqSet DOIs to their ID and version + val doiToSeqSet = SeqSetsTable + .select(SeqSetsTable.seqSetId, SeqSetsTable.seqSetVersion, SeqSetsTable.seqSetDOI) + .where { SeqSetsTable.seqSetDOI.isNotNull() } + .associate { it[SeqSetsTable.seqSetDOI]!! to (it[SeqSetsTable.seqSetId] to it[SeqSetsTable.seqSetVersion]) } + + // Citation sources matched with at least one seqSet present in the database + val matchedSources = citationSources.filter { source -> + source.seqSetDOIs.any { it in doiToSeqSet } + } + + // Map of matched sources to their seqSet DOIs + val matchedSourceToSeqSetDOIs = matchedSources.associate { it.source.sourceDOI to it.seqSetDOIs } + + // Upsert matched citation sources based on their DOI + // Build entries for the join table from the returned source DOIs + primary keys + val joinEntries = SeqSetCitationSourceTable + .batchUpsert(matchedSources, SeqSetCitationSourceTable.sourceDOI) { + this[SeqSetCitationSourceTable.sourceDOI] = it.source.sourceDOI + this[SeqSetCitationSourceTable.origin] = CitationOrigin.CROSSREF + this[SeqSetCitationSourceTable.title] = it.source.title + this[SeqSetCitationSourceTable.year] = it.source.year + this[SeqSetCitationSourceTable.contributors] = it.source.contributors + } + .flatMap { result -> + val citationSourceId = result[SeqSetCitationSourceTable.citationSourceId] + val seqSetDOIs = matchedSourceToSeqSetDOIs.getValue(result[SeqSetCitationSourceTable.sourceDOI]) + seqSetDOIs.mapNotNull { doi -> + doiToSeqSet[doi]?.let { (seqSetId, version) -> + SeqSetToCitationSourceEntry(citationSourceId, seqSetId, version) + } + } + } + + // Insert entries in the join table + SeqSetToCitationSourceTable.batchInsert( + joinEntries, + ignore = true, + ) { (citationSourceId, seqSetId, seqSetVersion) -> + this[SeqSetToCitationSourceTable.citationSourceId] = citationSourceId + this[SeqSetToCitationSourceTable.seqSetId] = seqSetId + this[SeqSetToCitationSourceTable.seqSetVersion] = seqSetVersion + } + + // Return upserted citation sources + return CitationSourcesUpdateResult(matchedSources.mapTo(mutableSetOf()) { it.source.sourceDOI }) + } + fun getUserCitedBySeqSet(accessionVersions: List): CitedBy { log.info { "Get user cited by seqSet" } @@ -434,18 +494,33 @@ class SeqSetCitationsDatabaseService( return citedBy } - fun getSeqSetCitedByPublication(seqSetId: String, version: Long): CitedBy { - // TODO: implement after registering to CrossRef API - // https://github.com/orgs/loculus-project/projects/3/views/1?pane=issue&itemId=50282833 - + fun getSeqSetCitedByPublication(seqSetId: String, version: Long): List { log.info { "Get seqSet cited by publication for seqSetId $seqSetId, version $version" } - val citedBy = CitedBy( - mutableListOf(), - mutableListOf(), - ) + val seqSet = ( + SeqSetsTable + .select(SeqSetsTable.seqSetId, SeqSetsTable.seqSetVersion) + .where { (SeqSetsTable.seqSetId eq seqSetId) and (SeqSetsTable.seqSetVersion eq version) } + .singleOrNull() + ?: throw NotFoundException("SeqSet $seqSetId, version $version does not exist") + ) - return citedBy + return SeqSetToCitationSourceTable.innerJoin( + SeqSetCitationSourceTable, + ).selectAll() + .where { + (SeqSetToCitationSourceTable.seqSetId eq seqSet[SeqSetsTable.seqSetId]) and + (SeqSetToCitationSourceTable.seqSetVersion eq seqSet[SeqSetsTable.seqSetVersion]) + }.orderBy(SeqSetCitationSourceTable.year to SortOrder.DESC).map { + SeqSetCitation( + source = CitationSource( + it[SeqSetCitationSourceTable.sourceDOI], + it[SeqSetCitationSourceTable.title], + it[SeqSetCitationSourceTable.year], + it[SeqSetCitationSourceTable.contributors], + ), + ) + } } fun validateSeqSetRecords(seqSetRecords: List) { diff --git a/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCrossRefCitationsTask.kt b/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCrossRefCitationsTask.kt new file mode 100644 index 0000000000..3aca2f97b9 --- /dev/null +++ b/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCrossRefCitationsTask.kt @@ -0,0 +1,78 @@ +package org.loculus.backend.service.seqsetcitations + +import org.loculus.backend.api.SeqSetCitationSource +import org.loculus.backend.config.BackendSpringProperty +import org.loculus.backend.config.ENABLE_SEQSETS_TRUE_VALUE +import org.loculus.backend.service.crossref.CrossRefService +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.scheduling.annotation.Scheduled +import org.springframework.stereotype.Component + +private val log = mu.KotlinLogging.logger {} + +internal fun mergeCitationSources(citationSources: List): Set { + val mergedSources = mutableMapOf() + + for (citationSource in citationSources) { + val existingSource = mergedSources[citationSource.source.sourceDOI] + if (existingSource != null && + existingSource.source != citationSource.source + ) { + log.warn { + "Conflicting CrossRef metadata for citation source ${citationSource.source.sourceDOI} (keeping latest): $existingSource and $citationSource" + } + } + mergedSources[citationSource.source.sourceDOI] = citationSource.copy( + seqSetDOIs = existingSource?.seqSetDOIs.orEmpty() + citationSource.seqSetDOIs, + ) + } + return mergedSources.values.toSet() +} + +@Component +@ConditionalOnProperty(BackendSpringProperty.ENABLE_SEQSETS, havingValue = ENABLE_SEQSETS_TRUE_VALUE) +class SeqSetCrossRefCitationsTask( + private val crossRefService: CrossRefService, + private val seqSetCitationsDatabaseService: SeqSetCitationsDatabaseService, +) { + /** + * Runs every six hours, with an initial delay of one minute. + * Adds citation sources from CrossRef, and connects to SeqSets via their DOI. + */ + @Scheduled( + initialDelay = 1, + fixedDelay = 360, + timeUnit = java.util.concurrent.TimeUnit.MINUTES, + ) + fun task() { + log.info { "Updating SeqSet CrossRef citations..." } + + if (!crossRefService.isActive) { + log.info { "CrossRef service is not active, skipping SeqSet citation update." } + return + } + + val doiPrefix = crossRefService.doiPrefix + if (doiPrefix.isNullOrBlank()) { + log.info { "CrossRef service has no DOI prefix, skipping SeqSet citation update." } + return + } + + log.info { "Fetching CrossRef citations for DOI prefix: $doiPrefix" } + val citationSources = mergeCitationSources(crossRefService.getCrossRefCitedBy(doiPrefix)) + val seqSetDOIs = citationSources.flatMap { it.seqSetDOIs }.toSet() + log.info { + "Fetched ${citationSources.size} citation source(s) from CrossRef covering ${seqSetDOIs.size} SeqSet DOI(s)." + } + if (citationSources.isEmpty()) return + + val updateResult = seqSetCitationsDatabaseService.updateCitationSourcesFromCrossRef(citationSources) + if (updateResult.updatedCitationSourceDOIs.isNotEmpty()) { + log.info { "Updated ${updateResult.updatedCitationSourceDOIs.size} citation source(s)." } + } + val skippedCitationSources = citationSources.size - updateResult.updatedCitationSourceDOIs.size + if (skippedCitationSources > 0) { + log.warn { "Skipped $skippedCitationSources citation source(s) with no matching SeqSet." } + } + } +} diff --git a/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetsTables.kt b/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetsTables.kt index 8d1035fdbb..90a1ba68bd 100644 --- a/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetsTables.kt +++ b/backend/src/main/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetsTables.kt @@ -2,13 +2,16 @@ package org.loculus.backend.service.seqsetcitations import org.jetbrains.exposed.sql.Table import org.jetbrains.exposed.sql.kotlin.datetime.datetime +import org.loculus.backend.api.CitationContributor +import org.loculus.backend.api.CitationOrigin +import org.loculus.backend.service.jacksonSerializableJsonb object SeqSetsTable : Table("seqsets") { val seqSetId = varchar("seqset_id", 255) val seqSetVersion = long("seqset_version") val name = varchar("name", 255) val description = varchar("description", 255) - val seqSetDOI = varchar("seqset_doi", 255) + val seqSetDOI = varchar("seqset_doi", 255).nullable() val createdAt = datetime("created_at") val createdBy = varchar("created_by", 255) override val primaryKey = PrimaryKey(seqSetId, seqSetVersion) @@ -28,3 +31,25 @@ object SeqSetToRecordsTable : Table("seqset_to_records") { val seqSetVersion = long("seqset_version") references SeqSetsTable.seqSetVersion override val primaryKey = PrimaryKey(seqSetRecordId, seqSetId, seqSetVersion) } + +object SeqSetCitationSourceTable : Table("seqset_citation_source") { + val citationSourceId = long("citation_source_id").autoIncrement() + val sourceDOI = text("source_doi").uniqueIndex() + val origin = customEnumeration( + name = "origin", + sql = "text", + fromDb = { CitationOrigin.valueOf(it as String) }, + toDb = { it.name }, + ) + val title = text("title") + val year = integer("year") + val contributors = jacksonSerializableJsonb>("contributors") + override val primaryKey = PrimaryKey(citationSourceId) +} + +object SeqSetToCitationSourceTable : Table("seqset_to_citation_source") { + val citationSourceId = long("citation_source_id") references SeqSetCitationSourceTable.citationSourceId + val seqSetId = text("seqset_id") references SeqSetsTable.seqSetId + val seqSetVersion = long("seqset_version") references SeqSetsTable.seqSetVersion + override val primaryKey = PrimaryKey(citationSourceId, seqSetId, seqSetVersion) +} diff --git a/backend/src/main/resources/db/migration/V1.28__add_seqset_citations_table.sql b/backend/src/main/resources/db/migration/V1.28__add_seqset_citations_table.sql new file mode 100644 index 0000000000..4109c87a83 --- /dev/null +++ b/backend/src/main/resources/db/migration/V1.28__add_seqset_citations_table.sql @@ -0,0 +1,26 @@ +create table seqset_citation_source ( + citation_source_id bigserial, + source_doi text not null unique, + origin text not null check (origin in ('CROSSREF', 'CURATED')), + title text not null, + year integer not null, + contributors jsonb not null, + + primary key (citation_source_id) +); + +create table seqset_to_citation_source ( + citation_source_id bigint not null, + seqset_id text not null, + seqset_version bigint not null, + + primary key (citation_source_id, seqset_id, seqset_version), + constraint foreign_key_citation_source + foreign key (citation_source_id) + references seqset_citation_source(citation_source_id) + on delete cascade, + constraint foreign_key_seqset + foreign key (seqset_id, seqset_version) + references seqsets(seqset_id, seqset_version) + on delete cascade +); diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/CitationEndpointsTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/CitationEndpointsTest.kt index 5a0aab26a8..a06afdbfd7 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/CitationEndpointsTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/CitationEndpointsTest.kt @@ -3,13 +3,20 @@ package org.loculus.backend.controller.seqsetcitations import com.jayway.jsonpath.JsonPath import com.ninjasquad.springmockk.MockkBean import io.mockk.every +import org.hamcrest.CoreMatchers.containsString import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.MethodSource import org.loculus.backend.api.AccessionVersion +import org.loculus.backend.api.CitationContributor +import org.loculus.backend.api.CitationSource +import org.loculus.backend.api.SeqSetCitationSource +import org.loculus.backend.controller.DEFAULT_USER_NAME import org.loculus.backend.controller.EndpointTest import org.loculus.backend.controller.expectUnauthorizedResponse +import org.loculus.backend.service.crossref.CrossRefService +import org.loculus.backend.service.seqsetcitations.SeqSetCrossRefCitationsTask import org.loculus.backend.service.submission.AccessionPreconditionValidator import org.loculus.backend.service.submission.SubmissionDatabaseService import org.springframework.beans.factory.annotation.Autowired @@ -20,16 +27,29 @@ import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPat import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status @EndpointTest -class CitationEndpointsTest(@Autowired private val client: SeqSetCitationsControllerClient) { +class CitationEndpointsTest( + @Autowired private val client: SeqSetCitationsControllerClient, + @Autowired private val seqSetCrossRefCitationsTask: SeqSetCrossRefCitationsTask, +) { @MockkBean lateinit var submissionDatabaseService: SubmissionDatabaseService @MockkBean lateinit var accessionPreconditionValidator: AccessionPreconditionValidator + @MockkBean + lateinit var crossRefService: CrossRefService + @BeforeEach fun setup() { + every { + submissionDatabaseService.getApprovedUserAccessionVersions( + match { it.username == DEFAULT_USER_NAME }, + ) + } returns listOf(AccessionVersion(MOCK_SEQ_ACCESSION, MOCK_SEQ_VERSION)) every { accessionPreconditionValidator.validate(any()) } returns Unit + every { crossRefService.doiPrefix } returns MOCK_DOI_PREFIX + every { crossRefService.isActive } returns false } @ParameterizedTest @@ -41,11 +61,7 @@ class CitationEndpointsTest(@Autowired private val client: SeqSetCitationsContro } @Test - fun `WHEN calling get user cited by seqSet of non-existing user THEN returns empty results`() { - every { - submissionDatabaseService.getApprovedUserAccessionVersions(any()) - } returns listOf() - + fun `WHEN calling get user cited by seqSet for user sequences not in any seqSet THEN returns empty results`() { client.getUserCitedBySeqSet() .andExpect(status().isOk) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) @@ -56,22 +72,39 @@ class CitationEndpointsTest(@Autowired private val client: SeqSetCitationsContro } @Test - fun `WHEN calling get seqSet cited by publication of non-existing seqSet THEN returns empty results`() { - client.getSeqSetCitedByPublication() + fun `WHEN calling get user cited by seqSet for user sequences in a seqSet THEN returns results`() { + val seqSetResult = client.createSeqSet() + .andExpect(status().isOk) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("\$.seqSetId").isString) + .andExpect(jsonPath("\$.seqSetVersion").value(1)) + .andReturn() + + client.getUserCitedBySeqSet() .andExpect(status().isOk) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) .andExpect(jsonPath("\$.years").isArray) - .andExpect(jsonPath("\$.years").isEmpty) + .andExpect(jsonPath("\$.years").isNotEmpty) + .andExpect(jsonPath("\$.years[0]").isNumber) .andExpect(jsonPath("\$.citations").isArray) - .andExpect(jsonPath("\$.citations").isEmpty) + .andExpect(jsonPath("\$.citations").isNotEmpty) + .andExpect(jsonPath("\$.citations[0]").value(1)) + + val seqSetId = JsonPath.read(seqSetResult.response.contentAsString, "$.seqSetId") + val seqSetVersion = JsonPath.read(seqSetResult.response.contentAsString, "$.seqSetVersion").toLong() + + client.deleteSeqSet(seqSetId, seqSetVersion) + .andExpect(status().isOk) } @Test - fun `WHEN calling get seqSet cited by publication of existing seqSet THEN returns results`() { - every { - submissionDatabaseService.getApprovedUserAccessionVersions(any()) - } returns listOf(AccessionVersion("mock-sequence-accession", 1L)) + fun `WHEN calling get seqSet cited by publication of non-existing seqSet THEN returns not found`() { + client.getSeqSetCitedByPublication() + .andExpect(status().isNotFound) + } + @Test + fun `WHEN calling get seqSet cited by publication for seqSet without crossref citations THEN returns empty list`() { val seqSetResult = client.createSeqSet() .andExpect(status().isOk) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) @@ -79,20 +112,135 @@ class CitationEndpointsTest(@Autowired private val client: SeqSetCitationsContro .andExpect(jsonPath("\$.seqSetVersion").value(1)) .andReturn() - client.getUserCitedBySeqSet() + val seqSetId = JsonPath.read(seqSetResult.response.contentAsString, "$.seqSetId") + val seqSetVersion = JsonPath.read(seqSetResult.response.contentAsString, "$.seqSetVersion").toLong() + + // Simulate running the crossref citations task + every { crossRefService.isActive } returns true + every { crossRefService.getCrossRefCitedBy(MOCK_DOI_PREFIX) } returns + emptyList() + seqSetCrossRefCitationsTask.task() + + client.getSeqSetCitedByPublication(seqSetId = seqSetId, seqSetVersion = seqSetVersion) .andExpect(status().isOk) .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) - .andExpect(jsonPath("\$.years").isArray) - .andExpect(jsonPath("\$.years").isNotEmpty) - .andExpect(jsonPath("\$.years[0]").isNumber) - .andExpect(jsonPath("\$.citations").isArray) - .andExpect(jsonPath("\$.citations").isNotEmpty) - .andExpect(jsonPath("\$.citations[0]").value(1)) + .andExpect(jsonPath("\$").isArray) + .andExpect(jsonPath("\$").isEmpty) + + client.deleteSeqSet(seqSetId, seqSetVersion) + .andExpect(status().isOk) + } + + @Test + fun `WHEN calling get seqSet cited by publication for seqSet with crossref citations THEN returns citations`() { + val seqSetResult = client.createSeqSet() + .andExpect(status().isOk) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("\$.seqSetId").isString) + .andExpect(jsonPath("\$.seqSetVersion").value(1)) + .andReturn() val seqSetId = JsonPath.read(seqSetResult.response.contentAsString, "$.seqSetId") + val seqSetVersion = JsonPath.read(seqSetResult.response.contentAsString, "$.seqSetVersion").toLong() + client.createSeqSetDOI(seqSetId = seqSetId, seqSetVersion = seqSetVersion) + .andExpect(status().isOk) + + // Simulate running the task and adding a citation source + val seqSetDOI = "${MOCK_DOI_PREFIX}/$seqSetId.$seqSetVersion" + val seqSetCitationSource = SeqSetCitationSource( + CitationSource( + sourceDOI = "10.5678/citing-paper", + title = "A paper citing the seqSet", + year = 2024, + contributors = listOf(CitationContributor(givenName = "Jane", surname = "Doe")), + ), + seqSetDOIs = setOf(seqSetDOI), + ) + every { crossRefService.isActive } returns true + every { crossRefService.getCrossRefCitedBy(MOCK_DOI_PREFIX) } returns + listOf(seqSetCitationSource) + seqSetCrossRefCitationsTask.task() + + client.getSeqSetCitedByPublication(seqSetId = seqSetId, seqSetVersion = seqSetVersion) + .andExpect(status().isOk) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("\$").isArray) + .andExpect(jsonPath("\$[0].source.sourceDOI").value(seqSetCitationSource.source.sourceDOI)) + .andExpect(jsonPath("\$[0].source.title").value(seqSetCitationSource.source.title)) + .andExpect(jsonPath("\$[0].source.year").value(seqSetCitationSource.source.year)) + .andExpect( + jsonPath( + "\$[0].source.contributors[0].givenName", + ).value(seqSetCitationSource.source.contributors[0].givenName), + ) + .andExpect( + jsonPath( + "\$[0].source.contributors[0].surname", + ).value(seqSetCitationSource.source.contributors[0].surname), + ) + + client.deleteSeqSet(seqSetId, seqSetVersion) + .andExpect(status().isUnprocessableEntity) + .andExpect(content().contentType(MediaType.APPLICATION_PROBLEM_JSON)) + .andExpect( + jsonPath( + "\$.detail", + containsString("SeqSet $seqSetId, version $seqSetVersion has a DOI and cannot be deleted"), + ), + ) + } + + @Test + fun `WHEN multiple crossref citation runs link the same citation source THEN all citations are recorded`() { + val seqSetAResult = client.createSeqSet().andExpect(status().isOk).andReturn() + val seqSetIdA = JsonPath.read(seqSetAResult.response.contentAsString, "$.seqSetId") + val seqSetVersionA = + JsonPath.read(seqSetAResult.response.contentAsString, "$.seqSetVersion").toLong() + client.createSeqSetDOI(seqSetId = seqSetIdA, seqSetVersion = seqSetVersionA).andExpect(status().isOk) + val seqSetDOIA = "${MOCK_DOI_PREFIX}/$seqSetIdA.$seqSetVersionA" + + val seqSetBResult = client.createSeqSet().andExpect(status().isOk).andReturn() + val seqSetIdB = JsonPath.read(seqSetBResult.response.contentAsString, "$.seqSetId") + val seqSetVersionB = + JsonPath.read(seqSetBResult.response.contentAsString, "$.seqSetVersion").toLong() + client.createSeqSetDOI(seqSetId = seqSetIdB, seqSetVersion = seqSetVersionB).andExpect(status().isOk) + val seqSetDOIB = "${MOCK_DOI_PREFIX}/$seqSetIdB.$seqSetVersionB" + + every { crossRefService.isActive } returns true + + val citationSource = SeqSetCitationSource( + CitationSource( + sourceDOI = "10.5678/citing-paper", + title = "A paper citing the seqSet", + year = 2024, + contributors = listOf(CitationContributor(givenName = "Jane", surname = "Doe")), + ), + seqSetDOIs = setOf(seqSetDOIA), + ) + + // Citation source cites only seqSet A + every { crossRefService.getCrossRefCitedBy(MOCK_DOI_PREFIX) } returns listOf(citationSource) + seqSetCrossRefCitationsTask.task() + + client.getSeqSetCitedByPublication(seqSetId = seqSetIdA, seqSetVersion = seqSetVersionA) + .andExpect(status().isOk) + .andExpect(jsonPath("\$.length()").value(1)) + .andExpect(jsonPath("\$[0].source.sourceDOI").value(citationSource.source.sourceDOI)) + + // Now, citation source also cites seqSet B + every { crossRefService.getCrossRefCitedBy(MOCK_DOI_PREFIX) } returns + listOf(citationSource.copy(seqSetDOIs = setOf(seqSetDOIA, seqSetDOIB))) + seqSetCrossRefCitationsTask.task() + + client.getSeqSetCitedByPublication(seqSetId = seqSetIdA, seqSetVersion = seqSetVersionA) + .andExpect(status().isOk) + .andExpect(jsonPath("\$.length()").value(1)) + .andExpect(jsonPath("\$[0].source.sourceDOI").value(citationSource.source.sourceDOI)) - client.deleteSeqSet(seqSetId) + client.getSeqSetCitedByPublication(seqSetId = seqSetIdB, seqSetVersion = seqSetVersionB) .andExpect(status().isOk) + .andExpect(jsonPath("\$.length()").value(1)) + .andExpect(jsonPath("\$[0].source.sourceDOI").value(citationSource.source.sourceDOI)) } companion object { diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/SeqSetCitationsControllerClient.kt b/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/SeqSetCitationsControllerClient.kt index 14296a2636..ca927822f3 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/SeqSetCitationsControllerClient.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/SeqSetCitationsControllerClient.kt @@ -14,12 +14,16 @@ const val MOCK_SEQSET_ID = "e302e770-e198-4a8f-9145-b536e3590656" const val MOCK_SEQSET_VERSION = 1L const val MOCK_SEQSET_NAME = "mock-seqset-name" const val MOCK_SEQSET_DESCRIPTION = "mock-seqset-description" -const val MOCK_SEQSET_RECORDS = "[{ \"accession\": \"mock-sequence-accession.1\", \"type\": \"loculus\" }]" +const val MOCK_SEQ_ACCESSION = "mock-sequence-accession" +const val MOCK_SEQ_VERSION = 1L +const val MOCK_ACCESSION_VERSION = "${MOCK_SEQ_ACCESSION}.${MOCK_SEQ_VERSION}" +const val MOCK_SEQSET_RECORDS = "[{ \"accession\": \"${MOCK_ACCESSION_VERSION}\", \"type\": \"loculus\" }]" const val MOCK_USERNAME = "testuser" const val MOCK_USER_EMAIL = "testuser@example.com" const val MOCK_USER_FIRST_NAME = "Test" const val MOCK_USER_LAST_NAME = "User" const val MOCK_USER_UNIVERSITY = "Test University" +const val MOCK_DOI_PREFIX = "10.1234" class SeqSetCitationsControllerClient(private val mockMvc: MockMvc) { @@ -90,7 +94,7 @@ class SeqSetCitationsControllerClient(private val mockMvc: MockMvc) { fun deleteSeqSet( seqSetId: String = MOCK_SEQSET_ID, - seqSetVersion: Long? = MOCK_SEQSET_VERSION, + seqSetVersion: Long = MOCK_SEQSET_VERSION, jwt: String? = jwtForDefaultUser, ): ResultActions = mockMvc.perform( delete("/delete-seqset") @@ -101,7 +105,7 @@ class SeqSetCitationsControllerClient(private val mockMvc: MockMvc) { fun createSeqSetDOI( seqSetId: String = MOCK_SEQSET_ID, - seqSetVersion: Long? = MOCK_SEQSET_VERSION, + seqSetVersion: Long = MOCK_SEQSET_VERSION, jwt: String? = jwtForDefaultUser, ): ResultActions = mockMvc.perform( post("/create-seqset-doi") diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/SeqSetEndpointsTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/SeqSetEndpointsTest.kt index 30952a1372..f09b6f73b8 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/SeqSetEndpointsTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/SeqSetEndpointsTest.kt @@ -24,13 +24,14 @@ class SeqSetEndpointsTest(@Autowired private val client: SeqSetCitationsControll @MockkBean lateinit var accessionPreconditionValidator: AccessionPreconditionValidator - @MockkBean(relaxed = true) + @MockkBean lateinit var crossRefService: CrossRefService @BeforeEach fun setup() { every { accessionPreconditionValidator.validate(any()) } returns Unit - every { crossRefService.postCrossRefXML(any()) } returns "SUCCESS" + every { crossRefService.doiPrefix } returns MOCK_DOI_PREFIX + every { crossRefService.isActive } returns false } @ParameterizedTest diff --git a/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/SeqSetValidationEndpointsTest.kt b/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/SeqSetValidationEndpointsTest.kt index 89cf2165f3..711d128e43 100644 --- a/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/SeqSetValidationEndpointsTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/controller/seqsetcitations/SeqSetValidationEndpointsTest.kt @@ -24,12 +24,13 @@ class SeqSetValidationEndpointsTest( @Autowired private val submissionConvenienceClient: SubmissionConvenienceClient, ) { - @MockkBean(relaxed = true) + @MockkBean lateinit var crossRefService: CrossRefService @BeforeEach fun setup() { - every { crossRefService.postCrossRefXML(any()) } returns "SUCCESS" + every { crossRefService.doiPrefix } returns MOCK_DOI_PREFIX + every { crossRefService.isActive } returns false } @Test diff --git a/backend/src/test/kotlin/org/loculus/backend/service/crossref/CrossRefServiceTest.kt b/backend/src/test/kotlin/org/loculus/backend/service/crossref/CrossRefServiceTest.kt index 2a51608cfc..93070eb6bf 100644 --- a/backend/src/test/kotlin/org/loculus/backend/service/crossref/CrossRefServiceTest.kt +++ b/backend/src/test/kotlin/org/loculus/backend/service/crossref/CrossRefServiceTest.kt @@ -1,8 +1,13 @@ package org.loculus.backend.service.crossref import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import org.loculus.backend.SpringBootTestWithoutDatabase +import org.loculus.backend.api.CitationContributor +import org.loculus.backend.api.CitationSource +import org.loculus.backend.api.SeqSetCitationSource import org.springframework.beans.factory.annotation.Autowired import java.time.Instant import java.time.LocalDate @@ -16,13 +21,286 @@ class CrossRefServiceTest(@Autowired private val crossRefService: CrossRefServic Instant.ofEpochSecond(1711411200), ZoneId.of("UTC"), ).toLocalDate() - private val doi = crossRefService.properties.doiPrefix + "/xxxx" + private val doi = crossRefService.doiPrefix + "/xxxx" private val crossRefXMLReference = """ $doiBatchID1711411200000Loculus Databasedois@loculus.orgLoculus DatabaseLoculus Databaseloculus.orgSeqSet: My test set03262024$doihttps://main.loculus.org/seqsets/LOC_SS_1.1 """.trimIndent() + @Test + fun `parseCrossRefCitedByXML returns citations from valid XML`() { + val xml = """ + + + + 10.5678/paper-1 + A citing paper + 2024 + + JaneDoe + JohnSmith + + + + + + 10.5678/paper-2 + Another citing paper + 2023 + + AliceJones + + + + + """.trimIndent() + + val result = crossRefService.parseCrossRefCitedByXML(xml) + + assertEquals( + listOf( + SeqSetCitationSource( + CitationSource( + sourceDOI = "10.5678/paper-1", + title = "A citing paper", + year = 2024, + contributors = listOf( + CitationContributor(givenName = "Jane", surname = "Doe"), + CitationContributor(givenName = "John", surname = "Smith"), + ), + ), + seqSetDOIs = setOf("10.1234/seqset-1"), + ), + SeqSetCitationSource( + CitationSource( + sourceDOI = "10.5678/paper-2", + title = "Another citing paper", + year = 2023, + contributors = listOf( + CitationContributor(givenName = "Alice", surname = "Jones"), + ), + ), + seqSetDOIs = setOf("10.1234/seqset-2"), + ), + ), + result, + ) + } + + @Test + fun `parseCrossRefCitedByXML returns empty list when no forward_link elements present`() { + val xml = "" + val result = crossRefService.parseCrossRefCitedByXML(xml) + assertTrue(result.isEmpty()) + } + + @Test + fun `parseCrossRefCitedByXML throws when forward_link is missing the seqSet DOI attribute`() { + val xml = """ + + + + 10.5678/paper-1 + + + + """.trimIndent() + + val ex = assertThrows { + crossRefService.parseCrossRefCitedByXML(xml) + } + assertTrue(ex.message!!.contains("missing seqset doi", ignoreCase = true)) + } + + @Test + fun `parseCrossRefCitedByXML throws when forward_link has no citation element`() { + val xml = """ + + + + """.trimIndent() + + val ex = assertThrows { + crossRefService.parseCrossRefCitedByXML(xml) + } + assertTrue(ex.message!!.contains("no citation element", ignoreCase = true)) + } + + @Test + fun `parseCrossRefCitedByXML throws when citation source has no DOI`() { + val xml = """ + + + + A citing paper + + + + """.trimIndent() + + val ex = assertThrows { + crossRefService.parseCrossRefCitedByXML(xml) + } + assertTrue(ex.message!!.contains("missing doi", ignoreCase = true)) + } + + @Test + fun `parseCrossRefCitedByXML throws for completely malformed input`() { + val xml = "<this is not valid xml at all !!!!" + val ex = assertThrows { + crossRefService.parseCrossRefCitedByXML(xml) + } + assertTrue(ex.message!!.contains("invalid xml", ignoreCase = true)) + } + + @Test + fun `parseCrossRefCitedByXML throws for valid xml missing crossref_result`() { + val xml = "this is valid xml" + val ex = assertThrows { + crossRefService.parseCrossRefCitedByXML(xml) + } + assertTrue(ex.message!!.contains("invalid crossref root element", ignoreCase = true)) + } + + @Test + fun `parseCrossRefCitedByXML returns empty contributor list when contributors element is missing`() { + val xml = """ + + + + 10.5678/paper-1 + A citing paper + 2024 + + + + """.trimIndent() + + val result = crossRefService.parseCrossRefCitedByXML(xml) + assertEquals( + listOf( + SeqSetCitationSource( + CitationSource( + sourceDOI = "10.5678/paper-1", + title = "A citing paper", + year = 2024, + contributors = emptyList(), + ), + seqSetDOIs = setOf("10.1234/seqset-1"), + ), + ), + result, + ) + } + + @Test + fun `parseCrossRefCitedByXML throws when citation source title is missing`() { + val xml = """ + + + + 10.5678/paper-1 + 2024 + + + + """.trimIndent() + + val ex = assertThrows { + crossRefService.parseCrossRefCitedByXML(xml) + } + assertTrue(ex.message!!.contains("missing title", ignoreCase = true)) + } + + @Test + fun `parseCrossRefCitedByXML throws when citation source title is blank`() { + val xml = """ + + + + 10.5678/paper-1 + + 2024 + + + + """.trimIndent() + + val ex = assertThrows { + crossRefService.parseCrossRefCitedByXML(xml) + } + assertTrue(ex.message!!.contains("missing title", ignoreCase = true)) + } + + @Test + fun `parseCrossRefCitedByXML throws when citation source year is missing`() { + val xml = """ + + + + 10.5678/paper-1 + A citing paper + + + + """.trimIndent() + + val ex = assertThrows { + crossRefService.parseCrossRefCitedByXML(xml) + } + assertTrue(ex.message!!.contains("missing or non-numeric year", ignoreCase = true)) + } + + @Test + fun `parseCrossRefCitedByXML throws when citation source year is non-numeric`() { + val xml = """ + + + + 10.5678/paper-1 + A citing paper + not-a-year + + + + """.trimIndent() + + val ex = assertThrows { + crossRefService.parseCrossRefCitedByXML(xml) + } + assertTrue(ex.message!!.contains("missing or non-numeric year", ignoreCase = true)) + } + + @Test + fun `parseCrossRefCitedByXML filters out empty contributors`() { + val xml = """ + + + + 10.5678/paper-1 + A citing paper + 2024 + + JaneDoe + + Solo + + + + + """.trimIndent() + + val result = crossRefService.parseCrossRefCitedByXML(xml) + assertEquals( + listOf( + CitationContributor("Jane", "Doe"), + CitationContributor("", "Solo"), + ), + result[0].source.contributors, + ) + } + @Test fun `Create an XML metadata string complying with CrossRef's schema`() { val crossRefXML = crossRefService.generateCrossRefXML( diff --git a/backend/src/test/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCrossRefCitationsTaskTest.kt b/backend/src/test/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCrossRefCitationsTaskTest.kt new file mode 100644 index 0000000000..74c8d72a5b --- /dev/null +++ b/backend/src/test/kotlin/org/loculus/backend/service/seqsetcitations/SeqSetCrossRefCitationsTaskTest.kt @@ -0,0 +1,82 @@ +package org.loculus.backend.service.seqsetcitations + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.loculus.backend.api.CitationContributor +import org.loculus.backend.api.CitationSource +import org.loculus.backend.api.SeqSetCitationSource + +class SeqSetCrossRefCitationsTaskTest { + private fun citationSource( + sourceDOI: String, + title: String = "A citing paper", + year: Int = 2024, + contributors: List = listOf(CitationContributor("Jane", "Doe")), + seqSetDOIs: Set = emptySet(), + ) = SeqSetCitationSource( + CitationSource( + sourceDOI = sourceDOI, + title = title, + year = year, + contributors = contributors, + ), + seqSetDOIs = seqSetDOIs, + ) + + @Test + fun `mergeCitationSources unions seqSetDOIs for the same sourceDOI`() { + val a = citationSource("10.5678/paper-1", seqSetDOIs = setOf("10.1234/seqset-a")) + val b = citationSource("10.5678/paper-1", seqSetDOIs = setOf("10.1234/seqset-b")) + val merged = mergeCitationSources(listOf(a, b)) + + assertEquals( + setOf( + citationSource( + "10.5678/paper-1", + seqSetDOIs = setOf("10.1234/seqset-a", "10.1234/seqset-b"), + ), + ), + merged, + ) + } + + @Test + fun `mergeCitationSources keeps distinct sources separate`() { + val a = citationSource("10.5678/paper-1", seqSetDOIs = setOf("10.1234/seqset-a")) + val b = citationSource("10.5678/paper-2", seqSetDOIs = setOf("10.1234/seqset-b")) + val merged = mergeCitationSources(listOf(a, b)) + + assertEquals(setOf(a, b), merged) + } + + @Test + fun `mergeCitationSources unions seqSetDOIs even when other metadata conflicts, keeping latest`() { + val first = citationSource( + "10.5678/paper-1", + title = "Original title", + seqSetDOIs = setOf("10.1234/seqset-a"), + ) + val second = citationSource( + "10.5678/paper-1", + title = "Updated title", + seqSetDOIs = setOf("10.1234/seqset-b"), + ) + val merged = mergeCitationSources(listOf(first, second)) + + assertEquals( + setOf( + citationSource( + "10.5678/paper-1", + title = "Updated title", + seqSetDOIs = setOf("10.1234/seqset-a", "10.1234/seqset-b"), + ), + ), + merged, + ) + } + + @Test + fun `mergeCitationSources returns empty set for empty input`() { + assertEquals(emptySet(), mergeCitationSources(emptyList())) + } +} diff --git a/website/src/components/SeqSetCitations/ExportSeqSet.tsx b/website/src/components/SeqSetCitations/ExportSeqSet.tsx index a4bf384f83..7e1a6ca11d 100644 --- a/website/src/components/SeqSetCitations/ExportSeqSet.tsx +++ b/website/src/components/SeqSetCitations/ExportSeqSet.tsx @@ -109,11 +109,11 @@ export const ExportSeqSet: FC = ({ seqSet, seqSetRecords, dat }; return ( -
+
-

Export

+

Export / Cite SeqSet

-
+
diff --git a/website/src/components/SeqSetCitations/SeqSetCitationsList.tsx b/website/src/components/SeqSetCitations/SeqSetCitationsList.tsx new file mode 100644 index 0000000000..40255a42d9 --- /dev/null +++ b/website/src/components/SeqSetCitations/SeqSetCitationsList.tsx @@ -0,0 +1,72 @@ +import { type FC } from 'react'; + +import { type SeqSetCitation } from '../../types/seqSetCitation'; + +interface SeqSetCitationsListProps { + isLoading: boolean; + error: Error | null; + seqSetCitations: SeqSetCitation[]; +} + +interface SeqSetCitationItemProps { + seqSetCitation: SeqSetCitation; +} + +const SeqSetCitationDOI: FC = ({ seqSetCitation }) => { + return ( + + DOI: + + {seqSetCitation.source.sourceDOI} + + + ); +}; + +const SeqSetCitationItem: FC = ({ seqSetCitation }) => { + return ( +
+
+ {seqSetCitation.source.contributors + .map((contributor) => `${contributor.givenName} ${contributor.surname}`) + .join(', ')} + . {seqSetCitation.source.title}, {seqSetCitation.source.year}. +
+
+ +
+
+ ); +}; + +export const SeqSetCitationsList: FC = ({ isLoading, error, seqSetCitations }) => { + return ( +
+
+

SeqSet Citations

+
+
+ {isLoading ? ( + + ) : error ? ( + Failed to load citations. + ) : seqSetCitations.length > 0 ? ( +
    + {seqSetCitations.map((seqSetCitation) => ( +
  • + +
  • + ))} +
+ ) : ( + No citations found for this SeqSet. + )} +
+
+ ); +}; diff --git a/website/src/components/SeqSetCitations/SeqSetForm.tsx b/website/src/components/SeqSetCitations/SeqSetForm.tsx index ed2016e9a5..caeeba6313 100644 --- a/website/src/components/SeqSetCitations/SeqSetForm.tsx +++ b/website/src/components/SeqSetCitations/SeqSetForm.tsx @@ -136,7 +136,7 @@ export const SeqSetForm: FC = ({ clientConfig, accessToken, edi }; return ( -
+

{`${editSeqSet ? 'Edit' : 'Create a'} SeqSet`}

diff --git a/website/src/components/SeqSetCitations/SeqSetItem.tsx b/website/src/components/SeqSetCitations/SeqSetItem.tsx index 759aa2e1f9..bccc16a777 100644 --- a/website/src/components/SeqSetCitations/SeqSetItem.tsx +++ b/website/src/components/SeqSetCitations/SeqSetItem.tsx @@ -1,26 +1,25 @@ import MUIPagination from '@mui/material/Pagination'; import { AxiosError } from 'axios'; -import { type FC, useState } from 'react'; +import { type FC, useMemo, useState } from 'react'; import { toast } from 'react-toastify'; -import { CitationPlot } from './CitationPlot'; import { DatePlot, CategoryPlot } from './SeqSetPlots.tsx'; import { SeqSetRecordsTableWithMetadata } from './SeqSetRecordsTableWithMetadata'; import type { AggregateRow } from './getSeqSetStatistics.ts'; import { mainTailwindColor } from '../../../colors.json'; import { getClientLogger } from '../../clientLogger'; -import { useCrossRefWork } from '../../hooks/useCrossRefOperations.ts'; import { seqSetCitationClientHooks } from '../../services/serviceHooks'; import type { ProblemDetail } from '../../types/backend.ts'; import type { SeqSetGraph } from '../../types/config.ts'; import type { ClientConfig } from '../../types/runtimeConfig'; -import { type CitedByResult, type SeqSet, type SeqSetRecord } from '../../types/seqSetCitation'; +import { type SeqSet, type SeqSetRecord } from '../../types/seqSetCitation'; import { createAuthorizationHeader } from '../../utils/createAuthorizationHeader'; import { displayConfirmationDialog } from '../ConfirmationDialog.tsx'; import { Button } from '../common/Button.tsx'; import { withQueryProvider } from '../common/withQueryProvider.tsx'; import MdiDotsGrid from '~icons/mdi/dots-grid'; import MdiViewGrid from '~icons/mdi/view-grid'; +import OouiNewWindowLtr from '~icons/ooui/new-window-ltr'; const logger = getClientLogger('SeqSetItem'); @@ -53,7 +52,6 @@ type SeqSetItemProps = { seqSetAccessionVersion: string; seqSet: SeqSet; seqSetRecords: SeqSetRecord[]; - citedByData: CitedByResult; seqSetGraphs: SeqSetGraph[]; seqSetGraphsData: Record; isAdminView?: boolean; @@ -67,7 +65,6 @@ const SeqSetItemInner: FC = ({ seqSetAccessionVersion, seqSet, seqSetRecords, - citedByData, seqSetGraphs, seqSetGraphsData, isAdminView = false, @@ -79,10 +76,32 @@ const SeqSetItemInner: FC = ({ const sequencesPerPage = 10; const { - data: seqSetCrossRefWork, - isLoading: isCrossRefWorkLoading, - isError: isCrossRefWorkError, - } = useCrossRefWork(seqSet.seqSetDOI); + isLoading: isSeqSetCitationsLoading, + error: seqSetCitationsError, + data: seqSetCitations, + } = seqSetCitationClientHooks(clientConfig).useGetSeqSetCitedBy({ + params: { seqSetId: seqSet.seqSetId, version: seqSet.seqSetVersion }, + }); + + const totalCitations = useMemo(() => { + if (isSeqSetCitationsLoading || seqSetCitationsError) return 0; + + return seqSetCitations.length; + }, [isSeqSetCitationsLoading, seqSetCitationsError, seqSetCitations]); + + const citationDates: AggregateRow[] = useMemo(() => { + if (isSeqSetCitationsLoading || seqSetCitationsError) return []; + + const citationDatesAggregate = new Map(); + seqSetCitations.forEach((citation) => { + const year = String(citation.source.year); + citationDatesAggregate.set(year, (citationDatesAggregate.get(year) ?? 0) + 1); + }); + return Array.from(citationDatesAggregate.entries()).map(([year, citations]) => ({ + value: year, + count: citations, + })); + }, [isSeqSetCitationsLoading, seqSetCitationsError, seqSetCitations]); const { mutate: createSeqSetDOI } = useCreateSeqSetDOIAction( clientConfig, @@ -102,7 +121,20 @@ const SeqSetItemInner: FC = ({ const renderDOI = () => { if (seqSet.seqSetDOI !== undefined && seqSet.seqSetDOI !== null) { - return `https://doi.org/${seqSet.seqSetDOI}`; + return ( + + {`https://doi.org/${seqSet.seqSetDOI}`} + + + + + ); } if (!isAdminView) { @@ -152,33 +184,23 @@ const SeqSetItemInner: FC = ({ - ) : isCrossRefWorkError ? ( - Failed to load citations. - ) : ( - - Cited by {seqSetCrossRefWork.message.isReferencedByCount} - - ) + isSeqSetCitationsLoading ? ( + + ) : seqSetCitationsError ? ( + Failed to load citations. ) : ( - Cited by 0 + Cited by {totalCitations} ) } /> } /> diff --git a/website/src/components/SeqSetCitations/SeqSetItemActions.tsx b/website/src/components/SeqSetCitations/SeqSetItemActions.tsx index a4a0b6b891..6b07ae434e 100644 --- a/website/src/components/SeqSetCitations/SeqSetItemActions.tsx +++ b/website/src/components/SeqSetCitations/SeqSetItemActions.tsx @@ -3,6 +3,7 @@ import { toast } from 'react-toastify'; import { AuthorDetails } from './AuthorDetails.tsx'; import { ExportSeqSet } from './ExportSeqSet'; +import { SeqSetCitationsList } from './SeqSetCitationsList.tsx'; import { SeqSetForm } from './SeqSetForm'; import { getClientLogger } from '../../clientLogger'; import { seqSetCitationClientHooks } from '../../services/serviceHooks'; @@ -13,12 +14,12 @@ import { getAccessionVersionString } from '../../utils/extractAccessionVersion.t import { displayConfirmationDialog } from '../ConfirmationDialog.tsx'; import { BaseDialog } from '../common/BaseDialog.tsx'; import { Button } from '../common/Button'; -import Modal from '../common/Modal'; import { withQueryProvider } from '../common/withQueryProvider.tsx'; import MdiDelete from '~icons/mdi/delete'; import MdiDownload from '~icons/mdi/download'; import MdiInformationOutline from '~icons/mdi/information-outline'; import MdiPencil from '~icons/mdi/pencil'; +import MdiViewListOutline from '~icons/mdi/view-list-outline'; const logger = getClientLogger('SeqSetItemActions'); @@ -55,8 +56,17 @@ const SeqSetItemActionsInner: FC = ({ const [editModalVisible, setEditModalVisible] = useState(false); const [exportModalVisible, setExportModalVisible] = useState(false); + const [citationsModalVisible, setCitationsModalVisible] = useState(false); const [creatorInfoVisible, setCreatorInfoVisible] = useState(false); + const { + isLoading: isSeqSetCitationsLoading, + error: seqSetCitationsError, + data: seqSetCitations, + } = seqSetCitationClientHooks(clientConfig).useGetSeqSetCitedBy({ + params: { seqSetId: seqSet.seqSetId, version: seqSet.seqSetVersion }, + }); + const { mutate: deleteSeqSet } = useDeleteSeqSetAction( clientConfig, accessToken, @@ -103,6 +113,13 @@ const SeqSetItemActionsInner: FC = ({ More details + {isAdminView ? (
- + setEditModalVisible(false)} + title='' + fullWidth={false} + className='min-h-[60vh]' + > +
-
- + + setExportModalVisible(false)} + title='' + fullWidth={false} + className='min-h-[60vh]' + > +
-
+ + setCitationsModalVisible(false)} + title='' + fullWidth={false} + className='min-h-[60vh]' + > +
+ +
0 ? [...topData, { value: `Others (${otherData.length})`, count: otherCount }] : topData; }; -export const DatePlot: React.FC = ({ data, description, barColor }) => { - const dateFormat = getDateFormatFromData(data); - const groupedData = groupByDateFormat(data, dateFormat); +export const DatePlot: React.FC = ({ data, description, barColor, dateFormat }) => { + const format = dateFormat ?? getDateFormatFromData(data); + const groupedData = groupByDateFormat(data, format); const { graphData, emptyCount } = getGraphData(groupedData, barColor); - const graphTimeProperties = getGraphTimeProperties(dateFormat); + const graphTimeProperties = getGraphTimeProperties(format); return ( void; children: ReactNode; fullWidth?: boolean; + className?: string; } -export const BaseDialog: React.FC = ({ title, isOpen, onClose, children, fullWidth = true }) => { +export const BaseDialog: React.FC = ({ + title, + isOpen, + onClose, + children, + fullWidth = true, + className, +}) => { const fullWidthClasses = fullWidth ? 'w-full w-max-5xl' : ''; return ( @@ -19,7 +27,7 @@ export const BaseDialog: React.FC = ({ title, isOpen, onClose,
{title} diff --git a/website/src/hooks/useCrossRefOperations.ts b/website/src/hooks/useCrossRefOperations.ts deleted file mode 100644 index 85bb72c898..0000000000 --- a/website/src/hooks/useCrossRefOperations.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useQuery, type UseQueryResult } from '@tanstack/react-query'; -import axios from 'axios'; - -import { crossRefWork, type CrossRefWork } from '../types/seqSetCitation'; - -export const useCrossRefWork = (doi?: string | null): UseQueryResult => { - return useQuery({ - queryKey: ['getCrossRefWork', doi], - queryFn: async () => { - return axios - .get(`https://api.crossref.org/works/${doi}/`) - .then((response) => crossRefWork.parse(response.data)); - }, - enabled: !!doi, - }); -}; diff --git a/website/src/pages/seqsets/[seqSetId].[version].astro b/website/src/pages/seqsets/[seqSetId].[version].astro index a3e4278f40..6b7d588072 100644 --- a/website/src/pages/seqsets/[seqSetId].[version].astro +++ b/website/src/pages/seqsets/[seqSetId].[version].astro @@ -34,7 +34,6 @@ if (!seqSetsAreEnabled()) { const seqSetClient = SeqSetCitationClient.create(); const seqSetVersionsResponse = await seqSetClient.call('getSeqSetVersions', { params: { seqSetId } }); const seqSetRecordsResponse = await seqSetClient.call('getSeqSetRecords', { params: { seqSetId, version } }); -const seqSetCitedByResponse = await seqSetClient.call('getSeqSetCitedBy', { params: { seqSetId, version } }); const getSeqSetByVersion = (seqSetVersions: SeqSet[], version: string) => { const matchedVersion = seqSetVersions.find((obj) => { @@ -61,7 +60,6 @@ const seqSetGraphResponses = await Promise.all( ); const seqSetAuthor = seqSetAuthorResponse.unwrapOr(undefined); -const citedByData = seqSetCitedByResponse.unwrapOr({ years: [], citations: [] }); const seqSetGraphsData = Object.fromEntries( seqSetGraphs.map((graph, i) => [graph.name, seqSetGraphResponses[i].unwrapOr([])]), ); @@ -69,7 +67,6 @@ const seqSetGraphsData = Object.fromEntries( const secondaryErrors = ( [ [seqSetAuthorResponse, 'Error while fetching author profile'], - [seqSetCitedByResponse, 'Error while fetching seqSet citations'], ...seqSetGraphResponses.map((response) => [response, 'Error while fetching seqSet statistics']), ] as [Result, string][] ).flatMap(([response, message]) => (response.isErr() ? [`${message}: ${JSON.stringify(response.error)}`] : [])); @@ -122,7 +119,6 @@ const secondaryErrors = ( seqSetAccessionVersion={seqSetAccessionVersion} seqSet={seqSetResponse.value} seqSetRecords={seqSetRecordsResponse.value} - citedByData={citedByData} seqSetGraphs={seqSetGraphs} seqSetGraphsData={seqSetGraphsData} isAdminView={seqSetResponse.value.createdBy === username} diff --git a/website/src/services/seqSetCitationApi.ts b/website/src/services/seqSetCitationApi.ts index b81a600afe..541b23dd6a 100644 --- a/website/src/services/seqSetCitationApi.ts +++ b/website/src/services/seqSetCitationApi.ts @@ -2,7 +2,7 @@ import { makeApi, makeEndpoint } from '@zodios/core'; import z from 'zod'; import { authorizationHeader, notAuthorizedError } from './commonApiTypes.ts'; -import { authorProfile, seqSets, seqSetRecords, citedByResult } from '../types/seqSetCitation.ts'; +import { authorProfile, seqSets, seqSetRecords, citedByResult, seqSetCitations } from '../types/seqSetCitation.ts'; const getSeqSetsOfUserEndpoint = makeEndpoint({ method: 'get', @@ -26,7 +26,7 @@ const getSeqSetCitedByEndpoint = makeEndpoint({ method: 'get', path: '/get-seqset-cited-by-publication?seqSetId=:seqSetId&version=:version', alias: 'getSeqSetCitedBy', - response: citedByResult, + response: seqSetCitations, errors: [notAuthorizedError], }); diff --git a/website/src/types/seqSetCitation.ts b/website/src/types/seqSetCitation.ts index 696cfb39de..ef2904792c 100644 --- a/website/src/types/seqSetCitation.ts +++ b/website/src/types/seqSetCitation.ts @@ -39,14 +39,21 @@ export const authorProfile = z.object({ }); export type AuthorProfile = z.infer; -export const crossRefWork = z.object({ - message: z - .object({ - // eslint-disable-next-line @typescript-eslint/naming-convention - 'is-referenced-by-count': z.number(), - }) - .transform((data) => ({ - isReferencedByCount: data['is-referenced-by-count'], - })), +const citationContributor = z.object({ + givenName: z.string(), + surname: z.string(), }); -export type CrossRefWork = z.infer; + +const citationSource = z.object({ + sourceDOI: z.string(), + title: z.string(), + year: z.number(), + contributors: z.array(citationContributor), +}); + +const seqSetCitation = z.object({ + source: citationSource, +}); +export type SeqSetCitation = z.infer; + +export const seqSetCitations = z.array(seqSetCitation);