diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ad012ff055382bca558992c62db278128e636e2..db302d901ac30dba3e8f90f1dfd9157ed24fa60e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Note that since we don't clearly distinguish between a public and private interf - Fix handling of mmcif with empty ``label_*`` fields - Add LoadTrajectory action +- Add Zenodo import extension (load structures, trajectories, and volumes) ## [v3.3.1] - 2022-02-27 diff --git a/src/apps/viewer/app.ts b/src/apps/viewer/app.ts index af6afd97b418dca8dfa28700a82a40b53c80296a..ed48e52f86ead319748dc63108a218f77fa0e2b7 100644 --- a/src/apps/viewer/app.ts +++ b/src/apps/viewer/app.ts @@ -17,6 +17,7 @@ import { ModelExport } from '../../extensions/model-export'; import { Mp4Export } from '../../extensions/mp4-export'; import { PDBeStructureQualityReport } from '../../extensions/pdbe'; import { RCSBAssemblySymmetry, RCSBValidationReport } from '../../extensions/rcsb'; +import { ZenodoImport } from '../../extensions/zenodo'; import { Volume } from '../../mol-model/volume'; import { DownloadStructure, PdbDownloadProvider } from '../../mol-plugin-state/actions/structure'; import { DownloadDensity } from '../../mol-plugin-state/actions/volume'; @@ -63,6 +64,7 @@ const Extensions = { 'mp4-export': PluginSpec.Behavior(Mp4Export), 'geo-export': PluginSpec.Behavior(GeometryExport), 'ma-quality-assessment': PluginSpec.Behavior(MAQualityAssessment), + 'zenodo-import': PluginSpec.Behavior(ZenodoImport), }; const DefaultViewerOptions = { diff --git a/src/extensions/zenodo/index.ts b/src/extensions/zenodo/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d09b84276a165ff7e32d9ef271f42f4033dea259 --- /dev/null +++ b/src/extensions/zenodo/index.ts @@ -0,0 +1,30 @@ +/** + * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author Alexander Rose <alexander.rose@weirdbyte.de> + */ + +import { PluginBehavior } from '../../mol-plugin/behavior/behavior'; +import { ZenodoImportUI } from './ui'; + +export const ZenodoImport = PluginBehavior.create<{ }>({ + name: 'extension-zenodo-import', + category: 'misc', + display: { + name: 'Zenodo Export' + }, + ctor: class extends PluginBehavior.Handler<{ }> { + register(): void { + this.ctx.customStructureControls.set('zenodo-import', ZenodoImportUI as any); + } + + update() { + return false; + } + + unregister() { + this.ctx.customStructureControls.delete('zenodo-import'); + } + }, + params: () => ({ }) +}); \ No newline at end of file diff --git a/src/extensions/zenodo/ui.tsx b/src/extensions/zenodo/ui.tsx new file mode 100644 index 0000000000000000000000000000000000000000..68c5ea3619adff22b2221e6e1827e66c8669ee03 --- /dev/null +++ b/src/extensions/zenodo/ui.tsx @@ -0,0 +1,269 @@ +/** + * Copyright (c) 2022 mol* contributors, licensed under MIT, See LICENSE file for more info. + * + * @author Alexander Rose <alexander.rose@weirdbyte.de> + */ + +import { DownloadStructure, LoadTrajectory } from '../../mol-plugin-state/actions/structure'; +import { DownloadDensity } from '../../mol-plugin-state/actions/volume'; +import { TrajectoryFormatCategory } from '../../mol-plugin-state/formats/trajectory'; +import { VolumeFormatCategory } from '../../mol-plugin-state/formats/volume'; +import { CollapsableControls, CollapsableState } from '../../mol-plugin-ui/base'; +import { Button } from '../../mol-plugin-ui/controls/common'; +import { OpenInBrowserSvg } from '../../mol-plugin-ui/controls/icons'; +import { ParameterControls } from '../../mol-plugin-ui/controls/parameters'; +import { PluginContext } from '../../mol-plugin/context'; +import { ParamDefinition as PD } from '../../mol-util/param-definition'; + +type ZenodoFile = { + bucket: string + checksum: string + key: string + links: { + [key: string]: string + self: string + } + size: number + type: string +} + +type ZenodoRecord = { + id: number + conceptdoi: string + conceptrecid: string + created: string + doi: string + files: ZenodoFile[] + revision: number + updated: string + metadata: { + title: string + } +} + +interface State { + busy?: boolean + recordValues: PD.Values<typeof ZenodoImportParams> + importValues?: PD.Values<ImportParams> + importParams?: ImportParams + record?: ZenodoRecord + files?: ZenodoFile[] +} + +const ZenodoImportParams = { + record: PD.Text('847637', { description: 'Zenodo ID.' }) +}; + +function createImportParams(files: ZenodoFile[], plugin: PluginContext) { + const modelOpts: [string, string][] = []; + const topologyOpts: [string, string][] = []; + const coordinatesOpts: [string, string][] = []; + const volumeOpts: [string, string][] = []; + + const structureExts = new Map<string, { format: string, isBinary: boolean }>(); + const volumeExts = new Map<string, { format: string, isBinary: boolean }>(); + for (const { provider: { category, binaryExtensions, stringExtensions }, name } of plugin.dataFormats.list) { + if (category === TrajectoryFormatCategory) { + if (binaryExtensions) for (const e of binaryExtensions) structureExts.set(e, { format: name, isBinary: true }); + if (stringExtensions) for (const e of stringExtensions) structureExts.set(e, { format: name, isBinary: false }); + } else if (category === VolumeFormatCategory) { + if (binaryExtensions) for (const e of binaryExtensions) volumeExts.set(e, { format: name, isBinary: true }); + if (stringExtensions) for (const e of stringExtensions) volumeExts.set(e, { format: name, isBinary: false }); + } + } + + for (const file of files) { + if (structureExts.has(file.type)) { + const { format, isBinary } = structureExts.get(file.type)!; + modelOpts.push([`${file.links.self}|${format}|${isBinary}`, file.key]); + topologyOpts.push([`${file.links.self}|${format}|${isBinary}`, file.key]); + } else if (volumeExts.has(file.type)) { + const { format, isBinary } = volumeExts.get(file.type)!; + volumeOpts.push([`${file.links.self}|${format}|${isBinary}`, file.key]); + } else if (file.type === 'psf') { + topologyOpts.push([`${file.links.self}|${file.type}|false`, file.key]); + } else if (file.type === 'xtc' || file.type === 'dcd') { + coordinatesOpts.push([`${file.links.self}|${file.type}|true`, file.key]); + } + } + + const params: PD.Params = {}; + let defaultType = ''; + + if (modelOpts.length) { + defaultType = 'structure'; + params.structure = PD.Select(modelOpts[0][0], modelOpts); + } + + if (modelOpts.length && topologyOpts.length) { + if (!defaultType) defaultType = 'trajectory'; + params.trajectory = PD.Group({ + topology: PD.Select(topologyOpts[0][0], topologyOpts), + coordinates: PD.Select(coordinatesOpts[0][0], coordinatesOpts), + }, { isFlat: true }); + } + + if (volumeOpts.length) { + if (!defaultType) defaultType = 'volume'; + params.volume = PD.Select(volumeOpts[0][0], volumeOpts); + } + + return { + type: PD.MappedStatic(defaultType, Object.keys(params).length ? params : { '': PD.EmptyGroup() }) + }; +} +type ImportParams = ReturnType<typeof createImportParams> + +export class ZenodoImportUI extends CollapsableControls<{}, State> { + protected defaultState(): State & CollapsableState { + return { + header: 'Zenodo Import', + isCollapsed: true, + brand: { accent: 'cyan', svg: OpenInBrowserSvg }, + recordValues: PD.getDefaultValues(ZenodoImportParams), + importValues: undefined, + importParams: undefined, + record: undefined, + files: undefined, + }; + } + + private recordParamsOnChange = (values: any) => { + this.setState({ recordValues: values }); + }; + + private importParamsOnChange = (values: any) => { + this.setState({ importValues: values }); + }; + + private loadRecord = async () => { + try { + this.setState({ busy: true }); + const record: ZenodoRecord = await this.plugin.runTask(this.plugin.fetch({ url: `https://zenodo.org/api/records/${this.state.recordValues.record}`, type: 'json' })); + const importParams = createImportParams(record.files, this.plugin); + this.setState({ + record, + files: record.files, + busy: false, + importValues: PD.getDefaultValues(importParams), + importParams + }); + } catch (e) { + console.error(e); + this.plugin.log.error(`Failed to load Zenodo record '${this.state.recordValues.record}'`); + this.setState({ busy: false }); + } + }; + + private loadFile = async (values: PD.Values<ImportParams>) => { + try { + this.setState({ busy: true }); + + const t = values.type; + if (t.name === 'structure') { + const defaultParams = DownloadStructure.createDefaultParams(this.plugin.state.data.root.obj!, this.plugin); + + const [url, format, isBinary] = t.params.split('|'); + + await this.plugin.runTask(this.plugin.state.data.applyAction(DownloadStructure, { + source: { + name: 'url', + params: { + url, + format: format as any, + isBinary: isBinary === 'true', + options: defaultParams.source.params.options, + } + } + })); + } else if (t.name === 'trajectory') { + const [topologyUrl, topologyFormat, topologyIsBinary] = t.params.topology.split('|'); + const [coordinatesUrl, coordinatesFormat, coordinatesIsBinary] = t.params.coordinates.split('|'); + + await this.plugin.runTask(this.plugin.state.data.applyAction(LoadTrajectory, { + source: { + name: 'url', + params: { + model: { + url: topologyUrl, + format: topologyFormat as any, + isBinary: topologyIsBinary === 'true', + }, + coordinates: { + url: coordinatesUrl, + format: coordinatesFormat as any, + isBinary: coordinatesIsBinary === 'true', + }, + } + } + })); + } else if (t.name === 'volume') { + const [url, format, isBinary] = t.params.split('|'); + + await this.plugin.runTask(this.plugin.state.data.applyAction(DownloadDensity, { + source: { + name: 'url', + params: { + url, + format: format as any, + isBinary: isBinary === 'true', + } + } + })); + } + } catch (e) { + console.error(e); + this.plugin.log.error(`Failed to load Zenodo file`); + } finally { + this.setState({ busy: false }); + } + }; + + private clearRecord = () => { + this.setState({ + importValues: undefined, + importParams: undefined, + record: undefined, + files: undefined + }); + }; + + private renderLoadRecord() { + return <div style={{ marginBottom: 10 }}> + <ParameterControls params={ZenodoImportParams} values={this.state.recordValues} onChangeValues={this.recordParamsOnChange} isDisabled={this.state.busy} /> + <Button onClick={this.loadRecord} style={{ marginTop: 1 }} disabled={this.state.busy}> + Load Record + </Button> + </div>; + } + + private renderRecordInfo(record: ZenodoRecord) { + return <div style={{ marginBottom: 10 }}> + <div className='msp-help-text'> + <div>{`${record.metadata.title} (${record.id})`}</div> + </div> + <Button onClick={this.clearRecord} style={{ marginTop: 1 }} disabled={this.state.busy}> + Clear + </Button> + </div>; + } + + private renderImportFile(params: ImportParams, values: PD.Values<ImportParams>) { + return values.type.name ? <div style={{ marginBottom: 10 }}> + <ParameterControls params={params} values={this.state.importValues} onChangeValues={this.importParamsOnChange} isDisabled={this.state.busy} /> + <Button onClick={() => this.loadFile(values)} style={{ marginTop: 1 }} disabled={this.state.busy}> + Import File + </Button> + </div> : <div className='msp-help-text' style={{ marginBottom: 10 }}> + <div>No supported files</div> + </div>; + } + + protected renderControls(): JSX.Element | null { + return <> + {!this.state.record ? this.renderLoadRecord() : null} + {this.state.record ? this.renderRecordInfo(this.state.record) : null} + {this.state.importParams && this.state.importValues ? this.renderImportFile(this.state.importParams, this.state.importValues) : null} + </>; + } +}