diff --git a/extensions/dicom-p10-downloader/.webpack/webpack.dev.js b/extensions/dicom-p10-downloader/.webpack/webpack.dev.js new file mode 100644 index 00000000000..1ae30844802 --- /dev/null +++ b/extensions/dicom-p10-downloader/.webpack/webpack.dev.js @@ -0,0 +1,8 @@ +const path = require('path'); +const webpackCommon = require('./../../../.webpack/webpack.commonjs.js'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); + +module.exports = (env, argv) => { + return webpackCommon(env, argv, { SRC_DIR, DIST_DIR }); +}; diff --git a/extensions/dicom-p10-downloader/.webpack/webpack.prod.js b/extensions/dicom-p10-downloader/.webpack/webpack.prod.js new file mode 100644 index 00000000000..31dff1ae7b9 --- /dev/null +++ b/extensions/dicom-p10-downloader/.webpack/webpack.prod.js @@ -0,0 +1,38 @@ +const merge = require('webpack-merge'); +const path = require('path'); +const webpackCommon = require('./../../../.webpack/webpack.commonjs.js'); +const pkg = require('./../package.json'); + +const ROOT_DIR = path.join(__dirname, './..'); +const SRC_DIR = path.join(__dirname, '../src'); +const DIST_DIR = path.join(__dirname, '../dist'); + +module.exports = (env, argv) => { + const commonConfig = webpackCommon(env, argv, { SRC_DIR, DIST_DIR }); + + return merge(commonConfig, { + devtool: 'source-map', + stats: { + colors: true, + hash: true, + timings: true, + assets: true, + chunks: false, + chunkModules: false, + modules: false, + children: false, + warnings: true, + }, + optimization: { + minimize: true, + sideEffects: true, + }, + output: { + path: ROOT_DIR, + library: 'OHIFExtDicomP10Downloader', + libraryTarget: 'umd', + libraryExport: 'default', + filename: pkg.main, + }, + }); +}; diff --git a/extensions/dicom-p10-downloader/CHANGELOG.md b/extensions/dicom-p10-downloader/CHANGELOG.md new file mode 100644 index 00000000000..1f6f3712740 --- /dev/null +++ b/extensions/dicom-p10-downloader/CHANGELOG.md @@ -0,0 +1,4 @@ +# Change Log + +All notable changes to this project will be documented in this file. See +[Conventional Commits](https://conventionalcommits.org) for commit guidelines. diff --git a/extensions/dicom-p10-downloader/LICENSE b/extensions/dicom-p10-downloader/LICENSE new file mode 100644 index 00000000000..19e20dd35ca --- /dev/null +++ b/extensions/dicom-p10-downloader/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 Open Health Imaging Foundation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/extensions/dicom-p10-downloader/README.md b/extensions/dicom-p10-downloader/README.md new file mode 100644 index 00000000000..48dcd5a2a15 --- /dev/null +++ b/extensions/dicom-p10-downloader/README.md @@ -0,0 +1 @@ +# @ohif/extension-dicom-p10-downloader diff --git a/extensions/dicom-p10-downloader/package.json b/extensions/dicom-p10-downloader/package.json new file mode 100644 index 00000000000..b65c804623e --- /dev/null +++ b/extensions/dicom-p10-downloader/package.json @@ -0,0 +1,39 @@ +{ + "name": "@ohif/extension-dicom-p10-downloader", + "version": "0.0.1", + "description": "OHIF extension for downloading DICOM P10 files", + "author": "OHIF", + "license": "MIT", + "repository": "OHIF/Viewers", + "main": "dist/index.umd.js", + "module": "src/index.js", + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=10", + "npm": ">=6", + "yarn": ">=1.16.0" + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "dev": "cross-env NODE_ENV=development webpack --config .webpack/webpack.dev.js --watch --debug --output-pathinfo", + "build": "cross-env NODE_ENV=production webpack --config .webpack/webpack.prod.js", + "build:package": "yarn run build", + "prepublishOnly": "yarn run build", + "start": "yarn run dev" + }, + "peerDependencies": { + "@ohif/core": "^2.6.0", + "dicom-parser": "^1.8.3", + "dicomweb-client": "^0.5.2" + }, + "dependencies": { + "@babel/runtime": "^7.5.5", + "file-saver": "^2.0.2", + "jszip": "^3.2.2" + } +} diff --git a/extensions/dicom-p10-downloader/src/commandsModule.js b/extensions/dicom-p10-downloader/src/commandsModule.js new file mode 100644 index 00000000000..f9aa65df37f --- /dev/null +++ b/extensions/dicom-p10-downloader/src/commandsModule.js @@ -0,0 +1,105 @@ +import OHIF from '@ohif/core'; +import { + save, + getDicomWebClientFromContext, + getSOPInstanceReferenceFromActiveViewport, + getSOPInstanceReferencesFromViewports, +} from './utils'; +import _downloadAndZip from './downloadAndZip'; + +const { + utils: { + Queue, + }, +} = OHIF; + +export function getCommands(context) { + const queue = new Queue(1); + const actions = { + /** + * @example Running this command using Commands Manager + * commandsManager.runCommand( + * 'downloadAndZip', + * { + * listOfUIDs: [...], + * options: { + * progress(status) { + * console.info('Progress:', (status.progress * 100).toFixed(2) + '%'); + * } + * } + * }, + * 'VIEWER' + * ); + */ + downloadAndZip({ servers, dicomWebClient, listOfUIDs, options }) { + return save( + _downloadAndZip( + dicomWebClient || getDicomWebClientFromContext(context, servers), + listOfUIDs, + options + ), + listOfUIDs + ); + }, + downloadAndZipSeriesOnViewports({ servers, viewports, progress }) { + const dicomWebClient = getDicomWebClientFromContext(context, servers); + const listOfUIDs = getSOPInstanceReferencesFromViewports(viewports); + return save( + _downloadAndZip(dicomWebClient, listOfUIDs, { progress }), + listOfUIDs + ); + }, + downloadAndZipSeriesOnActiveViewport({ servers, viewports, progress }) { + const dicomWebClient = getDicomWebClientFromContext(context, servers); + const listOfUIDs = getSOPInstanceReferenceFromActiveViewport(viewports); + return save( + _downloadAndZip(dicomWebClient, listOfUIDs, { progress }), + listOfUIDs + ); + }, + }; + + const definitions = { + downloadAndZip: { + commandFn: queue.bindSafe(actions.downloadAndZip, error), + storeContexts: ['servers'], + }, + downloadAndZipSeriesOnViewports: { + commandFn: queue.bindSafe(actions.downloadAndZipSeriesOnViewports, error), + storeContexts: ['servers', 'viewports'], + options: { progress }, + }, + downloadAndZipSeriesOnActiveViewport: { + commandFn: queue.bindSafe( + actions.downloadAndZipSeriesOnActiveViewport, + error + ), + storeContexts: ['servers', 'viewports'], + options: { progress }, + }, + }; + + return { + actions, + definitions, + }; +} + +/** + * Utils + */ + +function progress(status) { + OHIF.log.info( + 'Download and Zip Progress:', + (status.progress * 100.0).toFixed(2) + '%' + ); +} + +function error(e) { + if (e.message === 'Queue limit reached') { + OHIF.log.warn('A download is already in progress, please wait.'); + } else { + OHIF.log.error(e); + } +} diff --git a/extensions/dicom-p10-downloader/src/downloadAndZip.js b/extensions/dicom-p10-downloader/src/downloadAndZip.js new file mode 100644 index 00000000000..ce440358036 --- /dev/null +++ b/extensions/dicom-p10-downloader/src/downloadAndZip.js @@ -0,0 +1,244 @@ +import OHIF from '@ohif/core'; +import { api } from 'dicomweb-client'; +import dicomParser from 'dicom-parser'; +import JSZip from 'jszip'; + +/** + * Constants + */ + +const { + utils: { + isDicomUid, + hierarchicalListUtils, + progressTrackingUtils: progressUtils, + }, +} = OHIF; + +/** + * Public Methods + */ + +/** + * Download and Zip all DICOM P10 instances from specified DICOM Web Client + * based on an hierarchical list of UIDs; + * + * @param {DICOMwebClient} dicomWebClient A DICOMwebClient instance through + * which the referenced instances will be retrieved; + * @param {Array} listOfUIDs The hierarchical list of UIDs from the instances + * that should be retrieved: + * A hierarchical list of UIDs is a regular JS Array where the type of the UID + * (study, series, instance) is determined by its nasting lavel. For example: + * @ The following list instructs the library to download all the instances + * from both studies "A" and "B": + * + * ['studyUIDFromA', 'studyUIDFromB'] + * + * @ In the previous example both UIDs are treated as STUDY UIDs because both + * of them are listed in the same (top) level of the list. If, on the other + * hand, only instances from series "I" and "J" from the study "B" + * are to be downloaded, the expected hierarchical list would be: + * + * ['studyUIDFromA', ['studyUIDFromB', ['seriesUIDFromI', 'seriesUIDFromJ']]] + * + * @ Which, when prettified, reads like this: + * + * [ + * 'studyUIDFromA', + * ['studyUIDFromB', [ + * 'seriesUIDFromI', + * 'seriesUIDFromJ' + * ]] + * ] + * + * @ Furthermore, if only instances "X", "Y" and "Z" from series "J" need to + * be downloaded (instead of all the instances from that series), the list + * could be changed to: + * + * [ + * 'studyUIDFromA', + * ['studyUIDFromB', [ + * 'seriesUIDFromI', + * ['seriesUIDFromJ', [ + * 'instanceUIDFromX', + * 'instanceUIDFromY', + * 'instanceUIDFromZ' + * ]] + * ]] + * ] + * + * Please refer to hierarchicalListUtils.js for more information and utilities; + * + * @param {Object} options A plain object with options; + * @param {function} options.progress A callback to retrieve notifications + * @returns {Promise} A promise that resolves to an URL from which the ZIP file + * can be downloaded; + */ + +async function downloadAndZip(dicomWebClient, listOfUIDs, options) { + if (dicomWebClient instanceof api.DICOMwebClient) { + const settings = buildSettings(listOfUIDs, options); + const { compression } = settings.tasks; + // Register user-provided progress handler as a task list observer + progressUtils.addObserver(settings.taskList, settings.options.progress); + const buffers = await downloadAll(dicomWebClient, settings).catch(error => { + // Reject promise from compression task on download failure + compression.deferred.reject(error); + throw error; + }); + compression.deferred.resolve(zipAll(buffers, settings)); + const url = await compression.deferred.promise; + return url; + } + throw new Error('A valid DICOM Web Client instance is expected'); +} + +/** + * Utils + */ + +async function zipAll(buffers, settings) { + const zip = new JSZip(); + OHIF.log.info('Adding DICOM P10 files to archive:', buffers.length); + buffers.forEach((buffer, i) => { + const path = buildPath(buffer) || `${i}.dcm`; + zip.file(path, buffer); + }); + // Set compression task progress to 50% + progressUtils.update(settings.tasks.compression.task, 0.5); + const blob = await zip.generateAsync({ type: 'blob' }); + return URL.createObjectURL(blob); +} + +function buildSettings(listOfUIDs, options) { + const taskList = progressUtils.createList(); + const compression = progressUtils.addDeferred(taskList); + const downloads = []; + + // Build downloads list + hierarchicalListUtils.forEach( + listOfUIDs, + (StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID) => { + if (isDicomUid(StudyInstanceUID)) { + downloads.push({ + tracking: progressUtils.addDeferred(taskList), + parameters: [StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID], + }); + } + } + ); + + // Print tree of hierarchical references + OHIF.log.info('Downloading DICOM P10 files for references:'); + OHIF.log.info(hierarchicalListUtils.print(listOfUIDs)); + + return { + options: Object(options), + taskList, + tasks: { + downloads, + compression, + }, + }; +} + +function buildPath(buffer) { + let path; + try { + const byteArray = new Uint8Array(buffer); + const dataSet = dicomParser.parseDicom(byteArray, { + // Stop parsing after SeriesInstanceUID is found + untilTag: 'x0020000e', + }); + const StudyInstanceUID = dataSet.string('x0020000d'); + const SeriesInstanceUID = dataSet.string('x0020000e'); + const SOPInstanceUID = dataSet.string('x00080018'); + if (StudyInstanceUID && SeriesInstanceUID && SOPInstanceUID) { + path = `${StudyInstanceUID}/${SeriesInstanceUID}/${SOPInstanceUID}.dcm`; + } + } catch (e) { + OHIF.log.error('Error parsing downloaded DICOM P10 file...', e); + } + return path; +} + +async function downloadAll(dicomWebClient, settings) { + const { downloads } = settings.tasks; + + // Make sure at least one download was initiated + if (downloads.length < 1) { + throw new Error('No valid reference to be downloaded'); + } + + const promises = downloads.map(item => { + const { + parameters, + tracking: { deferred, task }, + } = item; + deferred.resolve(download(task, dicomWebClient, ...parameters)); + return deferred.promise; + }); + + // Wait on created download promises + return Promise.all(promises).then(results => { + const buffers = []; + // The "results" array may directly contain buffers (ArrayBuffer instances) + // or arrays of buffers, depending on the type of downloads initiated on the + // previous step (retrieveStudy, retrieveSeries or retrieveinstance). Ex: + // results = [buf1, [buf2, buf3], buf4, [buf5], ...]; + results.forEach( + function select(nesting, result) { + if (result instanceof ArrayBuffer) { + buffers.push(result); + } else if (nesting && Array.isArray(result)) { + // "nesting" argument is important to make sure only two levels + // of arrays are visited. For example, "bufX" should not be visited: + // [buf1, [buf2, buf3, [bufX]], buf4, [buf5], ...]; + result.forEach(select.bind(null, false)); + } + }.bind(null, true) + ); + return buffers; + }); +} + +async function download( + task, + dicomWebClient, + studyInstanceUID, + seriesInstanceUID, + sopInstanceUID +) { + // Strict DICOM-formatted variable names COULDN'T be used here because the + // DICOM Web client interface expects them in this specific format. + // @TODO: Add support for download progress handler which will use the + // currently not use "task" param + if (!isDicomUid(studyInstanceUID)) { + throw new Error('Download requires at least a "StudyInstanceUID" property'); + } + if (!isDicomUid(seriesInstanceUID)) { + // Download entire study + return dicomWebClient.retrieveStudy({ + studyInstanceUID, + }); + } + if (!isDicomUid(sopInstanceUID)) { + // Download entire series + return dicomWebClient.retrieveSeries({ + studyInstanceUID, + seriesInstanceUID, + }); + } + // Download specific instance + return dicomWebClient.retrieveInstance({ + studyInstanceUID, + seriesInstanceUID, + sopInstanceUID, + }); +} + +/** + * Exports + */ + +export { downloadAndZip as default, downloadAndZip }; diff --git a/extensions/dicom-p10-downloader/src/index.js b/extensions/dicom-p10-downloader/src/index.js new file mode 100644 index 00000000000..95fe623c4e5 --- /dev/null +++ b/extensions/dicom-p10-downloader/src/index.js @@ -0,0 +1,43 @@ +import { getDicomWebClientFromConfig } from './utils'; +import { getCommands } from './commandsModule'; + +/** + * Constants + */ + +/** + * Globals + */ + +const sharedContext = { + dicomWebClient: null, +}; + +/** + * Extension + */ +export default { + /** + * Only required property. Should be a unique value across all extensions. + */ + id: 'dicom-p10-downloader', + + /** + * LIFECYCLE HOOKS + */ + + preRegistration({ appConfig }) { + const dicomWebClient = getDicomWebClientFromConfig(appConfig); + if (dicomWebClient) { + sharedContext.dicomWebClient = dicomWebClient; + } + }, + + /** + * MODULE GETTERS + */ + + getCommandsModule() { + return getCommands(sharedContext); + }, +}; diff --git a/extensions/dicom-p10-downloader/src/utils.js b/extensions/dicom-p10-downloader/src/utils.js new file mode 100644 index 00000000000..d606d1f77df --- /dev/null +++ b/extensions/dicom-p10-downloader/src/utils.js @@ -0,0 +1,110 @@ +import OHIF from '@ohif/core'; +import { api } from 'dicomweb-client'; +import { saveAs } from 'file-saver'; + +const { + utils: { isDicomUid, resolveObjectPath, hierarchicalListUtils }, + DICOMWeb, +} = OHIF; + +function validDicomUid(subject) { + if (isDicomUid(subject)) { + return subject; + } +} + +function getActiveServerFromServersStore(store) { + const servers = resolveObjectPath(store, 'servers'); + if (Array.isArray(servers) && servers.length > 0) { + return servers.find(server => resolveObjectPath(server, 'active') === true); + } +} + +function getDicomWebClientFromConfig(config) { + const servers = resolveObjectPath(config, 'servers.dicomWeb'); + if (Array.isArray(servers) && servers.length > 0) { + const server = servers[0]; + return new api.DICOMwebClient({ + url: server.wadoRoot, + headers: DICOMWeb.getAuthorizationHeader(server), + }); + } +} + +function getDicomWebClientFromContext(context, store) { + const activeServer = getActiveServerFromServersStore(store); + if (activeServer) { + return new api.DICOMwebClient({ + url: activeServer.wadoRoot, + headers: DICOMWeb.getAuthorizationHeader(activeServer), + }); + } else if (context.dicomWebClient instanceof api.DICOMwebClient) { + return context.dicomWebClient; + } +} + +function getSOPInstanceReference(viewports, index) { + if (index >= 0) { + const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = Object( + resolveObjectPath(viewports, `viewportSpecificData.${index}`) + ); + return Object.freeze( + hierarchicalListUtils.addToList( + [], + validDicomUid(StudyInstanceUID), + validDicomUid(SeriesInstanceUID), + validDicomUid(SOPInstanceUID) + ) + ); + } +} + +function getSOPInstanceReferenceFromActiveViewport(viewports) { + return getSOPInstanceReference( + viewports, + resolveObjectPath(viewports, 'activeViewportIndex') + ); +} + +function getSOPInstanceReferencesFromViewports(viewports) { + const list = []; + const viewportSpecificData = resolveObjectPath( + viewports, + 'viewportSpecificData' + ); + Object.keys(viewportSpecificData).forEach(index => { + const { StudyInstanceUID, SeriesInstanceUID, SOPInstanceUID } = Object( + viewportSpecificData[index] + ); + hierarchicalListUtils.addToList( + list, + validDicomUid(StudyInstanceUID), + validDicomUid(SeriesInstanceUID), + validDicomUid(SOPInstanceUID) + ); + }); + return list; +} + +function save(promise, listOfUIDs) { + return Promise.resolve(promise) + .then(url => { + OHIF.log.info('Files successfully compressed:', url); + const StudyInstanceUID = hierarchicalListUtils.getItem(listOfUIDs, 0); + saveAs(url, `${StudyInstanceUID}.zip`); + return url; + }) + .catch(error => { + OHIF.log.error('Failed to create Zip file...', error); + return null; + }); +} + +export { + save, + validDicomUid, + getDicomWebClientFromConfig, + getDicomWebClientFromContext, + getSOPInstanceReferenceFromActiveViewport, + getSOPInstanceReferencesFromViewports, +}; diff --git a/platform/core/src/utils/Queue.js b/platform/core/src/utils/Queue.js new file mode 100644 index 00000000000..78ee0468479 --- /dev/null +++ b/platform/core/src/utils/Queue.js @@ -0,0 +1,66 @@ + +export default class Queue { + + constructor(limit) { + this.limit = limit; + this.size = 0; + this.awaiting = null; + } + + /** + * Creates a new "proxy" function associated with the current execution queue + * instance. When the returned function is invoked, the queue limit is checked + * to make sure the limit of scheduled tasks is repected (throwing an + * exception when the limit has been reached and before calling the original + * function). The original function is only invoked after all the previously + * scheduled tasks have finished executing (their returned promises have + * resolved/rejected); + * + * @param {function} task The function whose execution will be associated + * with the current Queue instance; + * @returns {function} The "proxy" function bound to the current Queue + * instance; + */ + bind(task) { + return bind(this, task); + } + + bindSafe(task, onError) { + const boundTask = bind(this, task); + return async function safeTask(...args) { + try { + return await boundTask(...args); + } catch (e) { + onError(e); + } + }; + } +} + +/** + * Utils + */ + +function bind(queue, task) { + const cleaner = clean.bind(null, queue); + return async function boundTask(...args) { + if (queue.size >= queue.limit) { + throw new Error('Queue limit reached'); + } + const promise = chain(queue.awaiting, task, args); + queue.awaiting = promise.then(cleaner, cleaner); + queue.size++; + return promise; + }; +} + +function clean(queue) { + if (queue.size > 0 && --queue.size === 0) { + queue.awaiting = null; + } +} + +async function chain(prev, task, args) { + await prev; + return task(...args); +} diff --git a/platform/core/src/utils/Queue.test.js b/platform/core/src/utils/Queue.test.js new file mode 100644 index 00000000000..0bd56e2ee9f --- /dev/null +++ b/platform/core/src/utils/Queue.test.js @@ -0,0 +1,66 @@ +import makeDeferred from './makeDeferred'; +import Queue from './Queue'; + +/** + * Utils + */ + +function timeout(delay) { + const { resolve, promise } = makeDeferred(); + setTimeout(() => void resolve(Date.now()), delay); + return promise; +} + +/** + * Tests + */ + +describe('Queue', () => { + it('should bind functions to the queue', async () => { + const queue = new Queue(2); + const mockedTimeout = jest.fn(timeout); + const timer = queue.bind(mockedTimeout); + const start = Date.now(); + timer(120).then(now => { + const elapsed = now - start; + expect(elapsed >= 120 && elapsed < 240).toBe(true); + }); + const end = await timer(120); + expect(end - start > 240).toBe(true); + expect(mockedTimeout).toBeCalledTimes(2); + }); + it('should prevent task execution when queue limit is reached', async () => { + const queue = new Queue(1); + const mockedTimeout = jest.fn(timeout); + const timer = queue.bind(mockedTimeout); + const start = Date.now(); + const promise = timer(120).then(time => time - start); + try { + await timer(120); + } catch (e) { + expect(Date.now() - start < 120).toBe(true); + expect(e.message).toBe('Queue limit reached'); + } + const elapsed = await promise; + expect(elapsed >= 120 && elapsed < 240).toBe(true); + expect(mockedTimeout).toBeCalledTimes(1); + }); + it('should safely bind tasks to the queue', async () => { + const queue = new Queue(1); + const mockedErrorHandler = jest.fn(); + const mockedTimeout = jest.fn(timeout); + const timer = queue.bindSafe(mockedTimeout, mockedErrorHandler); + const start = Date.now(); + const promise = timer(120).then(time => time - start); + await timer(120); + expect(Date.now() - start < 120).toBe(true); + expect(mockedErrorHandler).toBeCalledTimes(1); + expect(mockedErrorHandler).nthCalledWith( + 1, + expect.objectContaining({ message: 'Queue limit reached' }) + ); + const elapsed = await promise; + expect(elapsed >= 120 && elapsed < 240).toBe(true); + expect(mockedTimeout).toBeCalledTimes(1); + }); +}); diff --git a/platform/core/src/utils/hierarchicalListUtils.js b/platform/core/src/utils/hierarchicalListUtils.js new file mode 100644 index 00000000000..e82010127f3 --- /dev/null +++ b/platform/core/src/utils/hierarchicalListUtils.js @@ -0,0 +1,193 @@ +/** + * Constants + */ + +const SEPARATOR = '/'; + +/** + * API + */ + +/** + * Add values to a list hierarchically. + * @ For example: + * addToList([], 'a', 'b', 'c'); + * will add the following hierarchy to the list: + * a > b > c + * resulting in the following array: + * [['a', [['b', ['c']]]]] + * @param {Array} list The target list; + * @param {...string} values The values to be hierarchically added to the list; + * @returns {Array} Returns the provided list possibly updated with the given + * values or null when a bad list (not an actual array) is provided + */ +function addToList(list, ...values) { + if (Array.isArray(list)) { + if (values.length > 0) { + addValuesToList(list, values); + } + return list; + } + return null; +} + +/** + * Iterates through the provided hierarchical list executing the callback + * once for each leaf-node of the tree. The ancestors of the leaf-node being + * visited are passed to the callback function along with the leaf-node in + * the exact same order they appear on the tree (from root to leaf); + * @ For example, if the hierachy `a > b > c` appears on the tree ("a" being + * the root and "c" being the leaf) the callback function will be called as: + * callback('a', 'b', 'c'); + * @param {Array} list The hierarchical list to be iterated + * @param {function} callback The callback which will be exected once for + * each leaf-node of the hierarchical list; + * @returns {Array} Returns the provided list or null for bad arguments; + */ +function forEach(list, callback) { + if (Array.isArray(list)) { + if (typeof callback === 'function') { + forEachValue(list, callback); + } + return list; + } + return null; +} + +/** + * Retrieves an item from the given hierarchical list based on an index (number) + * or a path (string). + * @ For example: + * getItem(list, '1/0/4') + * will retrieve the fourth grandchild, from the first child of the second + * element of the list; + * @param {Array} list The source list; + * @param {string|number} indexOrPath The index of the element inside list + * (number) or the path to reach the desired element (string). The slash "/" + * character is cosidered the path separator; + */ +function getItem(list, indexOrPath) { + if (Array.isArray(list)) { + let subpath = null; + let index = typeof indexOrPath === 'number' ? indexOrPath : -1; + if (typeof indexOrPath === 'string') { + const separator = indexOrPath.indexOf(SEPARATOR); + if (separator > 0) { + index = parseInt(indexOrPath.slice(0, separator), 10); + if (separator + 1 < indexOrPath.length) { + subpath = indexOrPath.slice(separator + 1, indexOrPath.length); + } + } else { + index = parseInt(indexOrPath, 10); + } + } + if (index >= 0 && index < list.length) { + const item = list[index]; + if (isSublist(item)) { + if (subpath !== null) { + return getItem(item[1], subpath); + } + return item[0]; + } + return item; + } + } +} + +/** + * Pretty-print the provided hierarchical list; + * @param {Array} list The source list; + * @returns {string} The textual representation of the hierarchical list; + */ +function print(list) { + let text = ''; + if (Array.isArray(list)) { + let prev = []; + forEachValue(list, function(...args) { + let prevLen = prev.length; + for (let i = 0, l = args.length; i < l; ++i) { + if (i < prevLen && args[i] === prev[i]) { + continue; + } + text += ' '.repeat(i) + args[i] + '\n'; + } + prev = args; + }); + } + return text; +} + +/** + * Utils + */ + +function forEachValue(list, callback) { + for (let i = 0, l = list.length; i < l; ++i) { + let item = list[i]; + if (isSublist(item)) { + if (item[1].length > 0) { + forEachValue(item[1], callback.bind(null, item[0])); + continue; + } + item = item[0]; + } + callback(item); + } +} + +function addValuesToList(list, values) { + let value = values.shift(); + let index = add(list, value); + if (index >= 0) { + if (values.length > 0) { + let sublist = list[index]; + if (!isSublist(sublist)) { + sublist = toSublist(value); + list[index] = sublist; + } + return addValuesToList(sublist[1], values); + } + return true; + } + return false; +} + +function add(list, value) { + let index = find(list, value); + if (index === -2) { + index = list.push(value) - 1; + } + return index; +} + +function find(list, value) { + if (typeof value === 'string') { + for (let i = 0, l = list.length; i < l; ++i) { + let item = list[i]; + if (item === value || (isSublist(item) && item[0] === value)) { + return i; + } + } + return -2; + } + return -1; +} + +function isSublist(subject) { + return ( + Array.isArray(subject) && + subject.length === 2 && + typeof subject[0] === 'string' && + Array.isArray(subject[1]) + ); +} + +function toSublist(value) { + return [value + '', []]; +} + +/** + * Exports + */ + +export { addToList, getItem, forEach, print }; diff --git a/platform/core/src/utils/hierarchicalListUtils.test.js b/platform/core/src/utils/hierarchicalListUtils.test.js new file mode 100644 index 00000000000..4141783391d --- /dev/null +++ b/platform/core/src/utils/hierarchicalListUtils.test.js @@ -0,0 +1,96 @@ +import { addToList, forEach, getItem, print } from './hierarchicalListUtils'; + +describe('hierarchicalListUtils', function() { + let sharedList; + + beforeEach(function() { + sharedList = [ + ['1.2.3.1', ['1.2.3.1.1', '1.2.3.1.2']], + '1.2.3.2', + ['1.2.3.3', ['1.2.3.3.1', ['1.2.3.3.2', ['1.2.3.3.2.1', '1.2.3.3.2.2']]]], + ]; + }); + + describe('getItem', function() { + it('should retrieve elements from a list by index', function() { + expect(getItem(sharedList, 0)).toBe('1.2.3.1'); + expect(getItem(sharedList, 1)).toBe('1.2.3.2'); + expect(getItem(sharedList, 2)).toBe('1.2.3.3'); + expect(getItem(sharedList, 3)).toBeUndefined(); + }); + it('should retrieve elements from a list by path', function() { + expect(getItem(sharedList, '0')).toBe('1.2.3.1'); + expect(getItem(sharedList, '0/0')).toBe('1.2.3.1.1'); + expect(getItem(sharedList, '0/1')).toBe('1.2.3.1.2'); + expect(getItem(sharedList, '0/2')).toBeUndefined(); + expect(getItem(sharedList, '1')).toBe('1.2.3.2'); + expect(getItem(sharedList, '2')).toBe('1.2.3.3'); + expect(getItem(sharedList, '2/0')).toBe('1.2.3.3.1'); + expect(getItem(sharedList, '2/1')).toBe('1.2.3.3.2'); + expect(getItem(sharedList, '2/2')).toBeUndefined(); + expect(getItem(sharedList, '2/1/0')).toBe('1.2.3.3.2.1'); + expect(getItem(sharedList, '2/1/1')).toBe('1.2.3.3.2.2'); + expect(getItem(sharedList, '2/1/2')).toBeUndefined(); + expect(getItem(sharedList, '3')).toBeUndefined(); + }); + }); + + describe('addToList', function() { + it('should support adding elements to a list hierarchically', function() { + const list = []; + addToList(list, '1.2.3.1', '1.2.3.1.1'); + addToList(list, '1.2.3.1', '1.2.3.1.2'); + addToList(list, '1.2.3.2'); + addToList(list, '1.2.3.3', '1.2.3.3.1'); + addToList(list, '1.2.3.3', '1.2.3.3.2', '1.2.3.3.2.1'); + addToList(list, '1.2.3.3', '1.2.3.3.2', '1.2.3.3.2.2'); + expect(list).toStrictEqual(sharedList); + }); + it('should change leaf nodes into non-leaf nodes', function() { + const listw = []; + const listx = [['x.1', ['x.1.1', 'x.1.2']], 'x.2']; + const listy = [ + ['x.1', [['x.1.1', ['x.1.1.1']], 'x.1.2']], + ['x.2', ['x.2.1']], + ]; + addToList(listw, 'x.1'); + addToList(listw, 'x.1', 'x.1.1'); + addToList(listw, 'x.1', 'x.1.2'); + addToList(listw, 'x.2'); + expect(listw).toStrictEqual(listx); + addToList(listw, 'x.2', 'x.2.1'); + addToList(listw, 'x.1', 'x.1.1', 'x.1.1.1'); + expect(listw).toStrictEqual(listy); + }); + }); + + describe('forEach', function() { + it('should iterate through all leaf nodes of the tree', function() { + const fn = jest.fn(); + forEach(sharedList, fn); + expect(fn).toHaveBeenCalledTimes(6); + expect(fn).nthCalledWith(1, '1.2.3.1', '1.2.3.1.1'); + expect(fn).nthCalledWith(2, '1.2.3.1', '1.2.3.1.2'); + expect(fn).nthCalledWith(3, '1.2.3.2'); + expect(fn).nthCalledWith(4, '1.2.3.3', '1.2.3.3.1'); + expect(fn).nthCalledWith(5, '1.2.3.3', '1.2.3.3.2', '1.2.3.3.2.1'); + expect(fn).nthCalledWith(6, '1.2.3.3', '1.2.3.3.2', '1.2.3.3.2.2'); + }); + }); + + describe('print', function() { + it('should pretty-print the hierarchical list', function() { + expect(print(sharedList)).toBe( + '1.2.3.1\n' + + ' 1.2.3.1.1\n' + + ' 1.2.3.1.2\n' + + '1.2.3.2\n' + + '1.2.3.3\n' + + ' 1.2.3.3.1\n' + + ' 1.2.3.3.2\n' + + ' 1.2.3.3.2.1\n' + + ' 1.2.3.3.2.2\n' + ); + }); + }); +}); diff --git a/platform/core/src/utils/index.js b/platform/core/src/utils/index.js index fa992c476c3..6e9ea3140d5 100644 --- a/platform/core/src/utils/index.js +++ b/platform/core/src/utils/index.js @@ -10,8 +10,14 @@ import DicomLoaderService from './dicomLoaderService.js'; import b64toBlob from './b64toBlob.js'; import loadAndCacheDerivedDisplaySets from './loadAndCacheDerivedDisplaySets.js'; import * as urlUtil from './urlUtil'; +import makeDeferred from './makeDeferred'; import makeCancelable from './makeCancelable'; import hotkeys from './hotkeys'; +import Queue from './Queue'; +import isDicomUid from './isDicomUid'; +import resolveObjectPath from './resolveObjectPath'; +import * as hierarchicalListUtils from './hierarchicalListUtils'; +import * as progressTrackingUtils from './progressTrackingUtils'; const utils = { guid, @@ -26,8 +32,14 @@ const utils = { DicomLoaderService, urlUtil, loadAndCacheDerivedDisplaySets, + makeDeferred, makeCancelable, hotkeys, + Queue, + isDicomUid, + resolveObjectPath, + hierarchicalListUtils, + progressTrackingUtils, }; export { @@ -43,8 +55,14 @@ export { DicomLoaderService, urlUtil, loadAndCacheDerivedDisplaySets, + makeDeferred, makeCancelable, hotkeys, + Queue, + isDicomUid, + resolveObjectPath, + hierarchicalListUtils, + progressTrackingUtils, }; export default utils; diff --git a/platform/core/src/utils/index.test.js b/platform/core/src/utils/index.test.js index 347518a9288..85eb382304a 100644 --- a/platform/core/src/utils/index.test.js +++ b/platform/core/src/utils/index.test.js @@ -15,8 +15,14 @@ describe('Top level exports', () => { 'loadAndCacheDerivedDisplaySets', 'DicomLoaderService', 'urlUtil', + 'makeDeferred', 'makeCancelable', 'hotkeys', + 'Queue', + 'isDicomUid', + 'resolveObjectPath', + 'hierarchicalListUtils', + 'progressTrackingUtils', ].sort(); const exports = Object.keys(utils.default).sort(); diff --git a/platform/core/src/utils/isDicomUid.js b/platform/core/src/utils/isDicomUid.js new file mode 100644 index 00000000000..42eda4a0c54 --- /dev/null +++ b/platform/core/src/utils/isDicomUid.js @@ -0,0 +1,4 @@ +export default function isDicomUid(subject) { + const regex = /^\d+(?:\.\d+)*$/; + return typeof subject === 'string' && regex.test(subject.trim()); +} diff --git a/platform/core/src/utils/isDicomUid.test.js b/platform/core/src/utils/isDicomUid.test.js new file mode 100644 index 00000000000..3a442823a94 --- /dev/null +++ b/platform/core/src/utils/isDicomUid.test.js @@ -0,0 +1,16 @@ +import isDicomUid from './isDicomUid'; + +describe('isDicomUid', function() { + it('should return true for valid DICOM UIDs', function() { + expect(isDicomUid('1')).toBe(true); + expect(isDicomUid('1.2')).toBe(true); + expect(isDicomUid('1.2.3')).toBe(true); + expect(isDicomUid('1.2.3.4')).toBe(true); + }); + it('should return false for invalid DICOM UIDs', function() { + expect(isDicomUid('x')).toBe(false); + expect(isDicomUid('1.')).toBe(false); + expect(isDicomUid('1. 2')).toBe(false); + expect(isDicomUid('1.2.n.4')).toBe(false); + }); +}); diff --git a/platform/core/src/utils/makeDeferred.js b/platform/core/src/utils/makeDeferred.js new file mode 100644 index 00000000000..0f5a0fa9f7b --- /dev/null +++ b/platform/core/src/utils/makeDeferred.js @@ -0,0 +1,7 @@ +export default function makeDeferred() { + let reject, resolve, promise = new Promise(function (res, rej) { + resolve = res; + reject = rej; + }); + return Object.freeze({ promise, resolve, reject }); +} diff --git a/platform/core/src/utils/makeDeferred.test.js b/platform/core/src/utils/makeDeferred.test.js new file mode 100644 index 00000000000..db27db0ddb8 --- /dev/null +++ b/platform/core/src/utils/makeDeferred.test.js @@ -0,0 +1,14 @@ +import makeDeferred from './makeDeferred'; + +describe('makeDeferred', () => { + it('should provide a promise to be resolved externally', () => { + const deferred = makeDeferred(); + setTimeout(() => void deferred.resolve('Yay!')); + return deferred.promise.then(result => void expect(result).toBe('Yay!')); + }); + it('should provide a promise to be rejected externally', () => { + const deferred = makeDeferred(); + setTimeout(() => void deferred.reject('Oops...')); + return deferred.promise.catch(error => void expect(error).toBe('Oops...')); + }); +}); diff --git a/platform/core/src/utils/progressTrackingUtils.js b/platform/core/src/utils/progressTrackingUtils.js new file mode 100644 index 00000000000..400720d4b64 --- /dev/null +++ b/platform/core/src/utils/progressTrackingUtils.js @@ -0,0 +1,336 @@ +import makeDeferred from './makeDeferred'; + +/** + * Constants + */ + +const TYPE = Symbol('Type'); +const TASK = Symbol('Task'); +const LIST = Symbol('List'); + +/** + * Public Methods + */ + +/** + * Creates an instance of a task list + * @returns {Object} A task list object + */ +function createList() { + return objectWithType(LIST, { + head: null, + named: Object.create(null), + observers: [], + }); +} + +/** + * Checks if the given argument is a List instance + * @param {any} subject The value to be tested + * @returns {boolean} true if a valid List instance is given, false otherwise + */ +function isList(subject) { + return isOfType(LIST, subject); +} + +/** + * Creates an instance of a task + * @param {Object} list The List instance related to this task + * @param {Object} next The next Task instance to link to + * @returns {Object} A task object + */ +function createTask(list, next) { + return objectWithType(TASK, { + list: isList(list) ? list : null, + next: isTask(next) ? next : null, + failed: false, + awaiting: null, + progress: 0.0, + }); +} + +/** + * Checks if the given argument is a Task instance + * @param {any} subject The value to be tested + * @returns {boolean} true if a valid Task instance is given, false otherwise + */ +function isTask(subject) { + return isOfType(TASK, subject); +} + +/** + * Appends a new Task to the given List instance and notifies the list observers + * @param {Object} list A List instance + * @returns {Object} The new Task instance appended to the List or null if the + * given List instanc is not valid + */ +function increaseList(list) { + if (isList(list)) { + const task = createTask(list, list.head); + list.head = task; + notify(list, getOverallProgress(list)); + return task; + } + return null; +} + +/** + * Updates the internal progress value of the given Task instance and notifies + * the observers of the associated list. + * @param {Object} task The Task instance to be updated + * @param {number} value A number between 0 (inclusive) and 1 (exclusive) + * indicating the progress of the task; + * @returns {void} Nothing is returned + */ +function update(task, value) { + if (isTask(task) && isValidProgress(value) && value < 1.0) { + if (task.progress !== value) { + task.progress = value; + if (isList(task.list)) { + notify(task.list, getOverallProgress(task.list)); + } + } + } +} + +/** + * Sets a Task instance as finished (progress = 1.0), freezes it in order to + * prevent further modifications and notifies the observers of the associated + * list. + * @param {Object} task The Task instance to be finalized + * @returns {void} Nothing is returned + */ +function finish(task) { + if (isTask(task)) { + task.progress = 1.0; + task.awaiting = null; + Object.freeze(task); + if (isList(task.list)) { + notify(task.list, getOverallProgress(task.list)); + } + } +} + +/** + * Generate a summarized snapshot of the current status of the given task List + * @param {Object} list The List instance to be scanned + * @returns {Object} An obeject representing the summarized status of the list + */ +function getOverallProgress(list) { + const status = createStatus(); + if (isList(list)) { + let task = list.head; + while (isTask(task)) { + status.total++; + if (isValidProgress(task.progress)) { + status.partial += task.progress; + if (task.progress === 1.0 && task.failed) status.failures++; + } + task = task.next; + } + } + if (status.total > 0) { + status.progress = status.partial / status.total; + } + return Object.freeze(status); +} + +/** + * Adds a Task instance to the given list that waits on a given "thenable". When + * the thenable resolves the "finish" method is called on the newly created + * instance thus notifying the observers of the list. + * @param {Object} list The List instance to which the new task will be added + * @param {Object|Promise} thenable The thenable to be waited on + * @returns {Object} A reference to the newly created Task; + */ +function waitOn(list, thenable) { + const task = increaseList(list); + if (isTask(task)) { + task.awaiting = Promise.resolve(thenable).then( + function() { + finish(task); + }, + function() { + task.failed = true; + finish(task); + } + ); + return task; + } + return null; +} + +/** + * Adds a Task instance to the given list using a deferred (a Promise that can + * be externally resolved) notifying the observers of the list. + * @param {Object} list The List instance to which the new task will be added + * @returns {Object} An object with references to the created deferred and task + */ +function addDeferred(list) { + const deferred = makeDeferred(); + const task = waitOn(list, deferred.promise); + return Object.freeze({ + deferred, + task, + }); +} + +/** + * Assigns a name to a specific task of the list + * @param {Object} list The List instance whose task will be named + * @param {Object} task The specified Task instance + * @param {string} name The name of the task + * @returns {boolean} Returns true on success, false otherwise + */ +function setTaskName(list, task, name) { + if ( + contains(list, task) && + list.named !== null && + typeof list.named === 'object' && + typeof name === 'string' + ) { + list.named[name] = task; + return true; + } + return false; +} + +/** + * Retrieves a task by name + * @param {Object} list The List instance whose task will be retrieved + * @param {string} name The name of the task to be retrieved + * @returns {Object} The Task instance or null if not found + */ +function getTaskByName(list, name) { + if ( + isList(list) && + list.named !== null && + typeof list.named === 'object' && + typeof name === 'string' + ) { + const task = list.named[name]; + if (isTask(task)) { + return task; + } + } + return null; +} + +/** + * Adds an observer (callback function) to a given List instance + * @param {Object} list The List instance to which the observer will be appended + * @param {Function} observer The observer (function) that will be executed + * every time a change happens within the list + * @returns {boolean} Returns true on success and false otherewise + */ +function addObserver(list, observer) { + if ( + isList(list) && + Array.isArray(list.observers) && + typeof observer === 'function' + ) { + list.observers.push(observer); + return true; + } + return false; +} + +/** + * Removes an observer (callback function) from a given List instance + * @param {Object} list The instance List from which the observer will removed + * @param {Function} observer The observer function to be removed + * @returns {boolean} Returns true on success and false otherewise + */ +function removeObserver(list, observer) { + if ( + isList(list) && + Array.isArray(list.observers) && + list.observers.length > 0 + ) { + const index = list.observers.indexOf(observer); + if (index >= 0) { + list.observers.splice(index, 1); + return true; + } + } + return false; +} + +/** + * Utils + */ + +function createStatus() { + return Object.seal({ + total: 0, + partial: 0.0, + progress: 0.0, + failures: 0, + }); +} + +function objectWithType(type, object) { + return Object.seal(Object.defineProperty(object, TYPE, { value: type })); +} + +function isOfType(type, subject) { + return ( + subject !== null && typeof subject === 'object' && subject[TYPE] === type + ); +} + +function isValidProgress(value) { + return typeof value === 'number' && value >= 0.0 && value <= 1.0; +} + +function contains(list, task) { + if (isList(list) && isTask(task)) { + let item = list.head; + while (isTask(item)) { + if (item === task) { + return true; + } + item = item.next; + } + } + return false; +} + +function notify(list, data) { + if ( + isList(list) && + Array.isArray(list.observers) && + list.observers.length > 0 + ) { + list.observers.slice().forEach(function(observer) { + if (typeof observer === 'function') { + try { + observer(data, list); + } catch (e) { + /* Oops! */ + } + } + }); + } +} + +/** + * Exports + */ + +export { + createList, + isList, + createTask, + isTask, + increaseList, + update, + finish, + getOverallProgress, + waitOn, + addDeferred, + setTaskName, + getTaskByName, + addObserver, + removeObserver, +}; diff --git a/platform/core/src/utils/progressTrackingUtils.test.js b/platform/core/src/utils/progressTrackingUtils.test.js new file mode 100644 index 00000000000..5d9e5dc4752 --- /dev/null +++ b/platform/core/src/utils/progressTrackingUtils.test.js @@ -0,0 +1,175 @@ +import * as utils from './progressTrackingUtils'; + +describe('progressTrackingUtils', () => { + describe('Creation of lists of tasks to be tracked', () => { + it('should support creation of task lists', () => { + expect(utils.createList()).toBeInstanceOf(Object); + }); + it('should support validation of task lists', () => { + const list = utils.createList(); + expect(utils.isList(list)).toBe(true); + expect(utils.isList(JSON.parse(JSON.stringify(list)))).toBe(false); + }); + }); + + describe('Usage of lists of tasks to be tracked', () => { + let context; + + // Mock for download + function fakeRequest(callback) { + return new Promise(resolve => { + let progress = 0.0; + setTimeout(function step() { + if (progress < 1.0) { + progress += 1 / 4; + callback(progress); + setTimeout(step); + return; + } + resolve(true); + }); + }); + } + + beforeEach(() => { + const list = utils.createList(); + const observer = jest.fn(); + utils.addObserver(list, observer); + context = { list, observer }; + }); + + it('should call observer twice for each task', () => { + const { list, observer } = context; + const promises = [ + Promise.resolve('A'), + Promise.resolve('B'), + Promise.resolve('C'), + ]; + promises.forEach(promise => void utils.waitOn(list, promise)); + return Promise.all(promises).then(() => { + expect(observer).toBeCalledTimes(6); + [ + { + failures: 0, + partial: 0, + progress: 0, + total: 1, + }, + { + failures: 0, + partial: 0, + progress: 0, + total: 2, + }, + { + failures: 0, + partial: 0, + progress: 0, + total: 3, + }, + { + failures: 0, + partial: 1.0, + progress: 1 / 3, + total: 3, + }, + { + failures: 0, + partial: 2.0, + progress: 2 / 3, + total: 3, + }, + { + failures: 0, + partial: 3.0, + progress: 1.0, + total: 3, + }, + ].forEach((item, i) => { + const result = expect.objectContaining(item); + expect(observer).nthCalledWith(i + 1, result, list); + }); + expect(utils.getOverallProgress(list)).toStrictEqual({ + failures: 0, + partial: 3.0, + progress: 1.0, + total: 3, + }); + }); + }); + + it('should support tasks with internal progress updates', () => { + const { list, observer } = context; + const download = utils.addDeferred(list); + const processing = download.deferred.promise.then(result => result); + const update = jest.fn(p => void utils.update(download.task, p)); + download.deferred.resolve(fakeRequest(update)); + utils.waitOn(list, processing); + return processing.then(() => { + expect(update).toBeCalledTimes(4); + [0.25, 0.5, 0.75, 1.0].forEach( + (value, i) => void expect(update).nthCalledWith(i + 1, value) + ); + expect(observer).toBeCalledTimes(7); + [ + { + failures: 0, + partial: 0, + progress: 0, + total: 1, + }, + { + failures: 0, + partial: 0, + progress: 0, + total: 2, + }, + { + failures: 0, + partial: 0.25, + progress: 0.125, + total: 2, + }, + { + failures: 0, + partial: 0.5, + progress: 0.25, + total: 2, + }, + { + failures: 0, + partial: 0.75, + progress: 0.375, + total: 2, + }, + { + failures: 0, + partial: 1.0, + progress: 0.5, + total: 2, + }, + { + failures: 0, + partial: 2.0, + progress: 1.0, + total: 2, + }, + ].forEach((item, i) => { + const result = expect.objectContaining(item); + expect(observer).nthCalledWith(i + 1, result, list); + }); + }); + }); + }); + + describe('Naming of specific tasks', () => { + it('should support naming specific tasks', () => { + const list = utils.createList(); + const tasks = [utils.increaseList(list), utils.increaseList(list)]; + expect(utils.setTaskName(list, tasks[0], 'firstTask')).toBe(true); + expect(utils.setTaskName(list, tasks[1], 'secondTask')).toBe(true); + expect(utils.getTaskByName(list, 'secondTask')).toBe(tasks[1]); + expect(utils.getTaskByName(list, 'firstTask')).toBe(tasks[0]); + }); + }); +}); diff --git a/platform/core/src/utils/resolveObjectPath.js b/platform/core/src/utils/resolveObjectPath.js new file mode 100644 index 00000000000..d5e82babf56 --- /dev/null +++ b/platform/core/src/utils/resolveObjectPath.js @@ -0,0 +1,17 @@ +export default function resolveObjectPath(root, path, defaultValue) { + if (root !== null && typeof root === 'object' && typeof path === 'string') { + let value, + separator = path.indexOf('.'); + if (separator >= 0) { + return resolveObjectPath( + root[path.slice(0, separator)], + path.slice(separator + 1, path.length), + defaultValue + ); + } + value = root[path]; + return value === undefined && defaultValue !== undefined + ? defaultValue + : value; + } +} diff --git a/platform/core/src/utils/resolveObjectPath.test.js b/platform/core/src/utils/resolveObjectPath.test.js new file mode 100644 index 00000000000..6d019a5bc37 --- /dev/null +++ b/platform/core/src/utils/resolveObjectPath.test.js @@ -0,0 +1,35 @@ +import resolveObjectPath from './resolveObjectPath'; + +describe('resolveObjectPath', function() { + let config; + + beforeEach(function() { + config = { + active: { + user: { + name: { + first: 'John', + last: 'Doe', + }, + }, + servers: [ + { + ipv4: '10.0.0.1', + }, + ], + }, + }; + }); + + it('should safely return deeply nested values from an object', function() { + expect(resolveObjectPath(config, 'active.user.name.first')).toBe('John'); + expect(resolveObjectPath(config, 'active.user.name.last')).toBe('Doe'); + expect(resolveObjectPath(config, 'active.servers.0.ipv4')).toBe('10.0.0.1'); + }); + + it('should silently return undefined when intermediate values are not valid objects', function() { + expect(resolveObjectPath(config, 'active.usr.name.first')).toBeUndefined(); + expect(resolveObjectPath(config, 'active.name.last')).toBeUndefined(); + expect(resolveObjectPath(config, 'active.servers.7.ipv4')).toBeUndefined(); + }); +}); diff --git a/platform/viewer/package.json b/platform/viewer/package.json index 454a594cf3e..f4a03ffc746 100644 --- a/platform/viewer/package.json +++ b/platform/viewer/package.json @@ -56,6 +56,7 @@ "@ohif/extension-dicom-rt": "^0.0.2", "@ohif/extension-dicom-segmentation": "^0.1.2", "@ohif/extension-lesion-tracker": "^0.2.0", + "@ohif/extension-dicom-p10-downloader": "^0.0.1", "@ohif/extension-vtk": "^1.3.11", "@ohif/i18n": "^0.52.8", "@ohif/ui": "^1.4.3", diff --git a/platform/viewer/src/index.js b/platform/viewer/src/index.js index 819d4c1c39a..4b6afde12a7 100644 --- a/platform/viewer/src/index.js +++ b/platform/viewer/src/index.js @@ -29,6 +29,7 @@ import OHIFDicomSegmentationExtension from '@ohif/extension-dicom-segmentation'; import OHIFDicomRtExtension from '@ohif/extension-dicom-rt'; import OHIFDicomMicroscopyExtension from '@ohif/extension-dicom-microscopy'; import OHIFDicomPDFExtension from '@ohif/extension-dicom-pdf'; +import OHIFDicomP10DownloaderExtension from '@ohif/extension-dicom-p10-downloader'; /* * Default Settings @@ -48,6 +49,7 @@ const appProps = { OHIFDicomPDFExtension, OHIFDicomSegmentationExtension, OHIFDicomRtExtension, + OHIFDicomP10DownloaderExtension, ], };