* 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:
parent
2412c08595
commit
29d4955980
8
extensions/dicom-p10-downloader/.webpack/webpack.dev.js
Normal file
8
extensions/dicom-p10-downloader/.webpack/webpack.dev.js
Normal 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 });
|
||||
};
|
||||
38
extensions/dicom-p10-downloader/.webpack/webpack.prod.js
Normal file
38
extensions/dicom-p10-downloader/.webpack/webpack.prod.js
Normal 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,
|
||||
},
|
||||
});
|
||||
};
|
||||
4
extensions/dicom-p10-downloader/CHANGELOG.md
Normal file
4
extensions/dicom-p10-downloader/CHANGELOG.md
Normal 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.
|
||||
21
extensions/dicom-p10-downloader/LICENSE
Normal file
21
extensions/dicom-p10-downloader/LICENSE
Normal 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.
|
||||
1
extensions/dicom-p10-downloader/README.md
Normal file
1
extensions/dicom-p10-downloader/README.md
Normal file
@ -0,0 +1 @@
|
||||
# @ohif/extension-dicom-p10-downloader
|
||||
39
extensions/dicom-p10-downloader/package.json
Normal file
39
extensions/dicom-p10-downloader/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
105
extensions/dicom-p10-downloader/src/commandsModule.js
Normal file
105
extensions/dicom-p10-downloader/src/commandsModule.js
Normal 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);
|
||||
}
|
||||
}
|
||||
244
extensions/dicom-p10-downloader/src/downloadAndZip.js
Normal file
244
extensions/dicom-p10-downloader/src/downloadAndZip.js
Normal 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 };
|
||||
43
extensions/dicom-p10-downloader/src/index.js
Normal file
43
extensions/dicom-p10-downloader/src/index.js
Normal 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);
|
||||
},
|
||||
};
|
||||
110
extensions/dicom-p10-downloader/src/utils.js
Normal file
110
extensions/dicom-p10-downloader/src/utils.js
Normal 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,
|
||||
};
|
||||
66
platform/core/src/utils/Queue.js
Normal file
66
platform/core/src/utils/Queue.js
Normal 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);
|
||||
}
|
||||
66
platform/core/src/utils/Queue.test.js
Normal file
66
platform/core/src/utils/Queue.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
193
platform/core/src/utils/hierarchicalListUtils.js
Normal file
193
platform/core/src/utils/hierarchicalListUtils.js
Normal 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 };
|
||||
96
platform/core/src/utils/hierarchicalListUtils.test.js
Normal file
96
platform/core/src/utils/hierarchicalListUtils.test.js
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
@ -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();
|
||||
|
||||
4
platform/core/src/utils/isDicomUid.js
Normal file
4
platform/core/src/utils/isDicomUid.js
Normal file
@ -0,0 +1,4 @@
|
||||
export default function isDicomUid(subject) {
|
||||
const regex = /^\d+(?:\.\d+)*$/;
|
||||
return typeof subject === 'string' && regex.test(subject.trim());
|
||||
}
|
||||
16
platform/core/src/utils/isDicomUid.test.js
Normal file
16
platform/core/src/utils/isDicomUid.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
7
platform/core/src/utils/makeDeferred.js
Normal file
7
platform/core/src/utils/makeDeferred.js
Normal 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 });
|
||||
}
|
||||
14
platform/core/src/utils/makeDeferred.test.js
Normal file
14
platform/core/src/utils/makeDeferred.test.js
Normal 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...'));
|
||||
});
|
||||
});
|
||||
336
platform/core/src/utils/progressTrackingUtils.js
Normal file
336
platform/core/src/utils/progressTrackingUtils.js
Normal 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,
|
||||
};
|
||||
175
platform/core/src/utils/progressTrackingUtils.test.js
Normal file
175
platform/core/src/utils/progressTrackingUtils.test.js
Normal 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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
17
platform/core/src/utils/resolveObjectPath.js
Normal file
17
platform/core/src/utils/resolveObjectPath.js
Normal 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;
|
||||
}
|
||||
}
|
||||
35
platform/core/src/utils/resolveObjectPath.test.js
Normal file
35
platform/core/src/utils/resolveObjectPath.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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",
|
||||
|
||||
@ -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,
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user