Feat #1130 [WIP] Adding support for downloading and zipping files (#1501)

* Feat #1130 Adding support for downloading and zipping files

@ This is a combination of 12 commits.
 - Feat #1130 Initial Commit
 - Feat #1130 Adding support for hierachical lists
 - Feat #1130 [WIP] Adding support for downloading and zipping files
 - Feat #1130 Adding support for hierarchical folder structure study, series, instances on generated zip file
 - Feat #1130 Adding unit tests and addressing code review feedbacks
 - Feat #1130 POC (adding module to support on progress handling)
 - Feat #1130 Improving logic from progress tracking utils
 - Feat #1130 Renaming module with progress tracking utils
 - Feat #1130 Beta version of Progress Tracking utils
 - Feat #1130 Adding unit tests for Progress Tracking Utils
 - Feat #1130 Documenting public methods from progress tracking utils module
 - Feat #1130 Adding support for download and zip progress reporting

* Feat #1130 Addressing code review feedback

* Feat #1130 Preventing multiple downloads from executing at once

* Feat #1130 Improving error message for concurrent downloads

* Remove downloadAndZipSeriesOnViewports

Co-authored-by: James Petts <jamesapetts@gmail.com>
This commit is contained in:
Emanuel Fiuza de Oliveira 2020-04-15 13:57:57 -03:00 committed by GitHub
parent 2412c08595
commit 29d4955980
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 1665 additions and 0 deletions

View File

@ -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 });
};

View File

@ -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,
},
});
};

View File

@ -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.

View File

@ -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.

View File

@ -0,0 +1 @@
# @ohif/extension-dicom-p10-downloader

View File

@ -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"
}
}

View File

@ -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);
}
}

View File

@ -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 };

View File

@ -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);
},
};

View File

@ -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,
};

View File

@ -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);
}

View File

@ -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);
});
});

View File

@ -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 };

View File

@ -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'
);
});
});
});

View File

@ -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;

View File

@ -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();

View File

@ -0,0 +1,4 @@
export default function isDicomUid(subject) {
const regex = /^\d+(?:\.\d+)*$/;
return typeof subject === 'string' && regex.test(subject.trim());
}

View File

@ -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);
});
});

View File

@ -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 });
}

View File

@ -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...'));
});
});

View File

@ -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,
};

View File

@ -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]);
});
});
});

View File

@ -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;
}
}

View File

@ -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();
});
});

View File

@ -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",

View File

@ -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,
],
};