p2s_mapper 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +148 -0
  3. package/dist/alignmentQuality.d.ts +58 -0
  4. package/dist/alignmentQuality.js +108 -0
  5. package/dist/alignmentQuality.js.map +1 -0
  6. package/dist/alphaFoldModels.d.ts +29 -0
  7. package/dist/alphaFoldModels.js +60 -0
  8. package/dist/alphaFoldModels.js.map +1 -0
  9. package/dist/caCoordsToPdb.d.ts +18 -0
  10. package/dist/caCoordsToPdb.js +95 -0
  11. package/dist/caCoordsToPdb.js.map +1 -0
  12. package/dist/chooseMappedEntity.d.ts +55 -0
  13. package/dist/chooseMappedEntity.js +140 -0
  14. package/dist/chooseMappedEntity.js.map +1 -0
  15. package/dist/coordinates.d.ts +66 -0
  16. package/dist/coordinates.js +66 -0
  17. package/dist/coordinates.js.map +1 -0
  18. package/dist/dbIds.d.ts +16 -0
  19. package/dist/dbIds.js +42 -0
  20. package/dist/dbIds.js.map +1 -0
  21. package/dist/extractPerResidueConfidence.d.ts +49 -0
  22. package/dist/extractPerResidueConfidence.js +42 -0
  23. package/dist/extractPerResidueConfidence.js.map +1 -0
  24. package/dist/extractStructureSequences.d.ts +142 -0
  25. package/dist/extractStructureSequences.js +217 -0
  26. package/dist/extractStructureSequences.js.map +1 -0
  27. package/dist/fetchUtils.d.ts +34 -0
  28. package/dist/fetchUtils.js +91 -0
  29. package/dist/fetchUtils.js.map +1 -0
  30. package/dist/index.d.ts +23 -0
  31. package/dist/index.js +46 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/invertMap.d.ts +2 -0
  34. package/dist/invertMap.js +8 -0
  35. package/dist/invertMap.js.map +1 -0
  36. package/dist/isoformRanking.d.ts +47 -0
  37. package/dist/isoformRanking.js +91 -0
  38. package/dist/isoformRanking.js.map +1 -0
  39. package/dist/mappings.d.ts +58 -0
  40. package/dist/mappings.js +193 -0
  41. package/dist/mappings.js.map +1 -0
  42. package/dist/package.json +1 -0
  43. package/dist/pairwiseAlignment.d.ts +35 -0
  44. package/dist/pairwiseAlignment.js +337 -0
  45. package/dist/pairwiseAlignment.js.map +1 -0
  46. package/dist/pdbUniProtMapping.d.ts +75 -0
  47. package/dist/pdbUniProtMapping.js +241 -0
  48. package/dist/pdbUniProtMapping.js.map +1 -0
  49. package/dist/pdbeBestStructures.d.ts +23 -0
  50. package/dist/pdbeBestStructures.js +79 -0
  51. package/dist/pdbeBestStructures.js.map +1 -0
  52. package/dist/pdbeEntryMolecules.d.ts +7 -0
  53. package/dist/pdbeEntryMolecules.js +39 -0
  54. package/dist/pdbeEntryMolecules.js.map +1 -0
  55. package/dist/siftsNumbering.d.ts +23 -0
  56. package/dist/siftsNumbering.js +44 -0
  57. package/dist/siftsNumbering.js.map +1 -0
  58. package/dist/stripStopCodon.d.ts +15 -0
  59. package/dist/stripStopCodon.js +24 -0
  60. package/dist/stripStopCodon.js.map +1 -0
  61. package/dist/structureFormat.d.ts +40 -0
  62. package/dist/structureFormat.js +54 -0
  63. package/dist/structureFormat.js.map +1 -0
  64. package/dist/structureSources.d.ts +28 -0
  65. package/dist/structureSources.js +76 -0
  66. package/dist/structureSources.js.map +1 -0
  67. package/dist/structureUrls.d.ts +54 -0
  68. package/dist/structureUrls.js +147 -0
  69. package/dist/structureUrls.js.map +1 -0
  70. package/dist/types.d.ts +14 -0
  71. package/dist/types.js +27 -0
  72. package/dist/types.js.map +1 -0
  73. package/dist/uniProtSearch.d.ts +32 -0
  74. package/dist/uniProtSearch.js +114 -0
  75. package/dist/uniProtSearch.js.map +1 -0
  76. package/esm/alignmentQuality.d.ts +58 -0
  77. package/esm/alignmentQuality.js +100 -0
  78. package/esm/alignmentQuality.js.map +1 -0
  79. package/esm/alphaFoldModels.d.ts +29 -0
  80. package/esm/alphaFoldModels.js +55 -0
  81. package/esm/alphaFoldModels.js.map +1 -0
  82. package/esm/caCoordsToPdb.d.ts +18 -0
  83. package/esm/caCoordsToPdb.js +91 -0
  84. package/esm/caCoordsToPdb.js.map +1 -0
  85. package/esm/chooseMappedEntity.d.ts +55 -0
  86. package/esm/chooseMappedEntity.js +134 -0
  87. package/esm/chooseMappedEntity.js.map +1 -0
  88. package/esm/coordinates.d.ts +66 -0
  89. package/esm/coordinates.js +58 -0
  90. package/esm/coordinates.js.map +1 -0
  91. package/esm/dbIds.d.ts +16 -0
  92. package/esm/dbIds.js +35 -0
  93. package/esm/dbIds.js.map +1 -0
  94. package/esm/extractPerResidueConfidence.d.ts +49 -0
  95. package/esm/extractPerResidueConfidence.js +38 -0
  96. package/esm/extractPerResidueConfidence.js.map +1 -0
  97. package/esm/extractStructureSequences.d.ts +142 -0
  98. package/esm/extractStructureSequences.js +205 -0
  99. package/esm/extractStructureSequences.js.map +1 -0
  100. package/esm/fetchUtils.d.ts +34 -0
  101. package/esm/fetchUtils.js +82 -0
  102. package/esm/fetchUtils.js.map +1 -0
  103. package/esm/index.d.ts +23 -0
  104. package/esm/index.js +30 -0
  105. package/esm/index.js.map +1 -0
  106. package/esm/invertMap.d.ts +2 -0
  107. package/esm/invertMap.js +5 -0
  108. package/esm/invertMap.js.map +1 -0
  109. package/esm/isoformRanking.d.ts +47 -0
  110. package/esm/isoformRanking.js +86 -0
  111. package/esm/isoformRanking.js.map +1 -0
  112. package/esm/mappings.d.ts +58 -0
  113. package/esm/mappings.js +179 -0
  114. package/esm/mappings.js.map +1 -0
  115. package/esm/pairwiseAlignment.d.ts +35 -0
  116. package/esm/pairwiseAlignment.js +328 -0
  117. package/esm/pairwiseAlignment.js.map +1 -0
  118. package/esm/pdbUniProtMapping.d.ts +75 -0
  119. package/esm/pdbUniProtMapping.js +231 -0
  120. package/esm/pdbUniProtMapping.js.map +1 -0
  121. package/esm/pdbeBestStructures.d.ts +23 -0
  122. package/esm/pdbeBestStructures.js +73 -0
  123. package/esm/pdbeBestStructures.js.map +1 -0
  124. package/esm/pdbeEntryMolecules.d.ts +7 -0
  125. package/esm/pdbeEntryMolecules.js +35 -0
  126. package/esm/pdbeEntryMolecules.js.map +1 -0
  127. package/esm/siftsNumbering.d.ts +23 -0
  128. package/esm/siftsNumbering.js +40 -0
  129. package/esm/siftsNumbering.js.map +1 -0
  130. package/esm/stripStopCodon.d.ts +15 -0
  131. package/esm/stripStopCodon.js +20 -0
  132. package/esm/stripStopCodon.js.map +1 -0
  133. package/esm/structureFormat.d.ts +40 -0
  134. package/esm/structureFormat.js +48 -0
  135. package/esm/structureFormat.js.map +1 -0
  136. package/esm/structureSources.d.ts +28 -0
  137. package/esm/structureSources.js +71 -0
  138. package/esm/structureSources.js.map +1 -0
  139. package/esm/structureUrls.d.ts +54 -0
  140. package/esm/structureUrls.js +134 -0
  141. package/esm/structureUrls.js.map +1 -0
  142. package/esm/types.d.ts +14 -0
  143. package/esm/types.js +23 -0
  144. package/esm/types.js.map +1 -0
  145. package/esm/uniProtSearch.d.ts +32 -0
  146. package/esm/uniProtSearch.js +110 -0
  147. package/esm/uniProtSearch.js.map +1 -0
  148. package/package.json +63 -0
  149. package/src/alignmentQuality.ts +132 -0
  150. package/src/alphaFoldModels.ts +91 -0
  151. package/src/caCoordsToPdb.ts +111 -0
  152. package/src/chooseMappedEntity.ts +198 -0
  153. package/src/coordinates.ts +119 -0
  154. package/src/dbIds.ts +46 -0
  155. package/src/extractPerResidueConfidence.ts +88 -0
  156. package/src/extractStructureSequences.ts +342 -0
  157. package/src/fetchUtils.ts +110 -0
  158. package/src/index.ts +34 -0
  159. package/src/invertMap.ts +4 -0
  160. package/src/isoformRanking.ts +139 -0
  161. package/src/mappings.ts +230 -0
  162. package/src/pairwiseAlignment.ts +423 -0
  163. package/src/pdbUniProtMapping.ts +320 -0
  164. package/src/pdbeBestStructures.ts +94 -0
  165. package/src/pdbeEntryMolecules.ts +37 -0
  166. package/src/siftsNumbering.ts +61 -0
  167. package/src/stripStopCodon.ts +20 -0
  168. package/src/structureFormat.ts +70 -0
  169. package/src/structureSources.ts +101 -0
  170. package/src/structureUrls.ts +171 -0
  171. package/src/types.ts +30 -0
  172. package/src/uniProtSearch.ts +212 -0
@@ -0,0 +1,101 @@
1
+ // The experimental structures 3D-Beacons knows for a UniProt entry: every PDBe
2
+ // entry mapped to the accession, with the UniProt range it covers and its
3
+ // resolution, in one CORS-enabled call. That is what lets a page offer a
4
+ // crystal structure beside a prediction without a PDB search of its own; take
5
+ // the entry id from here and ask `fetchUniProtStructureMappings` for its
6
+ // residue mapping.
7
+
8
+ import { rawfetch } from './fetchUtils.ts'
9
+
10
+ import type { FetchOptions } from './fetchUtils.ts'
11
+
12
+ export interface ExperimentalStructure {
13
+ pdbId: string
14
+ method: string
15
+ /** Å; absent for NMR and some EM entries */
16
+ resolution?: number
17
+ /** 1-based inclusive UniProt residues the entry covers */
18
+ start: number
19
+ end: number
20
+ /** fraction of the UniProt sequence the entry covers, 0..1 */
21
+ coverage: number
22
+ }
23
+
24
+ export function beaconsSummaryUrl(uniprotId: string) {
25
+ return `https://www.ebi.ac.uk/pdbe/pdbe-kb/3dbeacons/api/uniprot/summary/${encodeURIComponent(uniprotId)}.json?provider=pdbe`
26
+ }
27
+
28
+ function isRecord(value: unknown): value is Record<string, unknown> {
29
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
30
+ }
31
+
32
+ function finiteNumber(value: unknown) {
33
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined
34
+ }
35
+
36
+ /**
37
+ * PDBe entries only, best-covering first and sharpest within a tie.
38
+ *
39
+ * Predicted entries (AlphaFold, SWISS-MODEL, AlphaFill) are left out because a
40
+ * prediction comes from AlphaFold's own API, which answers per model. And
41
+ * 3D-Beacons files SASBDB's small-angle-scattering fits under "experimentally
42
+ * determined" too, with numeric ids and near-full coverage — dystrophin's best
43
+ * "structure" by coverage was SASBDB 436 — which are not entries a residue
44
+ * mapping exists for. Hence the `provider === 'PDBe'` gate rather than the
45
+ * category alone.
46
+ */
47
+ export function parseExperimentalStructures(
48
+ json: unknown,
49
+ ): ExperimentalStructure[] {
50
+ const structures = isRecord(json) ? json.structures : undefined
51
+ if (!Array.isArray(structures)) {
52
+ return []
53
+ }
54
+ return structures
55
+ .flatMap(entry => {
56
+ const s = isRecord(entry) ? entry.summary : undefined
57
+ if (!isRecord(s)) {
58
+ return []
59
+ }
60
+ const start = finiteNumber(s.uniprot_start)
61
+ const end = finiteNumber(s.uniprot_end)
62
+ const coverage = finiteNumber(s.coverage)
63
+ return typeof s.model_identifier === 'string' &&
64
+ s.provider === 'PDBe' &&
65
+ s.model_category === 'EXPERIMENTALLY DETERMINED' &&
66
+ start !== undefined &&
67
+ end !== undefined &&
68
+ coverage !== undefined
69
+ ? [
70
+ {
71
+ pdbId: s.model_identifier.toLowerCase(),
72
+ method:
73
+ typeof s.experimental_method === 'string'
74
+ ? s.experimental_method
75
+ : 'experimental',
76
+ resolution: finiteNumber(s.resolution),
77
+ start,
78
+ end,
79
+ coverage,
80
+ },
81
+ ]
82
+ : []
83
+ })
84
+ .sort(
85
+ (a, b) =>
86
+ b.coverage - a.coverage ||
87
+ (a.resolution ?? Infinity) - (b.resolution ?? Infinity),
88
+ )
89
+ }
90
+
91
+ /** Best-effort: an unreachable API reads as "no entries", which costs a
92
+ * structure suggestion and nothing else. */
93
+ export async function fetchExperimentalStructures(
94
+ uniprotId: string,
95
+ opts?: FetchOptions,
96
+ ): Promise<ExperimentalStructure[]> {
97
+ const res = await rawfetch(beaconsSummaryUrl(uniprotId), opts).catch(
98
+ () => undefined,
99
+ )
100
+ return res?.ok ? parseExperimentalStructures(await res.json()) : []
101
+ }
@@ -0,0 +1,171 @@
1
+ // Pure structure-URL builders and target parsers. Kept dependency-free so both
2
+ // the launch flow and the ProteinView model (e.g. resolving a `uniprotId`/`pdbId`
3
+ // shorthand at hydration) can import them without pulling in heavy launch code.
4
+
5
+ const ALPHAFOLD_VERSION = 'v6'
6
+
7
+ // UniProt endpoints. rest.* is the machine API (the GFF the feature tracks and
8
+ // the alignment's annotation rows both read, the FASTA the temporary protein
9
+ // assembly is built from); www.*/entry is the human-facing entry page the UI
10
+ // links to. Centralized so a path change is one edit rather than a grep.
11
+ export function uniprotGffUrl(uniprotId: string) {
12
+ return `https://rest.uniprot.org/uniprotkb/${uniprotId}.gff`
13
+ }
14
+
15
+ export function uniprotFastaUrl(uniprotId: string) {
16
+ return `https://rest.uniprot.org/uniprotkb/${uniprotId}.fasta`
17
+ }
18
+
19
+ export function uniprotEntryUrl(uniprotId: string) {
20
+ return `https://www.uniprot.org/uniprotkb/${uniprotId}/entry`
21
+ }
22
+
23
+ /**
24
+ * The guessed filename of an accession's canonical AlphaFold model. A guess:
25
+ * the F1 fragment does not exist past the length cap and the version moves, so
26
+ * this is the last resort when the prediction API cannot be reached. Ask
27
+ * `fetchAlphaFoldModels` for the real files.
28
+ */
29
+ export function getAlphaFoldStructureUrl(
30
+ uniprotId: string,
31
+ version = ALPHAFOLD_VERSION,
32
+ ) {
33
+ return `https://alphafold.ebi.ac.uk/files/AF-${uniprotId}-F1-model_${version}.cif`
34
+ }
35
+
36
+ export function getPdbStructureUrl(pdbId: string) {
37
+ return `https://files.rcsb.org/download/${pdbId}.cif`
38
+ }
39
+
40
+ /**
41
+ * Resolve the `{ pdbId }` shorthand to a concrete structure URL, so a
42
+ * hand-authored snapshot or a short launch URL doesn't have to know RCSB's
43
+ * filename format. An explicit `url` (or inline `data`) always wins.
44
+ * Idempotent: re-resolving an already-resolved spec returns the same url.
45
+ *
46
+ * `uniprotId` deliberately resolves to no url here, and still outranks a
47
+ * `pdbId`. An accession's files are whatever AlphaFold DB currently holds — an
48
+ * isoform model, several fragments, a new model version — so the structure
49
+ * keeps the accession and the structure loader asks the prediction API which
50
+ * file to open.
51
+ *
52
+ * Shared by the Structure model's snapshot preprocessor and the
53
+ * LaunchView-ProteinView extension point, which would otherwise each carry
54
+ * their own copy of the same precedence rule.
55
+ */
56
+ export function resolveStructureUrl({
57
+ url,
58
+ data,
59
+ uniprotId,
60
+ pdbId,
61
+ }: {
62
+ url?: string
63
+ data?: string
64
+ uniprotId?: string
65
+ pdbId?: string
66
+ }) {
67
+ if (url !== undefined || data !== undefined) {
68
+ return url
69
+ }
70
+ return uniprotId !== undefined || pdbId === undefined
71
+ ? undefined
72
+ : getPdbStructureUrl(pdbId)
73
+ }
74
+
75
+ // Hosts that serve the PDB archive, so a 4-character filename there really is
76
+ // that PDB entry. Restricted deliberately: a user-uploaded model happening to be
77
+ // named 1abc.cif must not pick up 1ABC's UniProt annotations.
78
+ const PDB_ARCHIVE_HOSTS = ['files.rcsb.org', 'www.ebi.ac.uk', 'ftp.ebi.ac.uk']
79
+
80
+ /**
81
+ * The PDB id of a structure URL from the PDB archive, lowercased, or undefined
82
+ * for any other URL. Accepts the filename forms those hosts serve, e.g.
83
+ * `1TUP.cif`, `pdb1tup.ent.gz`, `1tup_updated.cif`.
84
+ */
85
+ export function getPdbIdFromUrl(url: string) {
86
+ const { host, file } = (() => {
87
+ try {
88
+ const parsed = new URL(url)
89
+ return { host: parsed.hostname, file: parsed.pathname.split('/').pop() }
90
+ } catch {
91
+ return { host: undefined, file: undefined }
92
+ }
93
+ })()
94
+ if (host === undefined || !PDB_ARCHIVE_HOSTS.includes(host)) {
95
+ return undefined
96
+ }
97
+ const match =
98
+ /^(?:pdb)?([1-9][a-z0-9]{3})(?:_updated)?\.(?:cif|bcif|pdb|ent)(?:\.gz)?$/i.exec(
99
+ file ?? '',
100
+ )
101
+ return match?.[1]?.toLowerCase()
102
+ }
103
+
104
+ // Foldseek targets may contain a description after the ID separated by a
105
+ // space, e.g. "AF-P16442-F1-model_v6 Histo-blood group ABO transferase".
106
+ function extractTargetId(target: string) {
107
+ return target.split(' ')[0]!
108
+ }
109
+
110
+ /** The accession of an AlphaFold model id or url, with its isoform suffix:
111
+ * `AF-P16442-F1-model_v6` gives P16442, `AF-P04637-2-F1-model_v6` P04637-2. */
112
+ export function getUniprotIdFromAlphaFoldTarget(target: string) {
113
+ const match = /AF-([A-Z0-9]+(?:-\d+)?)-F\d+/.exec(extractTargetId(target))
114
+ return match?.[1]
115
+ }
116
+
117
+ // Foldseek names a pdb100 hit by the assembly file and chain it searched,
118
+ // `8f2h-assembly1.cif.gz_A`; the entry id is the first four characters.
119
+ export function getStructureUrlFromTarget(target: string, db: string) {
120
+ const targetId = extractTargetId(target)
121
+ if (targetId.startsWith('AF-')) {
122
+ return `https://alphafold.ebi.ac.uk/files/${targetId}.cif`
123
+ }
124
+ const pdbId =
125
+ db === 'pdb100'
126
+ ? /^([1-9][a-z0-9]{3})(?:[-_.]|$)/i.exec(targetId)?.[1]
127
+ : undefined
128
+ return pdbId ? getPdbStructureUrl(pdbId) : undefined
129
+ }
130
+
131
+ export function getConfidenceUrlFromTarget(target: string) {
132
+ const targetId = extractTargetId(target)
133
+ if (targetId.startsWith('AF-')) {
134
+ const confidenceId = targetId.replace('-model_', '-confidence_')
135
+ return `https://alphafold.ebi.ac.uk/files/${confidenceId}.json`
136
+ }
137
+ return undefined
138
+ }
139
+
140
+ /**
141
+ * What a structure is called in the UI: its PDB id, its AlphaFold accession,
142
+ * else the file's name. Shared by the view title, the alignment panel heading
143
+ * and the hover readout, so one structure reads the same everywhere.
144
+ */
145
+ export function structureDisplayLabel({
146
+ url,
147
+ data,
148
+ uniprotId,
149
+ }: {
150
+ url?: string
151
+ data?: string
152
+ uniprotId?: string
153
+ }) {
154
+ if (url === undefined) {
155
+ return data !== undefined
156
+ ? 'Uploaded structure'
157
+ : uniprotId
158
+ ? `AlphaFold ${uniprotId}`
159
+ : ''
160
+ }
161
+ const pdbId = getPdbIdFromUrl(url)
162
+ if (pdbId) {
163
+ return pdbId.toUpperCase()
164
+ }
165
+ const urlAccession = getUniprotIdFromAlphaFoldTarget(url)
166
+ if (urlAccession) {
167
+ return `AlphaFold ${urlAccession}`
168
+ }
169
+ const name = url.split('/').pop()?.split('?')[0]
170
+ return name === undefined || name === '' ? url : name
171
+ }
package/src/types.ts ADDED
@@ -0,0 +1,30 @@
1
+ export type AlignmentAlgorithm = 'needleman_wunsch' | 'smith_waterman'
2
+
3
+ export const ALIGNMENT_ALGORITHMS = {
4
+ NEEDLEMAN_WUNSCH: 'needleman_wunsch',
5
+ SMITH_WATERMAN: 'smith_waterman',
6
+ } as const
7
+
8
+ export const DEFAULT_ALIGNMENT_ALGORITHM: AlignmentAlgorithm = 'smith_waterman'
9
+
10
+ export const ALIGNMENT_ALGORITHM_VALUES: AlignmentAlgorithm[] = [
11
+ 'needleman_wunsch',
12
+ 'smith_waterman',
13
+ ]
14
+
15
+ /**
16
+ * Parses an untrusted algorithm name (a URL session-spec param) into the union.
17
+ * The only place a coercion is needed: the model property is an enumeration, so
18
+ * everything downstream of hydration is already typed.
19
+ */
20
+ export function coerceAlignmentAlgorithm(value: string): AlignmentAlgorithm {
21
+ return (
22
+ ALIGNMENT_ALGORITHM_VALUES.find(v => v === value) ??
23
+ DEFAULT_ALIGNMENT_ALGORITHM
24
+ )
25
+ }
26
+
27
+ export const ALIGNMENT_ALGORITHM_LABELS: Record<AlignmentAlgorithm, string> = {
28
+ needleman_wunsch: 'Needleman-Wunsch',
29
+ smith_waterman: 'Smith-Waterman',
30
+ }
@@ -0,0 +1,212 @@
1
+ import { jsonfetch } from './fetchUtils.ts'
2
+ import {
3
+ buildUniProtXrefQuery,
4
+ isRecognizedDatabaseId,
5
+ stripTrailingVersion,
6
+ } from './dbIds.ts'
7
+
8
+ import type { FetchOptions } from './fetchUtils.ts'
9
+
10
+ interface UniProtApiResult {
11
+ results: {
12
+ entryType: string
13
+ primaryAccession: string
14
+ uniProtkbId?: string
15
+ genes?: {
16
+ geneName?: {
17
+ value: string
18
+ }
19
+ }[]
20
+ organism?: {
21
+ taxonId: number
22
+ scientificName?: string
23
+ commonName?: string
24
+ }
25
+ proteinDescription?: {
26
+ recommendedName?: {
27
+ fullName?: {
28
+ value: string
29
+ }
30
+ }
31
+ }
32
+ }[]
33
+ }
34
+
35
+ export interface UniProtEntry {
36
+ accession: string
37
+ id?: string
38
+ geneName?: string
39
+ organismName?: string
40
+ proteinName?: string
41
+ isReviewed: boolean
42
+ }
43
+
44
+ const UNIPROT_FIELDS =
45
+ 'accession,id,gene_names,organism_name,protein_name,reviewed'
46
+
47
+ function mapApiResultToEntry(
48
+ result: UniProtApiResult['results'][0],
49
+ ): UniProtEntry {
50
+ return {
51
+ accession: result.primaryAccession,
52
+ id: result.uniProtkbId,
53
+ geneName: result.genes?.[0]?.geneName?.value,
54
+ organismName:
55
+ result.organism?.commonName ?? result.organism?.scientificName,
56
+ proteinName: result.proteinDescription?.recommendedName?.fullName?.value,
57
+ isReviewed: result.entryType === 'UniProtKB reviewed (Swiss-Prot)',
58
+ }
59
+ }
60
+
61
+ async function searchUniProt(
62
+ query: string,
63
+ size: number,
64
+ opts?: FetchOptions,
65
+ ): Promise<UniProtEntry[]> {
66
+ const url = `https://rest.uniprot.org/uniprotkb/search?query=${encodeURIComponent(query)}&fields=${UNIPROT_FIELDS}&size=${size}`
67
+ const data = await jsonfetch<UniProtApiResult>(url, opts)
68
+ return data.results.map(mapApiResultToEntry)
69
+ }
70
+
71
+ /**
72
+ * `gene_exact` rather than `gene`, which also matches synonyms and returns
73
+ * paralogs. Without a taxon the query runs across every species, so a mouse
74
+ * assembly whose tracks carry no taxId lists mouse beside human instead of
75
+ * silently answering with the human entry.
76
+ *
77
+ * The symbol is quoted because a bare one containing `:` — an HLA allele, a
78
+ * name straight out of an annotation file — reads as another field to
79
+ * UniProt's parser, which answers 400.
80
+ */
81
+ export function buildGeneNameQuery(geneName: string, organismId?: number) {
82
+ return [
83
+ `gene_exact:"${geneName.replaceAll('"', '\\"')}"`,
84
+ organismId ? `organism_id:${organismId}` : undefined,
85
+ 'reviewed:true',
86
+ ]
87
+ .filter(s => s !== undefined)
88
+ .join(' AND ')
89
+ }
90
+
91
+ interface SearchAttempt {
92
+ entries: UniProtEntry[]
93
+ error: unknown
94
+ }
95
+
96
+ async function searchByXref(
97
+ id: string,
98
+ opts?: FetchOptions,
99
+ ): Promise<SearchAttempt> {
100
+ const query = buildUniProtXrefQuery(id)
101
+ if (!query) {
102
+ return { entries: [], error: undefined }
103
+ }
104
+ try {
105
+ return { entries: await searchUniProt(query, 10, opts), error: undefined }
106
+ } catch (e) {
107
+ console.error(`xref search failed for ${id}:`, e)
108
+ return { entries: [], error: e }
109
+ }
110
+ }
111
+
112
+ async function searchByGeneName(
113
+ geneName: string,
114
+ organismId: number | undefined,
115
+ opts?: FetchOptions,
116
+ ): Promise<SearchAttempt> {
117
+ try {
118
+ const entries = await searchUniProt(
119
+ buildGeneNameQuery(geneName, organismId),
120
+ organismId ? 5 : 10,
121
+ opts,
122
+ )
123
+ return { entries, error: undefined }
124
+ } catch (e) {
125
+ console.error(`gene name search failed for ${geneName}:`, e)
126
+ return { entries: [], error: e }
127
+ }
128
+ }
129
+
130
+ function deduplicateEntries(entries: UniProtEntry[]) {
131
+ const seen = new Set<string>()
132
+ const result: UniProtEntry[] = []
133
+ for (const entry of entries) {
134
+ if (!seen.has(entry.accession)) {
135
+ seen.add(entry.accession)
136
+ result.push(entry)
137
+ }
138
+ }
139
+ return result
140
+ }
141
+
142
+ export interface UniProtSearchResult {
143
+ entries: UniProtEntry[]
144
+ attemptedCount: number
145
+ failedCount: number
146
+ }
147
+
148
+ export async function searchUniProtEntries(
149
+ {
150
+ recognizedIds = [],
151
+ geneId,
152
+ geneName,
153
+ organismId,
154
+ }: {
155
+ recognizedIds?: string[]
156
+ geneId?: string
157
+ geneName?: string
158
+ /** NCBI taxon id; undefined searches every species */
159
+ organismId?: number
160
+ },
161
+ opts?: FetchOptions,
162
+ ): Promise<UniProtSearchResult> {
163
+ const idsToSearch = new Set(recognizedIds)
164
+ const strippedGeneId = geneId ? stripTrailingVersion(geneId) : undefined
165
+ if (strippedGeneId && isRecognizedDatabaseId(strippedGeneId)) {
166
+ idsToSearch.add(strippedGeneId)
167
+ }
168
+
169
+ // The gene-name query runs alongside the xrefs rather than after them: it is
170
+ // only consulted when no xref found a reviewed entry, but waiting for that
171
+ // answer before starting it doubled the latency of the commonest case.
172
+ const [xrefResults, geneResult] = await Promise.all([
173
+ Promise.all([...idsToSearch].map(id => searchByXref(id, opts))),
174
+ geneName ? searchByGeneName(geneName, organismId, opts) : undefined,
175
+ ])
176
+
177
+ let entries = deduplicateEntries(xrefResults.flatMap(r => r.entries))
178
+ // The gene-name answer is consulted only where no xref found a reviewed
179
+ // entry. Counting it otherwise reported "failed for 1 of 2 identifiers" over
180
+ // a gene that had in fact resolved, off a query nothing was waiting for.
181
+ const consultedGeneResult =
182
+ geneResult && !entries.some(e => e.isReviewed) ? geneResult : undefined
183
+ if (consultedGeneResult) {
184
+ entries = deduplicateEntries([...entries, ...consultedGeneResult.entries])
185
+ }
186
+
187
+ const attemptedCount = idsToSearch.size + (consultedGeneResult ? 1 : 0)
188
+ const failedCount =
189
+ xrefResults.filter(r => r.error !== undefined).length +
190
+ (consultedGeneResult?.error === undefined ? 0 : 1)
191
+
192
+ // Every attempt failing is a network problem, not an empty result. Throwing
193
+ // it stops consumers reporting "No UniProt ID found" over a dead connection.
194
+ if (
195
+ entries.length === 0 &&
196
+ attemptedCount > 0 &&
197
+ attemptedCount === failedCount
198
+ ) {
199
+ throw (
200
+ consultedGeneResult?.error ??
201
+ xrefResults.find(r => r.error !== undefined)?.error
202
+ )
203
+ }
204
+
205
+ return {
206
+ entries: entries.toSorted(
207
+ (a, b) => Number(b.isReviewed) - Number(a.isReviewed),
208
+ ),
209
+ attemptedCount,
210
+ failedCount,
211
+ }
212
+ }