diff --git a/package.json b/package.json index ecff62337..772b5cd8e 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "react-router": "^5.0.1", "react-router-dom": "^5.0.1", "react-transition-group": "^4.1.1", - "react-viewerbase": "0.15.3", + "react-viewerbase": "0.17.0", "redux": "^4.0.1", "redux-logger": "^3.0.6", "redux-oidc": "3.1.x", diff --git a/public/assets/Button_File.svg b/public/assets/Button_File.svg new file mode 100644 index 000000000..45043e55e --- /dev/null +++ b/public/assets/Button_File.svg @@ -0,0 +1 @@ +FILE UPLOAD \ No newline at end of file diff --git a/public/assets/Button_Folder.svg b/public/assets/Button_Folder.svg new file mode 100644 index 000000000..7c3d0e8cc --- /dev/null +++ b/public/assets/Button_Folder.svg @@ -0,0 +1 @@ +FOLDER UPLOAD \ No newline at end of file diff --git a/public/config/google.js b/public/config/google.js new file mode 100644 index 000000000..2fcbe54f3 --- /dev/null +++ b/public/config/google.js @@ -0,0 +1,26 @@ +window.config = { + routerBasename: '/', + relativeWebWorkerScriptsPath: '', + enableGoogleCloudAdapter: true, + servers: { + // This is an array, but we'll only use the first entry for now + dicomWeb: [], + }, + // This is an array, but we'll only use the first entry for now + oidc: [ + { + // ~ REQUIRED + // Authorization Server URL + authority: 'https://accounts.google.com', + client_id: '99926187585-6nli1cbsf1774f575vj9ti0j7h6ru711.apps.googleusercontent.com', //'YOURCLIENTID.apps.googleusercontent.com', + redirect_uri: 'http://localhost:5000/callback', // `OHIFStandaloneViewer.js` + response_type: 'id_token token', + scope: 'email profile openid https://www.googleapis.com/auth/cloudplatformprojects.readonly https://www.googleapis.com/auth/cloud-healthcare', // email profile openid + // ~ OPTIONAL + post_logout_redirect_uri: '/logout-redirect.html', + revoke_uri: 'https://accounts.google.com/o/oauth2/revoke?token=', + "automaticSilentRenew": true, + "revokeAccessTokenOnSignout": true, + }, + ], +} diff --git a/src/connectedComponents/ToolbarRow.js b/src/connectedComponents/ToolbarRow.js index c25db6a24..76de3c009 100644 --- a/src/connectedComponents/ToolbarRow.js +++ b/src/connectedComponents/ToolbarRow.js @@ -1,7 +1,11 @@ import './ToolbarRow.css'; import React, { Component } from 'react'; -import { ExpandableToolMenu, RoundedButtonGroup, ToolbarButton } from 'react-viewerbase'; +import { + ExpandableToolMenu, + RoundedButtonGroup, + ToolbarButton, +} from 'react-viewerbase'; import { commandsManager, extensionManager } from './../App.js'; import ConnectedCineDialog from './ConnectedCineDialog'; diff --git a/src/googleCloud/ConnectedDicomFilesUploader.js b/src/googleCloud/ConnectedDicomFilesUploader.js new file mode 100644 index 000000000..15a818fd8 --- /dev/null +++ b/src/googleCloud/ConnectedDicomFilesUploader.js @@ -0,0 +1,22 @@ +import { connect } from 'react-redux'; +import DicomFileUploader from './DicomFileUploader.js'; + +const isActive = a => a.active === true; + +const mapStateToProps = state => { + const activeServer = state.servers.servers.find(isActive); + const { authority, client_id } = window.config.oidc[0]; + const oidcStorageKey = `oidc.user:${authority}:${client_id}`; + + return { + oidcStorageKey, + url: activeServer && activeServer.qidoRoot, + }; +}; + +const ConnectedDicomFileUploader = connect( + mapStateToProps, + null +)(DicomFileUploader); + +export default ConnectedDicomFileUploader; diff --git a/src/googleCloud/ConnectedDicomStorePicker.js b/src/googleCloud/ConnectedDicomStorePicker.js new file mode 100644 index 000000000..e6dcd6e88 --- /dev/null +++ b/src/googleCloud/ConnectedDicomStorePicker.js @@ -0,0 +1,34 @@ +import { connect } from 'react-redux'; +import DicomStorePickerWindow from './DicomStorePickerWindow.js'; + +const isActive = a => a.active === true; + +const mapStateToProps = state => { + const activeServer = state.servers.servers.find(isActive); + const { authority, client_id } = window.config.oidc[0]; + const oidcStorageKey = `oidc.user:${authority}:${client_id}`; + + return { + oidcStorageKey, + url: activeServer && activeServer.qidoRoot, + }; +}; + +const mapDispatchToProps = dispatch => { + return { + setServers: servers => { + const action = { + type: 'SET_SERVERS', + servers, + }; + dispatch(action); + }, + }; +}; + +const ConnectedDicomStorePicker = connect( + mapStateToProps, + mapDispatchToProps +)(DicomStorePickerWindow); + +export default ConnectedDicomStorePicker; diff --git a/src/googleCloud/DatasetPicker.js b/src/googleCloud/DatasetPicker.js new file mode 100644 index 000000000..a7a62a30d --- /dev/null +++ b/src/googleCloud/DatasetPicker.js @@ -0,0 +1,52 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import api from './api/GoogleCloudApi'; +import DatasetsList from './DatasetsList'; +import './googleCloud.css'; + +export default class DatasetPicker extends Component { + constructor(props) { + super(props); + this.state = { + error: null, + loading: false, + datasets: [], + }; + } + + static propTypes = { + project: PropTypes.object, + location: PropTypes.object, + onSelect: PropTypes.func, + oidcKey: PropTypes.string, + }; + static defaultProps = {}; + + async componentDidMount() { + api.setOidcStorageKey(this.props.oidcKey); + + const response = await api.loadDatasets( + this.props.project.projectId, + this.props.location.locationId + ); + this.loading = false; + if (response.isError) { + this.error = response.message; + return; + } + this.setState({ datasets: response.data.datasets || [] }); + } + + render() { + const { datasets, loading, error } = this.state; + const { onSelect } = this.props; + return ( + + ); + } +} diff --git a/src/googleCloud/DatasetSelector.js b/src/googleCloud/DatasetSelector.js new file mode 100644 index 000000000..85119fb59 --- /dev/null +++ b/src/googleCloud/DatasetSelector.js @@ -0,0 +1,156 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import DicomStorePicker from './DicomStorePicker'; +import DatasetPicker from './DatasetPicker'; +import ProjectPicker from './ProjectPicker'; +import LocationPicker from './LocationPicker'; +import GoogleCloudApi from './api/GoogleCloudApi'; +import './googleCloud.css'; + +export default class DatasetSelector extends Component { + constructor(props) { + super(props); + this.state = { + project: null, + location: null, + dataset: null, + unloading: false, + }; + } + + static propTypes = { + id: PropTypes.string, + event: PropTypes.string, + oidcKey: PropTypes.string, + canClose: PropTypes.string, + setServers: PropTypes.func.isRequired, + }; + static defaultProps = {}; + + onProjectSelect = project => { + this.setState({ + project: project, + }); + }; + + onLocationSelect = location => { + this.setState({ + location: location, + }); + }; + + onDatasetSelect = dataset => { + this.setState({ + dataset: dataset, + }); + }; + + onProjectClick = () => { + this.setState({ + dataset: null, + location: null, + project: null, + }); + }; + + onLocationClick = () => { + this.setState({ + dataset: null, + location: null, + }); + }; + + onDatasetClick = () => { + this.setState({ + dataset: null, + }); + }; + + onDicomStoreSelect = dicomStoreJson => { + const dicomStore = dicomStoreJson.name; + const parts = dicomStore.split('/'); + const result = { + wadoUriRoot: GoogleCloudApi.urlBase + `/${dicomStore}/dicomWeb`, + qidoRoot: GoogleCloudApi.urlBase + `/${dicomStore}/dicomWeb`, + wadoRoot: GoogleCloudApi.urlBase + `/${dicomStore}/dicomWeb`, + project: parts[1], + location: parts[3], + dataset: parts[5], + dicomStore: parts[7], + }; + this.props.setServers(result); + }; + + render() { + const { project, location, dataset } = this.state; + const { + onProjectClick, + onLocationClick, + onDatasetClick, + onProjectSelect, + onLocationSelect, + onDatasetSelect, + onDicomStoreSelect, + } = this; + return ( + <> + Google Cloud Healthcare API + {project && ( +
+ {project.name} + {project && location && ( + <> + + {' '} + -> {location.name.split('/')[3]} + + {project && location && dataset && ( + + {' '} + -> {dataset.name.split('/')[5]} + + )} + + )} +
+ )} + + {!project && ( + + )} + + {project && !location && ( + <> + + + )} + {project && location && !dataset && ( + <> + + + )} + {project && location && dataset && ( + <> + + + )} + + ); + } +} diff --git a/src/googleCloud/DatasetsList.js b/src/googleCloud/DatasetsList.js new file mode 100644 index 000000000..af1d038b0 --- /dev/null +++ b/src/googleCloud/DatasetsList.js @@ -0,0 +1,57 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import './googleCloud.css'; + +export default class DatasetsList extends Component { + constructor(props) { + super(props); + this.state = { + search: '', + }; + } + + static propTypes = { + datasets: PropTypes.array, + loading: PropTypes.bool, + error: PropTypes.string, + onSelect: PropTypes.func, + }; + static defaultProps = {}; + + renderTableRow(dataset) { + return ( + { + this.onHighlightItem(dataset.name); + }} + onClick={() => { + this.props.onSelect(dataset); + }} + > + {dataset.name.split('/')[5]} + + ); + } + + onHighlightItem(dataset) { + this.setState({ highlightedItem: dataset }); + } + + render() { + return ( + + + {this.props.datasets.map(dataset => { + return this.renderTableRow(dataset); + })} + +
+ ); + } +} diff --git a/src/googleCloud/DicomFileUploader.js b/src/googleCloud/DicomFileUploader.js new file mode 100644 index 000000000..9e7fe0b76 --- /dev/null +++ b/src/googleCloud/DicomFileUploader.js @@ -0,0 +1,38 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import DicomUploader from './DicomUploader'; + +class DicomFileUploader extends Component { + static propTypes = { + url: PropTypes.string, + oidcStorageKey: PropTypes.string.isRequired, + onClose: PropTypes.func, + }; + state = { + uploaded: false, + }; + + constructor(props) { + super(props); + this.element = React.createRef(); + } + + render() { + if (this.props.url != null) { + return ( +
+ + +
+ ); + } + return <>; + } +} + +export default DicomFileUploader; diff --git a/src/googleCloud/DicomStoreList.js b/src/googleCloud/DicomStoreList.js new file mode 100644 index 000000000..ba5a16ca6 --- /dev/null +++ b/src/googleCloud/DicomStoreList.js @@ -0,0 +1,57 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import './googleCloud.css'; + +export default class DicomStoreList extends Component { + constructor(props) { + super(props); + this.state = { + search: '', + }; + } + + static propTypes = { + stores: PropTypes.array, + loading: PropTypes.bool, + error: PropTypes.string, + onSelect: PropTypes.func, + }; + static defaultProps = {}; + + renderTableRow(store) { + return ( + { + this.onHighlightItem(store.name); + }} + onClick={() => { + this.props.onSelect(store); + }} + > + {store.name.split('/')[7]} + + ); + } + + onHighlightItem(store) { + this.setState({ highlightedItem: store }); + } + + render() { + return ( + + + {this.props.stores.map(store => { + return this.renderTableRow(store); + })} + +
+ ); + } +} diff --git a/src/googleCloud/DicomStorePicker.js b/src/googleCloud/DicomStorePicker.js new file mode 100644 index 000000000..f4a44182b --- /dev/null +++ b/src/googleCloud/DicomStorePicker.js @@ -0,0 +1,50 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import api from './api/GoogleCloudApi'; +import DicomStoreList from './DicomStoreList'; +import './googleCloud.css'; + +export default class DicomStorePicker extends Component { + constructor(props) { + super(props); + this.state = { + error: null, + loading: false, + stores: [], + locations: [], + }; + } + + static propTypes = { + dataset: PropTypes.object, + onSelect: PropTypes.func, + }; + static defaultProps = {}; + + async componentDidMount() { + const { authority, client_id } = window.config.oidc[0]; + const oidcStorageKey = `oidc.user:${authority}:${client_id}`; + api.setOidcStorageKey(oidcStorageKey); + const response = await api.loadDicomStores(this.props.dataset.name); + this.loading = false; + if (response.isError) { + this.error = response.message; + return; + } + this.setState({ stores: response.data.dicomStores || [] }); + } + + render() { + const { stores, loading, error } = this.state; + const { onSelect } = this.props; + + return ( + + ); + } +} diff --git a/src/googleCloud/DicomStorePickerWindow.js b/src/googleCloud/DicomStorePickerWindow.js new file mode 100644 index 000000000..8b080f91e --- /dev/null +++ b/src/googleCloud/DicomStorePickerWindow.js @@ -0,0 +1,57 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import DatasetSelector from './DatasetSelector'; +import './googleCloud.css'; + +class DicomStorePickerWindow extends Component { + static propTypes = { + url: PropTypes.string, + oidcStorageKey: PropTypes.string.isRequired, + setServers: PropTypes.func.isRequired, + onClose: PropTypes.func, + }; + + constructor(props) { + super(props); + this.element = React.createRef(); + this.handleEvent = this.handleEvent.bind(this); + } + + componentDidMount() {} + + handleEvent(data) { + const servers = [ + { + name: data.dicomStore, + imageRendering: 'wadors', + thumbnailRendering: 'wadors', + qidoSupportsIncludeField: false, + requestOptions: { requestFromBrowser: true }, + type: 'dicomWeb', + qidoRoot: data.qidoRoot, + wadoRoot: data.wadoRoot, + wadoUriRoot: data.wadoUriRoot, + active: true, + }, + ]; + + this.props.setServers(servers); + } + + render() { + return ( +
+ + +
+ ); + } +} + +export default DicomStorePickerWindow; diff --git a/src/googleCloud/DicomUploader.js b/src/googleCloud/DicomUploader.js new file mode 100644 index 000000000..423c51fb5 --- /dev/null +++ b/src/googleCloud/DicomUploader.js @@ -0,0 +1,204 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { formatFileSize } from './utils/helpers'; +import CancellationToken from './utils/CancellationToken'; +import dicomUploader from './api/DicomUploadService'; +import './googleCloud.css'; + +export default class DicomUploader extends Component { + constructor(props) { + super(props); + this.state = { + status: 'Upload', + isCancelled: false, + errorsCount: 0, + files: null, + uploadedVolume: null, + wholeVolumeStr: null, + isFilesListHidden: true, + timeLeft: null, + uploadedList: null, + totalCount: 0, + successfullyUploadedCount: 0, + lastFile: '', + uploadContext: null, // this is probably not needed, but we use this variable to destinguish between different downloads + }; + this.uploadFiles = this.uploadFiles.bind(this); + } + + static propTypes = { + id: PropTypes.string, + event: PropTypes.string, + url: PropTypes.string, + oidcKey: PropTypes.string, + }; + static defaultProps = {}; + + filesLeft() { + return ( + this.state.uploadedList.length + ' of ' + this.state.totalCount + ' files' + ); + } + + volumeLeft() { + let left = formatFileSize(this.state.uploadedVolume); + return left + ' of ' + this.state.wholeVolumeStr; + } + + percents() { + return parseInt( + (100 * this.state.uploadedList.length) / + Object.keys(this.state.files).length + ); + } + + isFinished() { + return ( + this.state.isCancelled || + Object.keys(this.state.files).length === this.state.uploadedList.length + ); + } + + errorsMessage() { + const errors = this.state.errorsCount === 1 ? ' error' : ' errors'; + return ( + this.state.errorsCount + errors + ' while uploading, click for more info' + ); + } + + uploadFiles(files) { + const filesArray = Array.from(files.target.files); + const filesDict = {}; + filesArray.forEach((file, i) => { + const fileDesc = { + id: i, + name: file.name, + path: file.webkitRelativePath || file.name, + size: file.size, + error: null, + processed: false, + processedInUI: false, + }; + filesDict[i] = fileDesc; + file.fileId = i; + }); + const wholeVolume = filesArray.map(f => f.size).reduce((a, b) => a + b); + const uploadContext = Math.random(); + this.setState({ + status: 'Uploading...', + files: filesDict, + uploadedList: [], + uploadedVolume: 0, + lastFile: filesArray[0].name, + totalCount: filesArray.length, + wholeVolumeStr: formatFileSize(wholeVolume), + uploadContext: uploadContext, + cancellationToken: new CancellationToken(), + }); + const cancellationToken = new CancellationToken(); + const uploadCallback = (fileId, error) => + uploadContext === this.state.uploadContext && + this.uploadCallback.call(this, fileId, error); + dicomUploader.setOidcStorageKey(this.props.oidcKey); + dicomUploader.smartUpload( + files.target.files, + this.props.url, + this.props.oidcKey, + uploadCallback, + cancellationToken + ); + } + + uploadCallback(fileId, error) { + const file = this.state.files[fileId]; + file.processed = true; + if (!error) { + let uploadedVolume = this.state.uploadedVolume + file.size; + this.setState({ uploadedVolume: uploadedVolume }); + } else { + file.error = error; + this.setState({ errorsCount: this.state.errorsCount + 1 }); + } + this.setState({ lastFile: file.name }); + let uploadedList = this.state.uploadedList; + uploadedList.push(file); + this.setState({ uploadedList: uploadedList }); + } + + renderTableRow(file) { + let error = <>; + if (file.error != null) { + error = ( + <> + {file.error} + + ); + } + return ( + + + {file.name} {error} + + + ); + } + + render() { + if (this.state.files == null) { + return ( +
+
+ + +
+ +
+ + +
+
+ ); + } else { + return ( + <> + + + + + + + + {this.state.uploadedList.map(file => { + return this.renderTableRow(file); + })} + +
+ {this.percents()}% {this.filesLeft()} +
+ + ); + } + } +} diff --git a/src/googleCloud/LocationPicker.js b/src/googleCloud/LocationPicker.js new file mode 100644 index 000000000..f15782dc2 --- /dev/null +++ b/src/googleCloud/LocationPicker.js @@ -0,0 +1,50 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import api from './api/GoogleCloudApi'; +import LocationsList from './LocationsList'; +import './googleCloud.css'; + +export default class LocationPicker extends Component { + constructor(props) { + super(props); + this.state = { + error: null, + loading: false, + locations: [], + }; + } + + static propTypes = { + project: PropTypes.object, + onSelect: PropTypes.func, + oidcKey: PropTypes.string, + }; + + async componentDidMount() { + api.setOidcStorageKey(this.props.oidcKey); + + const response = await api.loadLocations(this.props.project.projectId); + + this.loading = false; + if (response.isError) { + this.error = response.message; + return; + } + this.setState({ locations: response.data.locations || [] }); + } + + static defaultProps = {}; + + render() { + const { locations, loading, error } = this.state; + const { onSelect } = this.props; + return ( + + ); + } +} diff --git a/src/googleCloud/LocationsList.js b/src/googleCloud/LocationsList.js new file mode 100644 index 000000000..a1ad230a7 --- /dev/null +++ b/src/googleCloud/LocationsList.js @@ -0,0 +1,56 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import './googleCloud.css'; +export default class LocationsList extends Component { + constructor(props) { + super(props); + this.state = { + search: '', + }; + } + + static propTypes = { + locations: PropTypes.array, + loading: PropTypes.bool, + error: PropTypes.string, + onSelect: PropTypes.func, + }; + static defaultProps = {}; + + renderTableRow(location) { + return ( + { + this.onHighlightItem(location.locationId); + }} + onClick={() => { + this.props.onSelect(location); + }} + > + {location.name.split('/')[3]} + + ); + } + + onHighlightItem(locationId) { + this.setState({ highlightedItem: locationId }); + } + + render() { + return ( + + + {this.props.locations.map(project => { + return this.renderTableRow(project); + })} + +
+ ); + } +} diff --git a/src/googleCloud/ProjectPicker.js b/src/googleCloud/ProjectPicker.js new file mode 100644 index 000000000..d9ba1f8c7 --- /dev/null +++ b/src/googleCloud/ProjectPicker.js @@ -0,0 +1,46 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import api from './api/GoogleCloudApi'; +import ProjectsList from './ProjectsList'; +import './googleCloud.css'; + +export default class ProjectPicker extends Component { + constructor(props) { + super(props); + this.state = { + error: null, + loading: false, + projects: [], + }; + } + + static propTypes = { + onSelect: PropTypes.func, + oidcKey: PropTypes.string, + }; + static defaultProps = {}; + + async componentDidMount() { + api.setOidcStorageKey(this.props.oidcKey); + const response = await api.loadProjects(); + this.loading = false; + if (response.isError) { + this.error = response.message; + return; + } + this.setState({ projects: response.data.projects || [] }); + } + + render() { + const { projects, loading, error } = this.state; + const { onSelect } = this.props; + return ( + + ); + } +} diff --git a/src/googleCloud/ProjectsList.js b/src/googleCloud/ProjectsList.js new file mode 100644 index 000000000..98974d8a5 --- /dev/null +++ b/src/googleCloud/ProjectsList.js @@ -0,0 +1,59 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import './googleCloud.css'; + +export default class ProjectsList extends Component { + constructor(props) { + super(props); + this.state = { + search: '', + highlightedItem: null, + }; + } + + static propTypes = { + projects: PropTypes.array, + loading: PropTypes.bool, + error: PropTypes.string, + onSelect: PropTypes.func.isRequired, + }; + static defaultProps = {}; + + renderTableRow(project) { + return ( + { + this.onHighlightItem(project.projectId); + }} + onClick={() => { + this.onHighlightItem(project.projectId); + this.props.onSelect(project); + }} + > + {project.name} + + ); + } + + onHighlightItem(project) { + this.setState({ highlightedItem: project }); + } + + render() { + return ( + + + {this.props.projects.map(project => { + return this.renderTableRow(project); + })} + +
+ ); + } +} diff --git a/src/googleCloud/api/DicomUploadService.js b/src/googleCloud/api/DicomUploadService.js new file mode 100644 index 000000000..4ad30ce40 --- /dev/null +++ b/src/googleCloud/api/DicomUploadService.js @@ -0,0 +1,92 @@ +import { httpErrorToStr, getOidcToken, checkDicomFile } from '../utils/helpers'; +const DICOM = require('dicomweb-client'); + +class DicomUploadService { + setOidcStorageKey(oidcStorageKey) { + /* eslint-disable */ + if (!oidcStorageKey) console.error('OIDC storage key is empty'); + this.oidcStorageKey = oidcStorageKey; + } + + async smartUpload(files, url, authToken, uploadCallback, cancellationToken) { + /* eslint-disable */ + const CHUNK_SIZE = 1; // Only one file per request is supported so far + const MAX_PARALLEL_JOBS = 50; // FIXME: tune MAX_PARALLEL_JOBS number + // + let filesArray = Array.from(files); + if (filesArray.length === 0) { + console.warn('No files are supplied for uploading'); + return; + } + let parallelJobsCount = Math.min(filesArray.length, MAX_PARALLEL_JOBS); + let completed = false; + + const processJob = async (resolve, reject) => { + while (filesArray.length > 0) { + if (cancellationToken.get()) return; + let chunk = filesArray.slice(0, CHUNK_SIZE); + filesArray = filesArray.slice(CHUNK_SIZE); + let error = null; + try { + if (chunk.length > 1) throw new Error('Not implemented'); + if (chunk.length === 1) + await this.simpleUpload(chunk[0], url, authToken); + } catch (err) { + // It looks like a stupid bug of Babel that err is not an actual Exception object + error = httpErrorToStr(err); + } + chunk.forEach(file => uploadCallback(file.fileId, error)); + if (!completed && filesArray.length === 0) { + completed = true; + resolve(); + return; + } + } + }; + + await new Promise(resolve => { + for (let i = 0; i < parallelJobsCount; i++) { + processJob(resolve); + } + }); + } + + async simpleUpload(file, url, authToken) { + /* eslint-disable */ + const client = this.getClient(url); + const loadedFile = await this.readFile(file); + const content = loadedFile.content; + if (!checkDicomFile(content)) + throw new Error('The file has a wrong DICOM header'); + await client.storeInstances({ datasets: [content] }); + } + + readFile(file) { + const promise = new Promise((resolve, reject) => { + var reader = new FileReader(); + reader.onload = () => { + resolve({ + name: file.name, + size: file.size, + type: file.type, + content: reader.result, + }); + }; + reader.onerror = error => reject(error); + reader.readAsArrayBuffer(file); + }); + return promise; + } + + getClient(url) { + if (!this.oidcStorageKey) throw new Error('OIDC storage key is not set'); + const accessToken = getOidcToken(this.oidcStorageKey); + if (!accessToken) throw new Error('OIDC access_token is not set'); + return new DICOM.api.DICOMwebClient({ + url, + headers: { Authorization: 'Bearer ' + accessToken }, + }); + } +} + +export default new DicomUploadService(); diff --git a/src/googleCloud/api/GoogleCloudApi.js b/src/googleCloud/api/GoogleCloudApi.js new file mode 100644 index 000000000..616ae8259 --- /dev/null +++ b/src/googleCloud/api/GoogleCloudApi.js @@ -0,0 +1,98 @@ +import { getOidcToken } from '../utils/helpers'; + +class GoogleCloudApi { + setOidcStorageKey(oidcStorageKey) { + if (!oidcStorageKey) console.error('OIDC storage key is empty'); + this.oidcStorageKey = oidcStorageKey; + } + + get fetchConfig() { + if (!this.oidcStorageKey) throw new Error('OIDC storage key is not set'); + const accessToken = getOidcToken(this.oidcStorageKey); + if (!accessToken) throw new Error('OIDC access_token is not set'); + return { + method: 'GET', + headers: { + Authorization: 'Bearer ' + accessToken, + }, + }; + } + + get urlBase() { + return `https://healthcare.googleapis.com/v1beta1`; + } + + get urlBaseProject() { + return this.urlBase + `/projects`; + } + + async doRequest(urlStr, config = {}, params = {}) { + var url = new URL(urlStr); + let data = null; + url.search = new URLSearchParams(params); + + try { + const response = await fetch(url, { ...this.fetchConfig, config }); + try { + data = await response.json(); + } catch (err) {} + if (response.status >= 200 && response.status < 300 && data != null) { + if (data.nextPageToken != null) { + params.pageToken = data.nextPageToken; + let subPage = await this.doRequest(urlStr, config, params); + for (var key in data) { + if (data.hasOwnProperty(key)) { + data[key] = data[key].concat(subPage.data[key]); + } + } + } + return { + isError: false, + status: response.status, + data: data, + }; + } else { + return { + isError: true, + status: response.status, + message: + (data && data.error && data.error.message) || 'Unknown error', + }; + } + } catch (err) { + if (data && data.error) { + return { + isError: true, + status: err.status, + message: err.response.data.error.message || 'Unspecified error', + }; + } + return { + isError: true, + message: (err && err.message) || 'Oops! Something went wrong', + }; + } + } + + async loadProjects() { + return this.doRequest( + 'https://cloudresourcemanager.googleapis.com/v1/projects' + ); + } + + async loadLocations(projectId) { + return this.doRequest(this.urlBaseProject + `/${projectId}/locations`); + } + + async loadDatasets(projectId, locationId) { + return this.doRequest( + this.urlBaseProject + `/${projectId}/locations/${locationId}/datasets` + ); + } + + async loadDicomStores(dataset) { + return this.doRequest(this.urlBase + `/${dataset}/dicomStores`); + } +} + +export default new GoogleCloudApi(); diff --git a/src/googleCloud/googleCloud.css b/src/googleCloud/googleCloud.css new file mode 100644 index 000000000..466483d93 --- /dev/null +++ b/src/googleCloud/googleCloud.css @@ -0,0 +1,49 @@ +.gcp-files-selector { + display: flex; + justify-content: space-between; +} +.gcp-files-selector__btn-dir, +.gcp-files-selector__btn-file { + height: 80px !important; + width: 228px !important; + font-size: 14px; + border-radius: 8px; + margin: 0 !important; +} +.gcp-files-selector__btn-dir { + background-image: url('/assets/Button_Folder.svg'); +} +.gcp-files-selector__btn-file { + background-image: url('/assets/Button_File.svg'); +} + +.gcp-window { + background-color: #000000; +} + +.gcp-invisible-input { + position: absolute; + display: none; + z-index: -1000; + max-width: 0 !important; + max-height: 0 !important; +} +.gcp-hidden { + display: none; +} + +.gcp-picker--title { + color: #ffffff; + font-size: 24px; + font-weight: 28px; + text-align: center; + margin: 20px auto; +} + +.gcp-picker--path { + color: #ffffff; + font-size: 16px; + font-weight: 28px; + text-align: leaft; + margin: 20px auto; +} diff --git a/src/googleCloud/utils/CancellationToken.js b/src/googleCloud/utils/CancellationToken.js new file mode 100644 index 000000000..cc40fdd57 --- /dev/null +++ b/src/googleCloud/utils/CancellationToken.js @@ -0,0 +1,13 @@ +export default class CancellationToken { + constructor() { + this.cancelled = false; + } + + get() { + return this.cancelled; + } + + set(value) { + this.cancelled = value; + } +} diff --git a/src/googleCloud/utils/helpers.js b/src/googleCloud/utils/helpers.js new file mode 100644 index 000000000..fda924c39 --- /dev/null +++ b/src/googleCloud/utils/helpers.js @@ -0,0 +1,40 @@ +export function formatFileSize(size) { + if (size === 0) return '0 B'; + const n = Math.floor(Math.log(size) / Math.log(1024)); + return ( + (size / Math.pow(1024, n)).toFixed(2) * 1 + + ' ' + + ['B', 'kB', 'MB', 'GB', 'TB'][n] + ); +} + +export function httpErrorToStr(error) { + if (!error) return 'Unknown error'; + if (error.response) { + try { + const jsonResponse = JSON.parse(error.response); + if ( + jsonResponse.error && + jsonResponse.error.code && + jsonResponse.error.message + ) + return jsonResponse.error.code + ' - ' + jsonResponse.error.message; + } catch (err) { + return error.response; + } + } + return error.message || 'Unknown error.'; +} + +export function getOidcToken(oidcStorageKey) { + const oidcConfigStr = sessionStorage.getItem(oidcStorageKey); + if (oidcConfigStr) return JSON.parse(oidcConfigStr).access_token; +} + +/* eslint-disable */ +export function checkDicomFile(arrayBuffer) { + if (arrayBuffer.length <= 132) return false; + const arr = new Uint8Array(arrayBuffer.slice(128, 132)); + // bytes from 128 to 132 must be "DICM" + return Array.from('DICM').every((char, i) => char.charCodeAt(0) === arr[i]); +} diff --git a/src/studylist/StudyListWithData.js b/src/studylist/StudyListWithData.js index 38c630eb6..c6bf3d4c4 100644 --- a/src/studylist/StudyListWithData.js +++ b/src/studylist/StudyListWithData.js @@ -5,11 +5,16 @@ import { withRouter } from 'react-router-dom'; import { StudyList } from 'react-viewerbase'; import ConnectedHeader from '../connectedComponents/ConnectedHeader.js'; import moment from 'moment'; +import ConnectedDicomFilesUploader from '../googleCloud/ConnectedDicomFilesUploader'; +import ConnectedDicomStorePicker from '../googleCloud/ConnectedDicomStorePicker'; class StudyListWithData extends Component { state = { - studies: null, + searchData: {}, + studies: [], error: null, + modalComponentId: null, + showStudyList: true, }; static propTypes = { @@ -39,11 +44,31 @@ class StudyListWithData extends Component { componentDidMount() { // TODO: Avoid using timepoints here //const params = { studyInstanceUids, seriesInstanceUids, timepointId, timepointsFilter={} }; + if (!this.props.server && window.config.enableGoogleCloudAdapter) { + this.setState({ + modalComponentId: 'DicomStorePicker', + showStudyList: false, + }); + } else { + this.searchForStudies({ + ...StudyListWithData.defaultSearchData, + ...(this.props.filters || {}), + }); + } + } - this.searchForStudies({ - ...StudyListWithData.defaultSearchData, - ...(this.props.filters || {}), - }); + componentDidUpdate(prevProps) { + if (!this.state.searchData && !this.state.studies) { + this.searchForStudies(); + } + if (this.props.server !== prevProps.server) { + this.setState({ + modalComponentId: null, + showStudyList: true, + searchData: null, + studies: null, + }); + } } searchForStudies = (searchData = StudyListWithData.defaultSearchData) => { @@ -123,6 +148,17 @@ class StudyListWithData extends Component { //console.log('onImport'); }; + openModal = modalComponentId => { + this.setState({ + modalComponentId, + showStudyList: false, + }); + }; + + closeModal = () => { + this.setState({ modalComponentId: null }); + }; + onSelectItem = studyInstanceUID => { this.props.history.push(`/viewer/${studyInstanceUID}`); }; @@ -131,16 +167,63 @@ class StudyListWithData extends Component { this.searchForStudies(searchData); }; + update = () => { + this.setState({ + modalComponentId: null, + showStudyList: true, + }); + }; + render() { if (this.state.error) { return
Error: {JSON.stringify(this.state.error)}
; - } else if (this.state.studies === null) { + } else if (this.state.studies === null && !this.state.modalComponentId) { return
Loading...
; } - return ( - <> - + let healthCareApiButtons = ''; + let healthCareApiWindows = ''; + if (window.config.enableGoogleCloudAdapter) { + if (this.state.modalComponentId) { + if (this.state.modalComponentId === 'DicomStorePicker') { + healthCareApiWindows = ( + + ); + } else if (this.state.modalComponentId === 'DicomFilesUploader') { + healthCareApiWindows = ( + + ); + } + healthCareApiWindows = ( + <> +
+ {healthCareApiWindows} +
+ + ); + } + healthCareApiButtons = ( + <> +
+ + +
+ + ); + } + let studyList = <>; + studyList = ( +
+ > + {healthCareApiButtons} + {healthCareApiWindows} + +
+ ); + return ( + <> + + {studyList} ); } diff --git a/yarn.lock b/yarn.lock index 4d93670db..7d9279c6f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2921,7 +2921,7 @@ bytes@3.1.0: resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg== -cacache@^11.0.1, cacache@^11.0.2, cacache@^11.3.2: +cacache@^11.0.1, cacache@^11.0.2, cacache@^11.3.2, cacache@^11.3.3: version "11.3.3" resolved "https://registry.yarnpkg.com/cacache/-/cacache-11.3.3.tgz#8bd29df8c6a718a6ebd2d010da4d7972ae3bbadc" integrity sha512-p8WcneCytvzPxhDvYp31PD039vi77I12W+/KfR9S8AZbaiARFBCpsPJS+9uhWfeBfeAtW7o/4vt3MUqLkbY6nA== @@ -4313,7 +4313,7 @@ debug@3.2.6, debug@^3.1.0, debug@^3.2.5, debug@^3.2.6: dependencies: ms "^2.1.1" -debuglog@^1.0.1: +debuglog@*, debuglog@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/debuglog/-/debuglog-1.0.1.tgz#aa24ffb9ac3df9a2351837cfb2d279360cd78492" integrity sha1-qiT/uaw9+aI1GDfPstJ5NgzXhJI= @@ -6344,7 +6344,7 @@ got@^6.7.1: unzip-response "^2.0.1" url-parse-lax "^1.0.0" -graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6: +graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.0.tgz#8d8fdc73977cb04104721cb53666c1ca64cd328b" integrity sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg== @@ -6958,7 +6958,7 @@ import-local@^2.0.0: pkg-dir "^3.0.0" resolve-cwd "^2.0.0" -imurmurhash@^0.1.4: +imurmurhash@*, imurmurhash@^0.1.4: version "0.1.4" resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" integrity sha1-khi5srkoojixPcT7a21XbyMUU+o= @@ -8602,7 +8602,7 @@ libnpm@^2.0.1: read-package-json "^2.0.13" stringify-package "^1.0.0" -libnpmaccess@^3.0.1: +libnpmaccess@*, libnpmaccess@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/libnpmaccess/-/libnpmaccess-3.0.1.tgz#5b3a9de621f293d425191aa2e779102f84167fa8" integrity sha512-RlZ7PNarCBt+XbnP7R6PoVgOq9t+kou5rvhaInoNibhPO7eMlRfS0B8yjatgn2yaHIwWNyoJDolC/6Lc5L/IQA== @@ -8631,7 +8631,7 @@ libnpmhook@^5.0.2: get-stream "^4.0.0" npm-registry-fetch "^3.8.0" -libnpmorg@^1.0.0: +libnpmorg@*, libnpmorg@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/libnpmorg/-/libnpmorg-1.0.0.tgz#979b868c48ba28c5820e3bb9d9e73c883c16a232" integrity sha512-o+4eVJBoDGMgRwh2lJY0a8pRV2c/tQM/SxlqXezjcAg26Qe9jigYVs+Xk0vvlYDWCDhP0g74J8UwWeAgsB7gGw== @@ -8656,7 +8656,7 @@ libnpmpublish@^1.1.0: semver "^5.5.1" ssri "^6.0.1" -libnpmsearch@^2.0.0: +libnpmsearch@*, libnpmsearch@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/libnpmsearch/-/libnpmsearch-2.0.1.tgz#eccc73a8fbf267d765d18082b85daa2512501f96" integrity sha512-K0yXyut9MHHCAH+DOiglQCpmBKPZXSUu76+BE2maSEfQN15OwNaA/Aiioe9lRFlVFOr7WcuJCY+VSl+gLi9NTA== @@ -8665,7 +8665,7 @@ libnpmsearch@^2.0.0: get-stream "^4.0.0" npm-registry-fetch "^3.8.0" -libnpmteam@^1.0.1: +libnpmteam@*, libnpmteam@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/libnpmteam/-/libnpmteam-1.0.1.tgz#ff704b1b6c06ea674b3b1101ac3e305f5114f213" integrity sha512-gDdrflKFCX7TNwOMX1snWojCoDE5LoRWcfOC0C/fqF7mBq8Uz9zWAX4B2RllYETNO7pBupBaSyBDkTAC15cAMg== @@ -8923,6 +8923,11 @@ lodash-es@^4.17.11: resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.11.tgz#145ab4a7ac5c5e52a3531fb4f310255a152b4be0" integrity sha512-DHb1ub+rMjjrxqlB3H56/6MXtm1lSksDp2rA2cNWjG8mlDUYFhUj3Di2Zn5IwSU87xLv8tNIQ7sSwE/YOX/D/Q== +lodash._baseindexof@*: + version "3.1.0" + resolved "https://registry.yarnpkg.com/lodash._baseindexof/-/lodash._baseindexof-3.1.0.tgz#fe52b53a1c6761e42618d654e4a25789ed61822c" + integrity sha1-/lK1OhxnYeQmGNZU5KJXie1hgiw= + lodash._baseuniq@~4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/lodash._baseuniq/-/lodash._baseuniq-4.6.0.tgz#0ebb44e456814af7905c6212fa2c9b2d51b841e8" @@ -8931,11 +8936,33 @@ lodash._baseuniq@~4.6.0: lodash._createset "~4.0.0" lodash._root "~3.0.0" +lodash._bindcallback@*: + version "3.0.1" + resolved "https://registry.yarnpkg.com/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz#e531c27644cf8b57a99e17ed95b35c748789392e" + integrity sha1-5THCdkTPi1epnhftlbNcdIeJOS4= + +lodash._cacheindexof@*: + version "3.0.2" + resolved "https://registry.yarnpkg.com/lodash._cacheindexof/-/lodash._cacheindexof-3.0.2.tgz#3dc69ac82498d2ee5e3ce56091bafd2adc7bde92" + integrity sha1-PcaayCSY0u5ePOVgkbr9Ktx73pI= + +lodash._createcache@*: + version "3.1.2" + resolved "https://registry.yarnpkg.com/lodash._createcache/-/lodash._createcache-3.1.2.tgz#56d6a064017625e79ebca6b8018e17440bdcf093" + integrity sha1-VtagZAF2JeeevKa4AY4XRAvc8JM= + dependencies: + lodash._getnative "^3.0.0" + lodash._createset@~4.0.0: version "4.0.3" resolved "https://registry.yarnpkg.com/lodash._createset/-/lodash._createset-4.0.3.tgz#0f4659fbb09d75194fa9e2b88a6644d363c9fe26" integrity sha1-D0ZZ+7CddRlPqeK4imZE02PJ/iY= +lodash._getnative@*, lodash._getnative@^3.0.0: + version "3.9.1" + resolved "https://registry.yarnpkg.com/lodash._getnative/-/lodash._getnative-3.9.1.tgz#570bc7dede46d61cdcde687d65d3eecbaa3aaff5" + integrity sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U= + lodash._reinterpolate@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz#0ccf2d89166af03b3663c796538b75ac6e114d9d" @@ -9011,6 +9038,11 @@ lodash.once@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac" integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w= +lodash.restparam@*: + version "3.6.1" + resolved "https://registry.yarnpkg.com/lodash.restparam/-/lodash.restparam-3.6.1.tgz#936a4e309ef330a7645ed4145986c85ae5b20805" + integrity sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU= + lodash.set@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23" @@ -10061,7 +10093,7 @@ npm-logical-tree@^1.2.1: semver "^5.5.0" validate-npm-package-name "^3.0.0" -npm-packlist@^1.1.12, npm-packlist@^1.1.6, npm-packlist@^1.4.1: +npm-packlist@^1.1.12, npm-packlist@^1.1.6: version "1.4.1" resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.1.tgz#19064cdf988da80ea3cee45533879d90192bbfbc" integrity sha512-+TcdO7HJJ8peiiYhvPxsEDhF3PJFGUGRcFsGve3vxvxdcpO2Z4Z7rkosRM0kWj6LfbK/P0gu3dzk5RU1ffvFcw== @@ -10069,6 +10101,14 @@ npm-packlist@^1.1.12, npm-packlist@^1.1.6, npm-packlist@^1.4.1: ignore-walk "^3.0.1" npm-bundled "^1.0.1" +npm-packlist@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.4.tgz#866224233850ac534b63d1a6e76050092b5d2f44" + integrity sha512-zTLo8UcVYtDU3gdeaFu2Xu0n0EvelfHDGuqtNIn5RO7yQj4H1TqNdBc/yZjxnWA0PVB8D3Woyp0i5B43JwQ6Vw== + dependencies: + ignore-walk "^3.0.1" + npm-bundled "^1.0.1" + npm-path@^2.0.2: version "2.0.4" resolved "https://registry.yarnpkg.com/npm-path/-/npm-path-2.0.4.tgz#c641347a5ff9d6a09e4d9bce5580c4f505278e64" @@ -10085,7 +10125,7 @@ npm-pick-manifest@^2.2.3: npm-package-arg "^6.0.0" semver "^5.4.1" -npm-profile@^4.0.1: +npm-profile@*, npm-profile@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-profile/-/npm-profile-4.0.1.tgz#d350f7a5e6b60691c7168fbb8392c3603583f5aa" integrity sha512-NQ1I/1Q7YRtHZXkcuU1/IyHeLy6pd+ScKg4+DQHdfsm769TGq6HPrkbuNJVJS4zwE+0mvvmeULzQdWn2L2EsVA== @@ -10128,9 +10168,9 @@ npm-which@^3.0.1: which "^1.2.10" npm@^6.8.0: - version "6.9.1" - resolved "https://registry.yarnpkg.com/npm/-/npm-6.9.1.tgz#f4cc74841116baf4d9aca89ce83ffa4cba8c3ce0" - integrity sha512-E4+eXNaGyVa7QiyhBdnsPxd7zb/whI8PrW2RJjqh6HHnweERuko3XTm9MwH+bRmRo6u27uzSGFm9oKwNOReXmQ== + version "6.10.0" + resolved "https://registry.yarnpkg.com/npm/-/npm-6.10.0.tgz#7ed37211db53ad486643418c8028092d1ed8b24d" + integrity sha512-pOMc81mT4fHXv/iMbw4T4GQVZzlzx/Vf5bta+JgMWVR+qqBeNI0mAbKrQ15vZf3eMJ+DaJj6+XgD7650JQs+rg== dependencies: JSONStream "^1.3.5" abbrev "~1.1.1" @@ -10139,9 +10179,9 @@ npm@^6.8.0: aproba "^2.0.0" archy "~1.0.0" bin-links "^1.1.2" - bluebird "^3.5.3" + bluebird "^3.5.5" byte-size "^5.0.1" - cacache "^11.3.2" + cacache "^11.3.3" call-limit "~1.1.0" chownr "^1.1.1" ci-info "^2.0.0" @@ -10160,7 +10200,7 @@ npm@^6.8.0: fs-write-stream-atomic "~1.0.10" gentle-fs "^2.0.1" glob "^7.1.3" - graceful-fs "^4.1.15" + graceful-fs "^4.2.0" has-unicode "~2.0.1" hosted-git-info "^2.7.1" iferr "^1.0.2" @@ -10195,7 +10235,7 @@ npm@^6.8.0: npm-install-checks "~3.0.0" npm-lifecycle "^2.1.0" npm-package-arg "^6.1.0" - npm-packlist "^1.4.1" + npm-packlist "^1.4.4" npm-pick-manifest "^2.2.3" npm-registry-fetch "^3.9.0" npm-user-validate "~1.0.0" @@ -10203,7 +10243,7 @@ npm@^6.8.0: once "~1.4.0" opener "^1.5.1" osenv "^0.1.5" - pacote "^9.5.0" + pacote "^9.5.1" path-is-inside "~1.0.2" promise-inflight "~1.0.1" qrcode-terminal "^0.12.0" @@ -10213,8 +10253,9 @@ npm@^6.8.0: read-cmd-shim "~1.0.1" read-installed "~4.0.3" read-package-json "^2.0.13" - read-package-tree "^5.2.2" - readable-stream "^3.2.0" + read-package-tree "^5.3.1" + readable-stream "^3.3.0" + readdir-scoped-modules "^1.1.0" request "^2.88.0" retry "^0.12.0" rimraf "^2.6.3" @@ -10226,7 +10267,7 @@ npm@^6.8.0: sorted-union-stream "~2.1.3" ssri "^6.0.1" stringify-package "^1.0.0" - tar "^4.4.8" + tar "^4.4.10" text-table "~0.2.0" tiny-relative-date "^1.3.0" uid-number "0.0.6" @@ -10239,7 +10280,7 @@ npm@^6.8.0: validate-npm-package-name "~3.0.0" which "^1.3.1" worker-farm "^1.6.0" - write-file-atomic "^2.4.2" + write-file-atomic "^2.4.3" "npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.2, npmlog@^4.1.2, npmlog@~4.1.2: version "4.1.2" @@ -10695,7 +10736,7 @@ package-json@^4.0.0: registry-url "^3.0.3" semver "^5.1.0" -pacote@^9.1.0, pacote@^9.2.3, pacote@^9.5.0: +pacote@^9.1.0, pacote@^9.2.3, pacote@^9.5.1: version "9.5.1" resolved "https://registry.yarnpkg.com/pacote/-/pacote-9.5.1.tgz#adb0d23daeef6d0b813ab5891d0c6459ccec998d" integrity sha512-Zqvczvf/zZ7QNosdE9uTC7SRuvSs9tFqRkF6cJl+2HH7COBnx4BRAGpeXJlrbN+mM0CMHpbi620xdEHhCflghA== @@ -12574,10 +12615,10 @@ react-transition-group@^4.1.1: loose-envify "^1.4.0" prop-types "^15.6.2" -react-viewerbase@0.15.3: - version "0.15.3" - resolved "https://registry.yarnpkg.com/react-viewerbase/-/react-viewerbase-0.15.3.tgz#d2d263865dbb4fabc920b8b7c6f75f5dbb95011d" - integrity sha512-unGny2lE3Ahttyj+6+TK9LkNS/U/62wt+ojKNf0H71Vt2s0v7xqEbNQQPFpu5xLPZIr5rCO3PoveEzijlQqTzg== +react-viewerbase@0.17.0: + version "0.17.0" + resolved "https://registry.yarnpkg.com/react-viewerbase/-/react-viewerbase-0.17.0.tgz#e741f94b24b6cef419fdd32b9bcfcdcfe81783dc" + integrity sha512-fy51pHrdhdXcPg6Pb1l8S5fzxlenFVhRUe80ZFq1aK9omrQXgaBF0uhm537WREyx2CS0/ZOD/wQnnbyjqEvYdQ== dependencies: "@babel/runtime" "7.2.0" "@ohif/i18n" "0.2.1" @@ -12677,10 +12718,10 @@ read-installed@~4.0.3: optionalDependencies: graceful-fs "^4.1.2" -read-package-tree@^5.2.2: - version "5.3.0" - resolved "https://registry.yarnpkg.com/read-package-tree/-/read-package-tree-5.3.0.tgz#4f95472e45e7145fb77f4069d12844b139f5ea12" - integrity sha512-Gi64+EWmi4515E1rPR77ae/Ip8cjFQTlsWytSYJj974U0tSnxm67pyXltbDjB1lvLw4dc85HbtidGL1K2c/oxw== +read-package-tree@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/read-package-tree/-/read-package-tree-5.3.1.tgz#a32cb64c7f31eb8a6f31ef06f9cedf74068fe636" + integrity sha512-mLUDsD5JVtlZxjSlPPx1RETkNjjvQYuweKwNVt1Sn8kP5Jh44pvYuUHCp6xSVDZWbNxVxG5lyZJ921aJH61sTw== dependencies: read-package-json "^2.0.0" readdir-scoped-modules "^1.0.0" @@ -12777,7 +12818,7 @@ readable-stream@^1.0.26-4, readable-stream@~1.1.10: isarray "0.0.1" string_decoder "~0.10.x" -readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.2.0: +readable-stream@^3.0.6, readable-stream@^3.1.1, readable-stream@^3.3.0: version "3.4.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.4.0.tgz#a51c26754658e0a3c21dbf59163bd45ba6f447fc" integrity sha512-jItXPLmrSR8jmTRmRWJXCnGJsfy85mB3Wd/uINMXA65yrnFo0cPClFIUWzo2najVNSl+mx7/4W8ttlLWJe99pQ== @@ -12818,6 +12859,16 @@ readdir-scoped-modules@^1.0.0: graceful-fs "^4.1.2" once "^1.3.0" +readdir-scoped-modules@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/readdir-scoped-modules/-/readdir-scoped-modules-1.1.0.tgz#8d45407b4f870a0dcaebc0e28670d18e74514309" + integrity sha512-asaikDeqAQg7JifRsZn1NJZXo9E+VwlyCfbkZhwyISinqk5zNS6266HS5kah6P0SaQKGF6SkNnZVHUzHFYxYDw== + dependencies: + debuglog "^1.0.1" + dezalgo "^1.0.0" + graceful-fs "^4.1.2" + once "^1.3.0" + readdirp@^2.0.0, readdirp@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" @@ -14657,7 +14708,7 @@ tar@^2.0.0: fstream "^1.0.12" inherits "2" -tar@^4, tar@^4.4.8: +tar@^4, tar@^4.4.10, tar@^4.4.8: version "4.4.10" resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.10.tgz#946b2810b9a5e0b26140cf78bea6b0b0d689eba1" integrity sha512-g2SVs5QIxvo6OLp0GudTqEf05maawKUxXru104iaayWA09551tFCTI8f1Asb4lPfkBr91k07iL4c11XO3/b0tA== @@ -15956,7 +16007,7 @@ write-file-atomic@2.4.1: imurmurhash "^0.1.4" signal-exit "^3.0.2" -write-file-atomic@^2.0.0, write-file-atomic@^2.3.0, write-file-atomic@^2.4.2: +write-file-atomic@^2.0.0, write-file-atomic@^2.3.0, write-file-atomic@^2.4.3: version "2.4.3" resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-2.4.3.tgz#1fd2e9ae1df3e75b8d8c367443c692d4ca81f481" integrity sha512-GaETH5wwsX+GcnzhPgKcKjJ6M2Cq3/iZp1WyY/X1CSqrW+jVNM9Y7D8EC2sM4ZG/V8wZlSniJnCKWPmBYAucRQ==