jbrowse-plugin-msaview 3.6.0 → 3.7.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 (156) hide show
  1. package/README.md +8 -0
  2. package/dist/LaunchMsaView/cleanProteinSequence.test.d.ts +1 -0
  3. package/dist/LaunchMsaView/cleanProteinSequence.test.js +77 -0
  4. package/dist/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.js +29 -32
  5. package/dist/LaunchMsaView/components/BlastQuery/BlastManualPanel.js +10 -15
  6. package/dist/LaunchMsaView/components/BlastQuery/CachedBlastResults.js +14 -13
  7. package/dist/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.js +25 -22
  8. package/dist/LaunchMsaView/components/OrthologQuery/OrthologPanel.js +14 -19
  9. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.d.ts +2 -1
  10. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.js +18 -2
  11. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.test.d.ts +1 -0
  12. package/dist/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.test.js +13 -0
  13. package/dist/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.js +19 -14
  14. package/dist/LaunchMsaView/components/SubmitCancelActions.d.ts +3 -1
  15. package/dist/LaunchMsaView/components/SubmitCancelActions.js +10 -7
  16. package/dist/LaunchMsaView/components/SubmitCancelActions.test.js +20 -2
  17. package/dist/LaunchMsaView/components/calculateProteinSequence.d.ts +8 -1
  18. package/dist/LaunchMsaView/components/calculateProteinSequence.js +25 -20
  19. package/dist/LaunchMsaView/components/launchConnectedView.d.ts +23 -0
  20. package/dist/LaunchMsaView/components/launchConnectedView.js +39 -0
  21. package/dist/LaunchMsaView/detectQueryRow.js +4 -7
  22. package/dist/LaunchMsaView/util.d.ts +6 -0
  23. package/dist/LaunchMsaView/util.js +7 -1
  24. package/dist/LaunchMsaViewExtensionPoint/index.test.js +15 -0
  25. package/dist/MsaViewPanel/afterCreateAutoruns.js +12 -3
  26. package/dist/MsaViewPanel/applyRegion.d.ts +23 -0
  27. package/dist/MsaViewPanel/applyRegion.js +19 -0
  28. package/dist/MsaViewPanel/applyRegion.test.d.ts +1 -0
  29. package/dist/MsaViewPanel/applyRegion.test.js +45 -0
  30. package/dist/MsaViewPanel/doLaunchBlast.js +2 -1
  31. package/dist/MsaViewPanel/doLaunchBlast.test.js +12 -0
  32. package/dist/MsaViewPanel/doLaunchOrthologs.test.js +1 -1
  33. package/dist/MsaViewPanel/genomeToMSA.js +3 -3
  34. package/dist/MsaViewPanel/genomeToMSA.test.js +69 -23
  35. package/dist/MsaViewPanel/launchAutoruns.test.d.ts +1 -0
  36. package/dist/MsaViewPanel/launchAutoruns.test.js +36 -0
  37. package/dist/MsaViewPanel/model.d.ts +18 -14
  38. package/dist/MsaViewPanel/model.js +17 -0
  39. package/dist/MsaViewPanel/msaDataStore.d.ts +5 -3
  40. package/dist/MsaViewPanel/msaDataStore.js +3 -12
  41. package/dist/MsaViewPanel/msaDataStore.test.js +8 -1
  42. package/dist/MsaViewPanel/observeProteinHighlights.test.js +4 -4
  43. package/dist/MsaViewPanel/regionSnapshot.test.d.ts +1 -0
  44. package/dist/MsaViewPanel/regionSnapshot.test.js +29 -0
  45. package/dist/MsaViewPanel/resolveConnectedTranscript.d.ts +41 -4
  46. package/dist/MsaViewPanel/resolveConnectedTranscript.js +103 -68
  47. package/dist/MsaViewPanel/resolveConnectedTranscript.test.d.ts +1 -0
  48. package/dist/MsaViewPanel/resolveConnectedTranscript.test.js +149 -0
  49. package/dist/MsaViewPanel/runLaunch.d.ts +8 -4
  50. package/dist/MsaViewPanel/runLaunch.js +15 -4
  51. package/dist/MsaViewPanel/runLaunch.test.js +16 -0
  52. package/dist/MsaViewPanel/storedData.test.js +23 -0
  53. package/dist/MsaViewPanel/syncGenomeHoverToMsaColumn.test.js +3 -2
  54. package/dist/MsaViewPanel/util.d.ts +23 -23
  55. package/dist/MsaViewPanel/util.js +23 -24
  56. package/dist/index.js +17 -10
  57. package/dist/jbrowse-plugin-msaview.umd.production.min.js +36 -36
  58. package/dist/jbrowse-plugin-msaview.umd.production.min.js.map +4 -4
  59. package/dist/utils/blastCache.d.ts +26 -2
  60. package/dist/utils/blastCache.js +51 -39
  61. package/dist/utils/blastCache.test.js +46 -19
  62. package/dist/utils/domainCache.d.ts +1 -1
  63. package/dist/utils/domainCache.js +18 -14
  64. package/dist/utils/ebiJobDispatcher.d.ts +11 -0
  65. package/dist/utils/ebiJobDispatcher.js +12 -13
  66. package/dist/utils/ebiJobDispatcher.test.js +17 -3
  67. package/dist/utils/eutils.d.ts +14 -0
  68. package/dist/utils/eutils.js +48 -14
  69. package/dist/utils/eutils.test.d.ts +1 -0
  70. package/dist/utils/eutils.test.js +31 -0
  71. package/dist/utils/idb.d.ts +16 -3
  72. package/dist/utils/idb.js +51 -5
  73. package/dist/utils/idb.test.d.ts +1 -0
  74. package/dist/utils/idb.test.js +78 -0
  75. package/dist/utils/ncbiDomains.js +4 -4
  76. package/dist/utils/ncbiOrthologs.d.ts +1 -1
  77. package/dist/utils/ncbiOrthologs.js +18 -17
  78. package/dist/utils/ncbiOrthologs.test.js +17 -1
  79. package/dist/utils/ncbiTaxonomy.js +14 -15
  80. package/dist/utils/taxonomyNames.js +23 -19
  81. package/dist/utils/taxonomyNames.test.d.ts +1 -0
  82. package/dist/utils/taxonomyNames.test.js +33 -0
  83. package/dist/utils/unirefHomologs.js +2 -2
  84. package/dist/utils/unirefHomologs.test.js +8 -1
  85. package/dist/version.d.ts +1 -1
  86. package/dist/version.js +1 -1
  87. package/package.json +9 -9
  88. package/src/LaunchMsaView/cleanProteinSequence.test.ts +91 -0
  89. package/src/LaunchMsaView/components/BlastQuery/BlastAutomaticPanel.tsx +35 -32
  90. package/src/LaunchMsaView/components/BlastQuery/BlastManualPanel.tsx +10 -14
  91. package/src/LaunchMsaView/components/BlastQuery/CachedBlastResults.tsx +16 -15
  92. package/src/LaunchMsaView/components/ManualMSALoader/ManualMSALoader.tsx +34 -21
  93. package/src/LaunchMsaView/components/OrthologQuery/OrthologPanel.tsx +18 -21
  94. package/src/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.test.ts +16 -0
  95. package/src/LaunchMsaView/components/OrthologQuery/OrthologSourceSelect.tsx +24 -2
  96. package/src/LaunchMsaView/components/PreLoadedMSA/PreLoadedMSADataPanel.tsx +19 -13
  97. package/src/LaunchMsaView/components/SubmitCancelActions.test.tsx +27 -2
  98. package/src/LaunchMsaView/components/SubmitCancelActions.tsx +12 -7
  99. package/src/LaunchMsaView/components/calculateProteinSequence.ts +28 -18
  100. package/src/LaunchMsaView/components/launchConnectedView.ts +56 -0
  101. package/src/LaunchMsaView/detectQueryRow.ts +5 -7
  102. package/src/LaunchMsaView/util.ts +7 -1
  103. package/src/LaunchMsaViewExtensionPoint/index.test.ts +16 -0
  104. package/src/LaunchMsaViewExtensionPoint/index.ts +28 -1
  105. package/src/MsaViewPanel/afterCreateAutoruns.ts +12 -3
  106. package/src/MsaViewPanel/applyRegion.test.ts +55 -0
  107. package/src/MsaViewPanel/applyRegion.ts +33 -0
  108. package/src/MsaViewPanel/doLaunchBlast.test.ts +20 -1
  109. package/src/MsaViewPanel/doLaunchBlast.ts +2 -1
  110. package/src/MsaViewPanel/doLaunchOrthologs.test.ts +1 -1
  111. package/src/MsaViewPanel/genomeToMSA.test.ts +75 -23
  112. package/src/MsaViewPanel/genomeToMSA.ts +3 -2
  113. package/src/MsaViewPanel/launchAutoruns.test.ts +45 -0
  114. package/src/MsaViewPanel/model.ts +22 -0
  115. package/src/MsaViewPanel/msaDataStore.test.ts +14 -2
  116. package/src/MsaViewPanel/msaDataStore.ts +4 -12
  117. package/src/MsaViewPanel/observeProteinHighlights.test.ts +4 -4
  118. package/src/MsaViewPanel/regionSnapshot.test.ts +34 -0
  119. package/src/MsaViewPanel/resolveConnectedTranscript.test.ts +183 -0
  120. package/src/MsaViewPanel/resolveConnectedTranscript.ts +154 -71
  121. package/src/MsaViewPanel/runLaunch.test.ts +19 -0
  122. package/src/MsaViewPanel/runLaunch.ts +21 -4
  123. package/src/MsaViewPanel/storedData.test.ts +32 -0
  124. package/src/MsaViewPanel/syncGenomeHoverToMsaColumn.test.ts +3 -2
  125. package/src/MsaViewPanel/util.ts +38 -29
  126. package/src/index.ts +19 -10
  127. package/src/utils/blastCache.test.ts +59 -20
  128. package/src/utils/blastCache.ts +62 -39
  129. package/src/utils/domainCache.ts +27 -15
  130. package/src/utils/ebiJobDispatcher.test.ts +18 -3
  131. package/src/utils/ebiJobDispatcher.ts +12 -16
  132. package/src/utils/eutils.test.ts +39 -0
  133. package/src/utils/eutils.ts +64 -14
  134. package/src/utils/idb.test.ts +106 -0
  135. package/src/utils/idb.ts +57 -5
  136. package/src/utils/ncbiDomains.ts +4 -4
  137. package/src/utils/ncbiOrthologs.test.ts +20 -0
  138. package/src/utils/ncbiOrthologs.ts +26 -28
  139. package/src/utils/ncbiTaxonomy.ts +17 -20
  140. package/src/utils/taxonomyNames.test.ts +41 -0
  141. package/src/utils/taxonomyNames.ts +33 -19
  142. package/src/utils/unirefHomologs.test.ts +11 -1
  143. package/src/utils/unirefHomologs.ts +2 -2
  144. package/src/version.ts +1 -1
  145. package/dist/LaunchMsaView/components/BlastQuery/blastLaunchView.d.ts +0 -16
  146. package/dist/LaunchMsaView/components/BlastQuery/blastLaunchView.js +0 -31
  147. package/dist/LaunchMsaView/components/ManualMSALoader/launchView.d.ts +0 -16
  148. package/dist/LaunchMsaView/components/ManualMSALoader/launchView.js +0 -16
  149. package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.d.ts +0 -9
  150. package/dist/LaunchMsaView/components/OrthologQuery/orthologLaunchView.js +0 -15
  151. package/dist/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.d.ts +0 -13
  152. package/dist/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.js +0 -22
  153. package/src/LaunchMsaView/components/BlastQuery/blastLaunchView.ts +0 -59
  154. package/src/LaunchMsaView/components/ManualMSALoader/launchView.ts +0 -43
  155. package/src/LaunchMsaView/components/OrthologQuery/orthologLaunchView.ts +0 -31
  156. package/src/LaunchMsaView/components/PreLoadedMSA/preCalculatedLaunchView.ts +0 -42
@@ -0,0 +1,78 @@
1
+ import { beforeEach, describe, expect, test, vi } from 'vitest';
2
+ import { bestEffort, createDbOpener } from './idb';
3
+ const { opens } = vi.hoisted(() => ({ opens: [] }));
4
+ vi.mock('idb', () => ({
5
+ openDB: (_name, _version, callbacks) => new Promise((resolve, reject) => {
6
+ opens.push({ callbacks, resolve, reject });
7
+ }),
8
+ }));
9
+ function fakeDb() {
10
+ return { close: vi.fn() };
11
+ }
12
+ beforeEach(() => {
13
+ opens.length = 0;
14
+ });
15
+ describe('createDbOpener', () => {
16
+ test('shares one connection between callers', async () => {
17
+ const getDB = createDbOpener('db', 1, () => { });
18
+ const first = getDB();
19
+ const second = getDB();
20
+ const db = fakeDb();
21
+ opens[0].resolve(db);
22
+ expect(await first).toBe(db);
23
+ expect(await second).toBe(db);
24
+ expect(opens.length).toBe(1);
25
+ });
26
+ test('a failed open is retried by the next caller', async () => {
27
+ const getDB = createDbOpener('db', 1, () => { });
28
+ const first = getDB();
29
+ opens[0].reject(new Error('private mode'));
30
+ await expect(first).rejects.toThrow('private mode');
31
+ void getDB();
32
+ expect(opens.length).toBe(2);
33
+ });
34
+ test('an open blocked by another tab rejects instead of waiting on it', async () => {
35
+ const getDB = createDbOpener('db', 2, () => { });
36
+ const first = getDB();
37
+ opens[0].callbacks.blocked(1, 2, {});
38
+ await expect(first).rejects.toThrow(/held open at version 1/);
39
+ // the upgrade goes through once the other tab lets go, and the connection
40
+ // nobody is waiting for any more is closed rather than leaked
41
+ const late = fakeDb();
42
+ opens[0].resolve(late);
43
+ await Promise.resolve();
44
+ expect(late.close).toHaveBeenCalled();
45
+ });
46
+ test('a connection blocking another tab closes and is reopened on next use', async () => {
47
+ const getDB = createDbOpener('db', 1, () => { });
48
+ const pending = getDB();
49
+ const db = fakeDb();
50
+ opens[0].resolve(db);
51
+ await pending;
52
+ const target = { close: vi.fn() };
53
+ opens[0].callbacks.blocking(1, 2, {
54
+ target,
55
+ });
56
+ expect(target.close).toHaveBeenCalled();
57
+ void getDB();
58
+ expect(opens.length).toBe(2);
59
+ });
60
+ test('a terminated connection is reopened on next use', async () => {
61
+ const getDB = createDbOpener('db', 1, () => { });
62
+ const pending = getDB();
63
+ opens[0].resolve(fakeDb());
64
+ await pending;
65
+ opens[0].callbacks.terminated();
66
+ void getDB();
67
+ expect(opens.length).toBe(2);
68
+ });
69
+ });
70
+ describe('bestEffort', () => {
71
+ test('answers the operation when it succeeds', async () => {
72
+ expect(await bestEffort('read', () => Promise.resolve(3), 0)).toBe(3);
73
+ });
74
+ test('answers the fallback when it throws', async () => {
75
+ vi.spyOn(console, 'warn').mockImplementation(() => { });
76
+ expect(await bestEffort('read', () => Promise.reject(new Error('quota')), 0)).toBe(0);
77
+ });
78
+ });
@@ -1,8 +1,8 @@
1
1
  import { getCachedDomains, saveDomains } from './domainCache';
2
- import { efetchUrl } from './eutils';
3
- import { textfetch } from './fetch';
2
+ import { decodeXmlEntities, efetchUrl, eutilsText } from './eutils';
4
3
  function field(xml, tag) {
5
- return new RegExp(`<${tag}>(.*?)</${tag}>`, 's').exec(xml)?.[1];
4
+ const text = new RegExp(`<${tag}>(.*?)</${tag}>`, 's').exec(xml)?.[1];
5
+ return text === undefined ? undefined : decodeXmlEntities(text);
6
6
  }
7
7
  function parseQualifiers(featureXml) {
8
8
  const quals = {};
@@ -136,7 +136,7 @@ export async function fetchProteinDomains(accessions) {
136
136
  const batchSize = 100;
137
137
  for (let i = 0; i < uncached.length; i += batchSize) {
138
138
  const batch = uncached.slice(i, i + batchSize);
139
- const xml = await textfetch(efetchUrl({
139
+ const xml = await eutilsText(efetchUrl({
140
140
  db: 'protein',
141
141
  id: batch.join(','),
142
142
  rettype: 'gp',
@@ -22,7 +22,7 @@ export declare function cleanGeneCandidate(raw: string): string;
22
22
  * itself; anything else is searched as a gene name within the query taxon.
23
23
  * Several candidate identifiers are tried in order, because a JBrowse feature
24
24
  * carries whatever its GFF/BigBed had — `id()`, `name`, `gene_name` — and only
25
- * some of those are real symbols.
25
+ * some of those are real symbols. `gene:TP53` and `TP53` are one search.
26
26
  */
27
27
  export declare function resolveGeneId(candidates: string[], taxId: number): Promise<{
28
28
  geneId: string;
@@ -14,13 +14,12 @@
14
14
  //
15
15
  // Mirrors jb2hubs' website/src/components/proteinMsa.ts assembler, trimmed to
16
16
  // what the launch dialog needs and using this plugin's fetch/eutils helpers.
17
- import { NCBI_EMAIL, NCBI_TOOL, efetchPost } from './eutils';
18
- import { jsonfetch, textfetch } from './fetch';
17
+ import { efetchPost, efetchUrl, eutilsJson, eutilsText, eutilsUrl, } from './eutils';
18
+ import { jsonfetch } from './fetch';
19
19
  // v2, not v2alpha: the alpha path still answers /orthologs but 404s
20
20
  // /product_report, so an assembler pointed at it silently resolves zero
21
21
  // representative proteins and reports "no orthologs" for every gene.
22
22
  const DATASETS = 'https://api.ncbi.nlm.nih.gov/datasets/v2';
23
- const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
24
23
  // NCBI's ortholog report IS the species panel. There is no list here to keep in
25
24
  // step with what NCBI knows, and the panel widens by itself as NCBI annotates
26
25
  // more genomes.
@@ -49,10 +48,6 @@ const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
49
48
  // a minute; EBI's own ceiling is 4000 sequences and 4MB, which even CFTR's full
50
49
  // set (1.3MB) sits inside.
51
50
  export const defaultMaxSpecies = 100;
52
- function ncbiUrl(url) {
53
- const sep = url.includes('?') ? '&' : '?';
54
- return `${url}${sep}tool=${NCBI_TOOL}&email=${encodeURIComponent(NCBI_EMAIL)}`;
55
- }
56
51
  /**
57
52
  * A candidate gene reference as the ortholog services know it: a GFF ID prefix
58
53
  * (gene:TP53) and a version suffix (NM_000546.6) stripped off. Shared with
@@ -70,20 +65,26 @@ export function cleanGeneCandidate(raw) {
70
65
  * itself; anything else is searched as a gene name within the query taxon.
71
66
  * Several candidate identifiers are tried in order, because a JBrowse feature
72
67
  * carries whatever its GFF/BigBed had — `id()`, `name`, `gene_name` — and only
73
- * some of those are real symbols.
68
+ * some of those are real symbols. `gene:TP53` and `TP53` are one search.
74
69
  */
75
70
  export async function resolveGeneId(candidates, taxId) {
71
+ const asked = new Set();
76
72
  for (const raw of candidates) {
77
73
  const query = raw.trim();
78
- if (!query) {
79
- continue;
80
- }
81
74
  if (/^\d+$/.test(query)) {
82
75
  return { geneId: query, matched: query };
83
76
  }
84
77
  const cleaned = cleanGeneCandidate(query);
85
- const term = `${cleaned}[Gene Name] AND ${taxId}[taxid]`;
86
- const json = await jsonfetch(ncbiUrl(`${EUTILS}/esearch.fcgi?db=gene&term=${encodeURIComponent(term)}&retmode=json&retmax=1`));
78
+ if (!cleaned || asked.has(cleaned)) {
79
+ continue;
80
+ }
81
+ asked.add(cleaned);
82
+ const json = await eutilsJson(eutilsUrl('esearch', {
83
+ db: 'gene',
84
+ term: `${cleaned}[Gene Name] AND ${taxId}[taxid]`,
85
+ retmode: 'json',
86
+ retmax: '1',
87
+ }));
87
88
  const geneId = json.esearchresult?.idlist?.[0];
88
89
  if (geneId) {
89
90
  return { geneId, matched: cleaned };
@@ -99,7 +100,7 @@ export async function resolveGeneId(candidates, taxId) {
99
100
  * taxon, which already has its own `<species>_query` row.
100
101
  */
101
102
  export async function fetchOrthologGenes(geneId, { taxa, exclude, limit = defaultMaxSpecies, } = {}) {
102
- const json = await jsonfetch(ncbiUrl(`${DATASETS}/gene/id/${geneId}/orthologs?returned_content=COMPLETE`));
103
+ const json = await jsonfetch(`${DATASETS}/gene/id/${geneId}/orthologs?returned_content=COMPLETE`);
103
104
  const byTaxon = new Map();
104
105
  for (const { gene } of json.reports ?? []) {
105
106
  const taxId = Number(gene?.tax_id);
@@ -143,7 +144,7 @@ export async function fetchRepresentativeProteins(geneIds) {
143
144
  const byGene = new Map();
144
145
  for (let i = 0; i < geneIds.length; i += PRODUCT_REPORT_CHUNK) {
145
146
  const chunk = geneIds.slice(i, i + PRODUCT_REPORT_CHUNK);
146
- const json = await jsonfetch(ncbiUrl(`${DATASETS}/gene/id/${chunk.join(',')}/product_report?page_size=${chunk.length}`));
147
+ const json = await jsonfetch(`${DATASETS}/gene/id/${chunk.join(',')}/product_report?page_size=${chunk.length}`);
147
148
  for (const { product } of json.reports ?? []) {
148
149
  const candidates = (product?.transcripts ?? [])
149
150
  .map(t => ({
@@ -220,7 +221,7 @@ export async function fetchProteinForGene(geneId) {
220
221
  if (!acc) {
221
222
  return undefined;
222
223
  }
223
- const seq = parseFasta(await textfetch(ncbiUrl(`${EUTILS}/efetch.fcgi?db=protein&id=${acc}&rettype=fasta&retmode=text`))).get(acc);
224
+ const seq = parseFasta(await eutilsText(efetchUrl({ db: 'protein', id: acc, rettype: 'fasta', retmode: 'text' }))).get(acc);
224
225
  return seq ? { accession: acc, sequence: seq } : undefined;
225
226
  }
226
227
  /**
@@ -242,7 +243,7 @@ export async function fetchOrthologRows({ geneId, taxa, exclude, limit, onProgre
242
243
  }
243
244
  onProgress(`Fetching ${withProtein.length} protein sequences...`);
244
245
  const accessions = withProtein.map(g => proteinByGene.get(g.geneId));
245
- const seqByAcc = parseFasta(await textfetch(...efetchPost({
246
+ const seqByAcc = parseFasta(await eutilsText(...efetchPost({
246
247
  db: 'protein',
247
248
  id: accessions.join(','),
248
249
  rettype: 'fasta',
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test, vi } from 'vitest';
2
- import { cleanGeneCandidate, dedupeLabels, fetchOrthologGenes, fetchRepresentativeProteins, parseFasta, } from './ncbiOrthologs';
2
+ import { cleanGeneCandidate, dedupeLabels, fetchOrthologGenes, fetchRepresentativeProteins, parseFasta, resolveGeneId, } from './ncbiOrthologs';
3
3
  describe('dedupeLabels', () => {
4
4
  test('sanitizes to single tokens', () => {
5
5
  // labels are used identically as FASTA headers, Newick leaf names and GFF
@@ -141,3 +141,19 @@ describe('the NCBI request ceilings', () => {
141
141
  expect(seen.every(u => /page_size=\d+/.test(u))).toBe(true);
142
142
  });
143
143
  });
144
+ // eutils allows three requests a second, so asking the same symbol twice is a
145
+ // request spent on nothing
146
+ test('resolveGeneId searches each cleaned symbol once', async () => {
147
+ const seen = [];
148
+ vi.stubGlobal('fetch', (url) => {
149
+ seen.push(url);
150
+ return Promise.resolve({
151
+ ok: true,
152
+ status: 200,
153
+ json: () => Promise.resolve({ esearchresult: { idlist: [] } }),
154
+ });
155
+ });
156
+ expect(await resolveGeneId(['gene:TP53', 'TP53', ' TP53 ', 'NM_000546.6'], 9606)).toBeUndefined();
157
+ expect(seen).toHaveLength(2);
158
+ vi.unstubAllGlobals();
159
+ });
@@ -1,15 +1,4 @@
1
- import { NCBI_EMAIL, NCBI_TOOL } from './eutils';
2
- import { jsonfetch } from './fetch';
3
- const EUTILS = 'https://eutils.ncbi.nlm.nih.gov/entrez/eutils';
4
- function eutilsUrl(endpoint, params) {
5
- const search = new URLSearchParams({
6
- ...params,
7
- retmode: 'json',
8
- tool: NCBI_TOOL,
9
- email: NCBI_EMAIL,
10
- });
11
- return `${EUTILS}/${endpoint}?${search.toString()}`;
12
- }
1
+ import { eutilsJson, eutilsUrl } from './eutils';
13
2
  /**
14
3
  * Free text -> NCBI taxon id. A bare number is taken as the id itself; anything
15
4
  * else is searched against db=taxonomy, which resolves a scientific name
@@ -28,7 +17,12 @@ export async function resolveTaxId(query) {
28
17
  if (/^\d+$/.test(term)) {
29
18
  return Number(term);
30
19
  }
31
- const json = await jsonfetch(eutilsUrl('esearch.fcgi', { db: 'taxonomy', term, retmax: '1' }));
20
+ const json = await eutilsJson(eutilsUrl('esearch', {
21
+ db: 'taxonomy',
22
+ term,
23
+ retmax: '1',
24
+ retmode: 'json',
25
+ }));
32
26
  const id = json.esearchresult?.idlist?.[0];
33
27
  return id ? Number(id) : undefined;
34
28
  }
@@ -52,12 +46,17 @@ export async function resolveAssemblySpecies(assemblyName) {
52
46
  if (!term) {
53
47
  return undefined;
54
48
  }
55
- const search = await jsonfetch(eutilsUrl('esearch.fcgi', { db: 'assembly', term, retmax: '1' }));
49
+ const search = await eutilsJson(eutilsUrl('esearch', {
50
+ db: 'assembly',
51
+ term,
52
+ retmax: '1',
53
+ retmode: 'json',
54
+ }));
56
55
  const uid = search.esearchresult?.idlist?.[0];
57
56
  if (!uid) {
58
57
  return undefined;
59
58
  }
60
- const summary = await jsonfetch(eutilsUrl('esummary.fcgi', { db: 'assembly', id: uid }));
59
+ const summary = await eutilsJson(eutilsUrl('esummary', { db: 'assembly', id: uid, retmode: 'json' }));
61
60
  const record = summary.result?.[uid];
62
61
  const taxId = Number(record?.speciestaxid);
63
62
  return record?.speciesname && taxId
@@ -1,6 +1,5 @@
1
- import { efetchUrl } from './eutils';
2
- import { textfetch } from './fetch';
3
- import { createDbOpener } from './idb';
1
+ import { decodeXmlEntities, efetchUrl, eutilsText } from './eutils';
2
+ import { bestEffort, createDbOpener } from './idb';
4
3
  const DB_NAME = 'jbrowse-msaview-taxonomy-cache';
5
4
  const STORE_NAME = 'common-names';
6
5
  const DB_VERSION = 2;
@@ -10,20 +9,24 @@ const getDB = createDbOpener(DB_NAME, DB_VERSION, db => {
10
9
  }
11
10
  db.createObjectStore(STORE_NAME, { keyPath: 'taxid' });
12
11
  });
13
- async function getCachedTaxonomies(taxids) {
14
- const db = await getDB();
15
- const tx = db.transaction(STORE_NAME, 'readonly');
16
- const results = await Promise.all(taxids.map(taxid => tx.store.get(taxid)));
17
- await tx.done;
18
- return results;
12
+ function getCachedTaxonomies(taxids) {
13
+ return bestEffort('taxonomy cache read', async () => {
14
+ const db = await getDB();
15
+ const tx = db.transaction(STORE_NAME, 'readonly');
16
+ const results = await Promise.all(taxids.map(taxid => tx.store.get(taxid)));
17
+ await tx.done;
18
+ return results;
19
+ }, []);
19
20
  }
20
- async function saveTaxonomyCache(entries) {
21
- const db = await getDB();
22
- const tx = db.transaction(STORE_NAME, 'readwrite');
23
- for (const entry of entries) {
24
- await tx.store.put(entry);
25
- }
26
- await tx.done;
21
+ function saveTaxonomyCache(entries) {
22
+ return bestEffort('taxonomy cache write', async () => {
23
+ const db = await getDB();
24
+ const tx = db.transaction(STORE_NAME, 'readwrite');
25
+ for (const entry of entries) {
26
+ await tx.store.put(entry);
27
+ }
28
+ await tx.done;
29
+ }, undefined);
27
30
  }
28
31
  export async function fetchTaxonomyInfo(taxidsWithRepeats) {
29
32
  // callers pass one taxid per alignment row, and a BLAST hit list is several
@@ -60,7 +63,7 @@ export async function fetchTaxonomyInfo(taxidsWithRepeats) {
60
63
  // error body that the regexes below silently find nothing in, so without
61
64
  // the status check a throttled batch looks like "these taxa have no
62
65
  // names" instead of reporting why
63
- const text = await textfetch(efetchUrl({ db: 'taxonomy', id: idsParam, retmode: 'xml' }));
66
+ const text = await eutilsText(efetchUrl({ db: 'taxonomy', id: idsParam, retmode: 'xml' }));
64
67
  // Build a map of taxid -> taxon block by finding Taxon elements.
65
68
  // Prefer entries with <LineageEx> (full top-level entries) over nested
66
69
  // entries inside another taxon's LineageEx
@@ -102,8 +105,9 @@ export async function fetchTaxonomyInfo(taxidsWithRepeats) {
102
105
  const genbankCommon = /<GenbankCommonName>(.*?)<\/GenbankCommonName>/.exec(taxonXml);
103
106
  const commonName = /<CommonName>(.*?)<\/CommonName>/.exec(taxonXml);
104
107
  const sciName = /<ScientificName>(.*?)<\/ScientificName>/.exec(taxonXml);
105
- const name = genbankCommon?.[1] ?? commonName?.[1];
106
- const sci = sciName?.[1] ?? '';
108
+ const rawName = genbankCommon?.[1] ?? commonName?.[1];
109
+ const name = rawName && decodeXmlEntities(rawName);
110
+ const sci = decodeXmlEntities(sciName?.[1] ?? '');
107
111
  result.set(taxid, { sciname: sci, commonName: name });
108
112
  toCache.push({ taxid, sciname: sci, commonName: name });
109
113
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,33 @@
1
+ import { afterEach, expect, test, vi } from 'vitest';
2
+ import { fetchTaxonomyInfo } from './taxonomyNames';
3
+ vi.mock('./idb', async (importOriginal) => ({
4
+ ...(await importOriginal()),
5
+ createDbOpener: () => () => Promise.reject(new DOMException('site data blocked', 'SecurityError')),
6
+ }));
7
+ const XML = `<TaxaSet>
8
+ <Taxon>
9
+ <TaxId>10090</TaxId>
10
+ <ScientificName>Mus musculus</ScientificName>
11
+ <OtherNames><GenbankCommonName>house mouse</GenbankCommonName></OtherNames>
12
+ <LineageEx></LineageEx>
13
+ </Taxon>
14
+ </TaxaSet>`;
15
+ afterEach(() => {
16
+ vi.unstubAllGlobals();
17
+ vi.restoreAllMocks();
18
+ });
19
+ // runs between the search and the alignment, so a throw here discarded a BLAST
20
+ // job that had already finished
21
+ test('a browser without IndexedDB still gets the names from NCBI', async () => {
22
+ vi.spyOn(console, 'warn').mockImplementation(() => { });
23
+ vi.stubGlobal('fetch', () => Promise.resolve({
24
+ ok: true,
25
+ status: 200,
26
+ text: () => Promise.resolve(XML),
27
+ }));
28
+ const info = await fetchTaxonomyInfo([10090]);
29
+ expect(info.get(10090)).toEqual({
30
+ sciname: 'Mus musculus',
31
+ commonName: 'house mouse',
32
+ });
33
+ });
@@ -71,14 +71,14 @@ export async function resolveUniProtEntry(candidates, taxId, signal) {
71
71
  continue;
72
72
  }
73
73
  if (UNIPROT_ACCESSION.test(candidate)) {
74
- const json = await jsonfetch(`${UNIPROT}/uniprotkb/${candidate.split('-')[0]}?fields=${ENTRY_FIELDS}`, { signal });
74
+ const json = await jsonfetch(`${UNIPROT}/uniprotkb/${candidate}?fields=${ENTRY_FIELDS}`, { signal });
75
75
  const entry = parseEntry(json);
76
76
  if (entry) {
77
77
  return entry;
78
78
  }
79
79
  continue;
80
80
  }
81
- const query = `gene_exact:${candidate} AND organism_id:${taxId}`;
81
+ const query = `gene_exact:"${candidate.replaceAll('"', '')}" AND organism_id:${taxId}`;
82
82
  const json = await jsonfetch(`${UNIPROT}/uniprotkb/search?query=${encodeURIComponent(query)}&fields=${ENTRY_FIELDS}&size=25`, { signal });
83
83
  const [best] = parseEntries(json).sort(rank);
84
84
  if (best) {
@@ -114,5 +114,12 @@ test('resolveUniProtEntry fetches an accession directly and searches a symbol',
114
114
  expect((await resolveUniProtEntry(['P04637'], 9606))?.accession).toBe('P04637');
115
115
  expect(calls[0]).toContain('/uniprotkb/P04637?');
116
116
  expect((await resolveUniProtEntry(['gene:TP53'], 9606))?.accession).toBe('Q2');
117
- expect(decodeURIComponent(calls[1])).toContain('gene_exact:TP53 AND organism_id:9606');
117
+ expect(decodeURIComponent(calls[1])).toContain('gene_exact:"TP53" AND organism_id:9606');
118
+ });
119
+ // the isoform is the query: dropping the suffix launched the canonical
120
+ // sequence in its place, 393 residues for P04637-2's 341
121
+ test('resolveUniProtEntry keeps an isoform suffix', async () => {
122
+ const calls = stubFetch([{ body: entry('P04637-2', 9606) }]);
123
+ expect((await resolveUniProtEntry(['P04637-2'], 9606))?.accession).toBe('P04637-2');
124
+ expect(calls[0]).toContain('/uniprotkb/P04637-2?');
118
125
  });
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const version = "3.6.0";
1
+ export declare const version = "3.7.0";
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const version = '3.6.0';
1
+ export const version = '3.7.0';
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "3.6.0",
2
+ "version": "3.7.0",
3
3
  "license": "MIT",
4
4
  "name": "jbrowse-plugin-msaview",
5
5
  "repository": {
@@ -25,28 +25,28 @@
25
25
  "@emotion/react": "^11.14.0",
26
26
  "@eslint/js": "^10.0.1",
27
27
  "@fal-works/esbuild-plugin-global-externals": "^2.1.2",
28
- "@jbrowse/core": "^5.0.0-beta.8",
28
+ "@jbrowse/core": "5.0.0-beta.8",
29
29
  "@jbrowse/mobx-state-tree": "^6.5.1",
30
- "@jbrowse/plugin-linear-genome-view": "^5.0.0-beta.8",
30
+ "@jbrowse/plugin-linear-genome-view": "5.0.0-beta.8",
31
31
  "@mui/icons-material": "^9.4.0",
32
32
  "@mui/material": "^9.4.0",
33
33
  "@mui/system": "^9.4.0",
34
- "@mui/x-data-grid": "^9.13.0",
34
+ "@mui/x-data-grid": "^9.14.0",
35
35
  "@testing-library/dom": "^10.4.2",
36
36
  "@testing-library/react": "^16.3.3",
37
- "@types/node": "^26.6.0",
37
+ "@types/node": "^26.6.1",
38
38
  "@types/react": "^19.3.0",
39
39
  "esbuild": "^0.28.2",
40
40
  "eslint": "^10.10.0",
41
41
  "eslint-plugin-import-x": "^4.17.1",
42
42
  "eslint-plugin-react": "^7.37.5",
43
43
  "eslint-plugin-react-hooks": "^7.1.1",
44
- "eslint-plugin-unicorn": "^74.0.0",
44
+ "eslint-plugin-unicorn": "^75.0.0",
45
45
  "git-cliff": "^2.13.1",
46
- "jsdom": "^30.0.1",
46
+ "jsdom": "^30.1.0",
47
47
  "mobx": "^7.0.3",
48
48
  "mobx-react": "^10.0.2",
49
- "msa-parsers": "^8.0.0",
49
+ "msa-parsers": "^8.1.0",
50
50
  "oxfmt": "^0.68.0",
51
51
  "oxlint": "^1.83.0",
52
52
  "oxlint-tsgolint": "^7.0.2001",
@@ -56,7 +56,7 @@
56
56
  "puppeteer": "^25.11.0",
57
57
  "react": "^19.3.0",
58
58
  "react-dom": "^19.3.0",
59
- "react-msaview": "^8.0.0",
59
+ "react-msaview": "^8.1.0",
60
60
  "rimraf": "^6.1.3",
61
61
  "rxjs": "^7.8.2",
62
62
  "serve": "^14.2.6",
@@ -0,0 +1,91 @@
1
+ import { SimpleFeature } from '@jbrowse/core/util'
2
+ import { genomeToTranscriptSeqMapping } from 'g2p_mapper'
3
+ import { describe, expect, test } from 'vitest'
4
+
5
+ import { transcriptPosToVisibleCol } from '../MsaViewPanel/util'
6
+ import { getProteinSequenceFromFeature } from './components/calculateProteinSequence'
7
+ import { findQueryRow } from './detectQueryRow'
8
+ import { cleanProteinSequence } from './util'
9
+
10
+ describe('cleanProteinSequence', () => {
11
+ test('drops the stop and a trailing partial codon', () => {
12
+ expect(cleanProteinSequence('MAWK*')).toBe('MAWK')
13
+ expect(cleanProteinSequence('MAWK&')).toBe('MAWK')
14
+ })
15
+
16
+ test('keeps a residue for every other codon', () => {
17
+ expect(cleanProteinSequence('&MAWK*')).toBe('XMAWK')
18
+ expect(cleanProteinSequence('MA*WK*')).toBe('MAXWK')
19
+ expect(cleanProteinSequence('MA&WK')).toBe('MAXWK')
20
+ })
21
+ })
22
+
23
+ // what the genome view hovers is a codon, and what lights is the query row's
24
+ // residue at g2p's number for that codon, so the two have to count alike
25
+ describe('a launched query row read through g2p', () => {
26
+ function rowResidueUnder(genomePos: number, json: Record<string, unknown>) {
27
+ const seq = json.seq as string
28
+ const feature = new SimpleFeature(json as never)
29
+ const row = cleanProteinSequence(
30
+ getProteinSequenceFromFeature({ seq, feature }),
31
+ )
32
+ const { g2p } = genomeToTranscriptSeqMapping(feature.toJSON() as never)
33
+ const col = transcriptPosToVisibleCol(
34
+ {
35
+ querySeqName: 'QUERY',
36
+ querySeqOffset: 0,
37
+ seqPosToVisibleCol: (_, p) => (p < row.length ? p : row.length),
38
+ visibleColToSeqPos: (_, c) => (c < row.length ? c : undefined),
39
+ },
40
+ g2p[genomePos]!,
41
+ )
42
+ return row[col!]
43
+ }
44
+
45
+ function transcript(seq: string, cds: Record<string, unknown>) {
46
+ return {
47
+ uniqueId: 't1',
48
+ refName: 'chr1',
49
+ start: 0,
50
+ end: seq.length,
51
+ strand: 1,
52
+ type: 'mRNA',
53
+ seq,
54
+ subfeatures: [
55
+ { uniqueId: 'c1', refName: 'chr1', start: 0, end: seq.length, ...cds },
56
+ ],
57
+ }
58
+ }
59
+
60
+ test('after a partial first codon', () => {
61
+ // G | ATG GCC TGG AAA TAA: base 4 opens GCC, alanine
62
+ const json = transcript('GATGGCCTGGAAATAA', { type: 'CDS', phase: 1 })
63
+ expect(rowResidueUnder(4, json)).toBe('A')
64
+ expect(rowResidueUnder(10, json)).toBe('K')
65
+ })
66
+
67
+ test('after an internal stop', () => {
68
+ // ATG TGA GCC TGG TAA: a selenocysteine GENCODE does not annotate
69
+ const json = transcript('ATGTGAGCCTGGTAA', { type: 'CDS' })
70
+ expect(rowResidueUnder(6, json)).toBe('A')
71
+ })
72
+ })
73
+
74
+ describe('query row detection', () => {
75
+ test('matches a row a search launch built from a partial transcript', () => {
76
+ const msa = '>QUERY\nXMAWK\n>other\nMAWR-'
77
+ expect(findQueryRow(msa, '&MAWK*').match).toMatchObject({
78
+ name: 'QUERY',
79
+ quality: 'exact',
80
+ offset: 0,
81
+ })
82
+ })
83
+
84
+ test('still places a row that starts at the first whole codon', () => {
85
+ const msa = '>Query_1\nMAWK\n>other\nMAWR'
86
+ expect(findQueryRow(msa, '&MAWK*').match).toMatchObject({
87
+ name: 'Query_1',
88
+ offset: 1,
89
+ })
90
+ })
91
+ })