Skip to content

Commit bdccbd2

Browse files
committed
Save track data
Convert to AbstractTrackModel Fix strand for gff3 save track data (#3688)
1 parent d4b70fb commit bdccbd2

File tree

22 files changed

+748
-80
lines changed

22 files changed

+748
-80
lines changed
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { BaseOptions, checkRefName, RefNameAliases } from './util'
2+
import RpcManager from '../rpc/RpcManager'
3+
import { when } from '../util'
4+
5+
export interface BasicRegion {
6+
start: number
7+
end: number
8+
refName: string
9+
assemblyName: string
10+
}
11+
12+
export async function loadRefNameMap(
13+
assembly: {
14+
name: string
15+
regions: BasicRegion[] | undefined
16+
refNameAliases: RefNameAliases | undefined
17+
getCanonicalRefName: (arg: string) => string
18+
rpcManager: RpcManager
19+
},
20+
adapterConfig: unknown,
21+
options: BaseOptions,
22+
signal?: AbortSignal,
23+
) {
24+
const { sessionId } = options
25+
await when(() => !!(assembly.regions && assembly.refNameAliases), {
26+
signal,
27+
name: 'when assembly ready',
28+
})
29+
30+
const refNames = (await assembly.rpcManager.call(
31+
sessionId,
32+
'CoreGetRefNames',
33+
{
34+
adapterConfig,
35+
signal,
36+
...options,
37+
},
38+
{ timeout: 1000000 },
39+
)) as string[]
40+
41+
const { refNameAliases } = assembly
42+
if (!refNameAliases) {
43+
throw new Error(`error loading assembly ${assembly.name}'s refNameAliases`)
44+
}
45+
46+
const refNameMap = Object.fromEntries(
47+
refNames.map(name => {
48+
checkRefName(name)
49+
return [assembly.getCanonicalRefName(name), name]
50+
}),
51+
)
52+
53+
// make the reversed map too
54+
const reversed = Object.fromEntries(
55+
Object.entries(refNameMap).map(([canonicalName, adapterName]) => [
56+
adapterName,
57+
canonicalName,
58+
]),
59+
)
60+
61+
return {
62+
forwardMap: refNameMap,
63+
reverseMap: reversed,
64+
}
65+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { AnyConfigurationModel } from '../configuration'
2+
import jsonStableStringify from 'json-stable-stringify'
3+
import { BaseRefNameAliasAdapter } from '../data_adapters/BaseAdapter'
4+
import PluginManager from '../PluginManager'
5+
import { BasicRegion } from './loadRefNameMap'
6+
7+
export type RefNameAliases = Record<string, string>
8+
9+
export interface BaseOptions {
10+
signal?: AbortSignal
11+
sessionId: string
12+
statusCallback?: Function
13+
}
14+
15+
export async function getRefNameAliases(
16+
config: AnyConfigurationModel,
17+
pm: PluginManager,
18+
signal?: AbortSignal,
19+
) {
20+
const type = pm.getAdapterType(config.type)
21+
const CLASS = await type.getAdapterClass()
22+
const adapter = new CLASS(config, undefined, pm) as BaseRefNameAliasAdapter
23+
return adapter.getRefNameAliases({ signal })
24+
}
25+
26+
export async function getCytobands(
27+
config: AnyConfigurationModel,
28+
pm: PluginManager,
29+
) {
30+
const type = pm.getAdapterType(config.type)
31+
const CLASS = await type.getAdapterClass()
32+
const adapter = new CLASS(config, undefined, pm)
33+
34+
// @ts-expect-error
35+
return adapter.getData()
36+
}
37+
38+
export async function getAssemblyRegions(
39+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
40+
assembly: any,
41+
adapterConfig: AnyConfigurationModel,
42+
signal?: AbortSignal,
43+
): Promise<BasicRegion[]> {
44+
const sessionId = 'loadRefNames'
45+
return assembly.rpcManager.call(
46+
sessionId,
47+
'CoreGetRegions',
48+
{
49+
adapterConfig,
50+
sessionId,
51+
signal,
52+
},
53+
{ timeout: 1000000 },
54+
)
55+
}
56+
57+
const refNameRegex = new RegExp(
58+
'[0-9A-Za-z!#$%&+./:;?@^_|~-][0-9A-Za-z!#$%&*+./:;=?@^_|~-]*',
59+
)
60+
61+
// Valid refName pattern from https://samtools.github.io/hts-specs/SAMv1.pdf
62+
export function checkRefName(refName: string) {
63+
if (!refNameRegex.test(refName)) {
64+
throw new Error(`Encountered invalid refName: "${refName}"`)
65+
}
66+
}
67+
68+
export function getAdapterId(adapterConf: unknown) {
69+
return jsonStableStringify(adapterConf)
70+
}

packages/core/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"dompurify": "^3.0.0",
4545
"escape-html": "^1.0.3",
4646
"fast-deep-equal": "^3.1.3",
47+
"file-saver": "^2.0.0",
4748
"generic-filehandle": "^3.0.0",
4849
"http-range-fetcher": "^1.4.0",
4950
"is-object": "^1.0.1",

packages/core/pluggableElementTypes/models/BaseTrackModel.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { lazy } from 'react'
12
import { transaction } from 'mobx'
23
import {
34
getRoot,
@@ -16,10 +17,14 @@ import {
1617
} from '../../configuration'
1718
import PluginManager from '../../PluginManager'
1819
import { MenuItem } from '../../ui'
20+
import { Save } from '../../ui/Icons'
1921
import { getContainingView, getEnv, getSession } from '../../util'
2022
import { isSessionModelWithConfigEditing } from '../../util/types'
2123
import { ElementId } from '../../util/types/mst'
2224

25+
// lazies
26+
const SaveTrackDataDlg = lazy(() => import('./components/SaveTrackData'))
27+
2328
export function getCompatibleDisplays(self: IAnyStateTreeNode) {
2429
const { pluginManager } = getEnv(self)
2530
const view = getContainingView(self)
@@ -211,6 +216,16 @@ export function createBaseTrackModel(
211216

212217
return [
213218
...menuItems,
219+
{
220+
label: 'Save track data',
221+
icon: Save,
222+
onClick: () => {
223+
getSession(self).queueDialog(handleClose => [
224+
SaveTrackDataDlg,
225+
{ model: self, handleClose },
226+
])
227+
},
228+
},
214229
...(compatDisp.length > 1
215230
? [
216231
{
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
import React, { useEffect, useState } from 'react'
2+
import {
3+
Button,
4+
DialogActions,
5+
DialogContent,
6+
FormControl,
7+
FormControlLabel,
8+
FormLabel,
9+
Radio,
10+
RadioGroup,
11+
TextField,
12+
Typography,
13+
} from '@mui/material'
14+
import { IAnyStateTreeNode } from 'mobx-state-tree'
15+
import { makeStyles } from 'tss-react/mui'
16+
import { saveAs } from 'file-saver'
17+
import { observer } from 'mobx-react'
18+
import { Dialog, ErrorMessage, LoadingEllipses } from '@jbrowse/core/ui'
19+
import {
20+
getSession,
21+
getContainingView,
22+
Feature,
23+
Region,
24+
AbstractTrackModel,
25+
} from '@jbrowse/core/util'
26+
import { getConf } from '@jbrowse/core/configuration'
27+
28+
// icons
29+
import GetAppIcon from '@mui/icons-material/GetApp'
30+
31+
// locals
32+
import { stringifyGFF3 } from './gff3'
33+
import { stringifyGenbank } from './genbank'
34+
35+
const useStyles = makeStyles()({
36+
root: {
37+
width: '80em',
38+
},
39+
textAreaFont: {
40+
fontFamily: 'Courier New',
41+
},
42+
})
43+
44+
async function fetchFeatures(
45+
track: IAnyStateTreeNode,
46+
regions: Region[],
47+
signal?: AbortSignal,
48+
) {
49+
const { rpcManager } = getSession(track)
50+
const adapterConfig = getConf(track, ['adapter'])
51+
const sessionId = 'getFeatures'
52+
return rpcManager.call(sessionId, 'CoreGetFeatures', {
53+
adapterConfig,
54+
regions,
55+
sessionId,
56+
signal,
57+
}) as Promise<Feature[]>
58+
}
59+
60+
export default observer(function SaveTrackDataDlg({
61+
model,
62+
handleClose,
63+
}: {
64+
model: AbstractTrackModel
65+
handleClose: () => void
66+
}) {
67+
const { classes } = useStyles()
68+
const [error, setError] = useState<unknown>()
69+
const [features, setFeatures] = useState<Feature[]>()
70+
const [type, setType] = useState('gff3')
71+
const [str, setStr] = useState('')
72+
const options = {
73+
gff3: { name: 'GFF3', extension: 'gff3' },
74+
genbank: { name: 'GenBank', extension: 'genbank' },
75+
}
76+
77+
useEffect(() => {
78+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
79+
;(async () => {
80+
try {
81+
const view = getContainingView(model) as { visibleRegions?: Region[] }
82+
setError(undefined)
83+
setFeatures(await fetchFeatures(model, view.visibleRegions || []))
84+
} catch (e) {
85+
console.error(e)
86+
setError(e)
87+
}
88+
})()
89+
}, [model])
90+
91+
useEffect(() => {
92+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
93+
;(async () => {
94+
try {
95+
const view = getContainingView(model)
96+
const session = getSession(model)
97+
if (!features) {
98+
return
99+
}
100+
const str = await (type === 'gff3'
101+
? stringifyGFF3(features)
102+
: stringifyGenbank({
103+
features,
104+
session,
105+
assemblyName: view.dynamicBlocks.contentBlocks[0].assemblyName,
106+
}))
107+
108+
setStr(str)
109+
} catch (e) {
110+
setError(e)
111+
}
112+
})()
113+
}, [type, features, model])
114+
115+
return (
116+
<Dialog maxWidth="xl" open onClose={handleClose} title="Save track data">
117+
<DialogContent className={classes.root}>
118+
{error ? <ErrorMessage error={error} /> : null}
119+
{!features ? (
120+
<LoadingEllipses />
121+
) : !features.length ? (
122+
<Typography>No features found</Typography>
123+
) : null}
124+
125+
<FormControl>
126+
<FormLabel>File type</FormLabel>
127+
<RadioGroup value={type} onChange={e => setType(e.target.value)}>
128+
{Object.entries(options).map(([key, val]) => (
129+
<FormControlLabel
130+
key={key}
131+
value={key}
132+
control={<Radio />}
133+
label={val.name}
134+
/>
135+
))}
136+
</RadioGroup>
137+
</FormControl>
138+
<TextField
139+
variant="outlined"
140+
multiline
141+
minRows={5}
142+
maxRows={15}
143+
fullWidth
144+
value={str}
145+
InputProps={{
146+
readOnly: true,
147+
classes: {
148+
input: classes.textAreaFont,
149+
},
150+
}}
151+
/>
152+
</DialogContent>
153+
<DialogActions>
154+
<Button
155+
onClick={() => {
156+
const ext = options[type as keyof typeof options].extension
157+
const blob = new Blob([str], { type: 'text/plain;charset=utf-8' })
158+
saveAs(blob, `jbrowse_track_data.${ext}`)
159+
}}
160+
startIcon={<GetAppIcon />}
161+
>
162+
Download
163+
</Button>
164+
165+
<Button variant="contained" type="submit" onClick={() => handleClose()}>
166+
Close
167+
</Button>
168+
</DialogActions>
169+
</Dialog>
170+
)
171+
})

0 commit comments

Comments
 (0)