From 79b29070859982eef94b7736bd219afd90a2054a Mon Sep 17 00:00:00 2001 From: Leonardo Campos Date: Mon, 28 Nov 2016 02:32:34 -0200 Subject: [PATCH] LT-366: StudyList :: Export workflow issues --- .../components/bootstrap/dialog/progress.html | 24 +- .../components/bootstrap/dialog/progress.js | 38 +-- .../components/bootstrap/dialog/progress.styl | 11 +- .../client/lib/exportStudies.js | 240 ++++++++++++------ .../client/lib/queryStudies.js | 60 +++-- .../lib/helpers/formatNumberPrecision.js | 2 +- 6 files changed, 250 insertions(+), 125 deletions(-) diff --git a/Packages/ohif-core/client/components/bootstrap/dialog/progress.html b/Packages/ohif-core/client/components/bootstrap/dialog/progress.html index 09725fcc7..9adb00e37 100644 --- a/Packages/ohif-core/client/components/bootstrap/dialog/progress.html +++ b/Packages/ohif-core/client/components/bootstrap/dialog/progress.html @@ -1,13 +1,17 @@ diff --git a/Packages/ohif-core/client/components/bootstrap/dialog/progress.js b/Packages/ohif-core/client/components/bootstrap/dialog/progress.js index f8569b8e0..7f488e05e 100644 --- a/Packages/ohif-core/client/components/bootstrap/dialog/progress.js +++ b/Packages/ohif-core/client/components/bootstrap/dialog/progress.js @@ -3,20 +3,15 @@ import { Session } from 'meteor/session'; import { _ } from 'meteor/underscore'; Template.dialogProgress.onCreated(() => { - console.log('dialogProgress :: onCreated'); -}); + const instance = Template.instance(); -Template.dialogProgress.onCreated(() => { Session.set('progressDialogState', { - progress: 0, - message: 'Test Message' + processed: 0, + total: instance.data.total, + message: instance.data.message }); }) -Template.dialogProgress.onDestroyed(() => { - Session.set('progressDialogState', undefined); -}) - Template.dialogProgress.onRendered(() => { const instance = Template.instance(); const task = instance.data.task; @@ -25,10 +20,10 @@ Template.dialogProgress.onRendered(() => { const progressDialog = { promise: instance.data.promise, - done: () => { + done: value => { // Hide the modal, removing the backdrop instance.$('.modal').on('hidden.bs.modal', event => { - instance.data.promiseResolve(); + instance.data.promiseResolve(value); }).modal('hide'); }, @@ -39,9 +34,16 @@ Template.dialogProgress.onRendered(() => { }).modal('hide'); }, - setProgress: _.throttle(progress => { + update: _.throttle(processed => { const state = Session.get('progressDialogState'); - state.progress = Math.min(1, progress); + state.processed = Math.max(0, processed); + + Session.set('progressDialogState', state); + }, 100), + + setTotal: _.throttle(total => { + const state = Session.get('progressDialogState'); + state.total = total; Session.set('progressDialogState', state); }, 100), @@ -54,21 +56,23 @@ Template.dialogProgress.onRendered(() => { }, 100) } - task.start(progressDialog); + task.run(progressDialog); }); Template.dialogProgress.helpers({ progress() { const state = Session.get('progressDialogState'); - if (!state) { - return; + + if (!state || !state.total) { + return 0; } - return state.progress * 100; + return Math.min(1, state.processed / state.total) * 100; }, message() { const state = Session.get('progressDialogState'); + if (!state) { return; } diff --git a/Packages/ohif-core/client/components/bootstrap/dialog/progress.styl b/Packages/ohif-core/client/components/bootstrap/dialog/progress.styl index b086da1ad..ef1c24a0b 100644 --- a/Packages/ohif-core/client/components/bootstrap/dialog/progress.styl +++ b/Packages/ohif-core/client/components/bootstrap/dialog/progress.styl @@ -1,7 +1,14 @@ .modal-progress .status - .progress-bar - float: none; + .progress-bar-container + border: solid 1px #28405E + overflow: auto; + margin: 0 0 10px + border-radius: 3px + + .percentage + margin: 0 5px + .btn-confirm display: none \ No newline at end of file diff --git a/Packages/ohif-study-list/client/lib/exportStudies.js b/Packages/ohif-study-list/client/lib/exportStudies.js index 7eea5e1b4..56bd25770 100644 --- a/Packages/ohif-study-list/client/lib/exportStudies.js +++ b/Packages/ohif-study-list/client/lib/exportStudies.js @@ -1,7 +1,28 @@ import { JSZip } from 'meteor/silentcicero:jszip'; import { OHIF } from 'meteor/ohif:core'; -let exportFailed; +const getNumberOfFilesToExport = function(studiesToExport) { + let numberOfFilesToExport = 0; + + studiesToExport.forEach(study => { + numberOfFilesToExport += getNumberOfFilesInStudy(study); + }); + + return numberOfFilesToExport; +} + +const convertSizeToString = size => { + const measuments = ['B', 'KB', 'MB', 'GB']; + let totalBytes = size, + measumentIndex = 0; + + while(totalBytes > 1024) { + totalBytes /= 1024; + measumentIndex++; + } + + return `${totalBytes.toFixed(2)} ${measuments[measumentIndex]}`; +} /** * Exports requested studies @@ -12,105 +33,170 @@ OHIF.studylist.exportStudies = studiesToExport => { return; } - queryStudies(studiesToExport, exportQueriedStudies); + queryStudiesWithProgress(studiesToExport) + .then(exportQueriedStudiesWithProgress); }; -const exportQueriedStudies = studiesToExport => { - let numberOfFilesToExport = 0; - studiesToExport.forEach(study => { - numberOfFilesToExport += getNumberOfFilesInStudy(study); +const exportQueriedStudiesWithProgress = studiesToExport => { + const exportFilesCount = getNumberOfFilesToExport(studiesToExport); + let exportHandler; + + return OHIF.ui.showFormDialog('dialogProgress', { + title: 'Exporting Studies...', + message: `Exported files: 0 / exportFilesCount`, + total: getNumberOfFilesToExport(studiesToExport), + task: { + run: (dialog) => { + exportHandler = exportQueriedStudies(studiesToExport, { + notify: stats => { + const fileSize = convertSizeToString(stats.totalBytes); + + dialog.update(stats.processed); + dialog.setMessage(`Exported files: ${stats.processed} / ${stats.total} (${fileSize})`); + } + }); + + exportHandler.promise.then(() => { + dialog.done(); + }, () => { + dialog.setMessage('Failed to export studies'); + }); + } + } + }).then(null, err => { + exportHandler.cancel(); }); - - OHIF.studylist.progressDialog.show('Exporting Studies...', numberOfFilesToExport); - - try { - exportQueriedStudiesInternal(studiesToExport, numberOfFilesToExport); - } catch (err) { - OHIF.studylist.progressDialog.close(); - OHIF.log.error(`Failed to export studies: ${err.message}`); - } }; -const exportQueriedStudiesInternal = (studiesToExport, numberOfFilesToExport) => { - const zip = new JSZip(); +const exportQueriedStudies = (studiesToExport, options) => { + const zip = new JSZip(), + promises = [], + pendingDownloads = [], + exportFilesCount = getNumberOfFilesToExport(studiesToExport), + notify = (options || {}).notify || function() { /* noop */ }; - exportFailed = false; - let numberOfFilesExported = 0; - - const onExportFailed = err => { - exportFailed = true; - OHIF.studylist.progressDialog.close(); - - //TODO: Export failed and dialog closed, so let user know - OHIF.log.error('Failed to export studies!', err); + const cancelDownloads = () => { + while(pendingDownloads.length) { + const download = pendingDownloads.pop(); + download.cancel(); + } }; + let totalBytes = 0; + studiesToExport.forEach(study => { - sortStudy(study); - - const studyFolder = zip.folder(study.studyInstanceUid); - study.seriesList.forEach(series => { - const seriesFolder = studyFolder.folder(series.seriesInstanceUid); - series.instances.forEach(instance => { if (!instance.wadouri) { return; } - // If failed to download a dicom file, skip others - if (exportFailed) { - return; - } + const download = downloadDicomFile(instance); + pendingDownloads.push(download); - // Download and Zip the dicom file - const xhr = new XMLHttpRequest(); - xhr.open('GET', instance.wadouri, true); - xhr.responseType = 'blob'; + const promise = download.promise + .then(data => { + const downloadIndex = pendingDownloads.indexOf(download); - // Downloaded the dicom file completely - xhr.onload = () => { - // If failed to download a dicom file, skip others - if (exportFailed) { - return; + totalBytes += data && data.size ? data.size : 0; + + if(downloadIndex > -1) { + pendingDownloads.splice(downloadIndex, 1); } - // Failed to export a file - if (xhr.readyState === 4 && xhr.status !== 200) { - onExportFailed(`File not downloaded: ${instance.wadouri}`); - return; + notify({ + total: exportFilesCount, + processed: exportFilesCount - pendingDownloads.length, + totalBytes: totalBytes + }) + + return zipInstance(study, series, instance, zip, data) + }) + .catch(err => { + if(!(err instanceof ExportStudyDownloadCanceledError)) { + OHIF.log.error('Failed to export studies', err); } - const blobFile = new Blob([xhr.response], { type: 'application/dicom' }); + cancelDownloads(); + throw err; + }); - const fileReader = new FileReader(); - - fileReader.onload = () => { - try { - seriesFolder.file(`${instance.sopInstanceUid}.dcm`, fileReader.result, { binary: true }); - } catch(err) { - onExportFailed(err.message); - return; - } - - numberOfFilesExported++; - - if (numberOfFilesExported === numberOfFilesToExport) { - const zipContent = zip.generate({ type: 'blob' }); - saveAs(zipContent, 'studies.zip'); - } - - OHIF.studylist.progressDialog.update(numberOfFilesExported); - }; - - fileReader.readAsArrayBuffer(blobFile); - }; - - // Failed to download the dicom file - xhr.onerror = () => onExportFailed(`File not downloaded: ${instance.wadouri}`); - - xhr.send(); + promises.push(promise); }); }); }); + + return { + cancel: cancelDownloads, + promise: Promise.all(promises).then(() => { + const zipContent = zip.generate({ type: 'blob' }); + saveAs(zipContent, 'studies.zip'); + }) + } }; + +const downloadDicomFile = instance => { + let xhr, + promiseReject; + + const promise = new Promise((resolve, reject) => { + promiseReject = reject; + + xhr = new XMLHttpRequest(); + xhr.open('GET', instance.wadouri, true); + xhr.responseType = 'blob'; + + xhr.onload = () => { + if (xhr.readyState === 4 && xhr.status !== 200) { + return reject(new Error(`File not downloaded: ${instance.wadouri}`)); + } + + resolve(xhr.response) + }; + + xhr.onerror = () => { + reject(new Error(`File not downloaded: ${instance.wadouri}`)); + }; + + xhr.send(); + }); + + return { + promise: promise, + cancel: () => { + xhr.abort(); + promiseReject(new ExportStudyDownloadCanceledError('Download canceled')); + } + } +}; + + +const zipInstance = (study, series, instance, zip, data) => { + const fileReader = new FileReader(), + blobFile = new Blob([data], { type: 'application/dicom' }), + zipFolder = zip.folder(study.studyInstanceUid).folder(series.seriesInstanceUid); + + const promise = new Promise((resolve, reject) => { + fileReader.onload = () => { + try { + zipFolder.file(`${instance.sopInstanceUid}.dcm`, fileReader.result, { binary: true }); + resolve() + } catch(err) { + reject(err); + } + }; + }); + + fileReader.readAsArrayBuffer(blobFile); + + return promise; +}; + + +const ExportStudyDownloadCanceledError = (message) => { + this.name = 'ExportStudyDownloadCanceledError'; + this.message = message || 'Download canceled'; + this.stack = (new Error()).stack; +} +ExportStudyDownloadCanceledError.prototype = Object.create(Error.prototype); +ExportStudyDownloadCanceledError.prototype.constructor = ExportStudyDownloadCanceledError; diff --git a/Packages/ohif-study-list/client/lib/queryStudies.js b/Packages/ohif-study-list/client/lib/queryStudies.js index 32448b34d..184f8554d 100644 --- a/Packages/ohif-study-list/client/lib/queryStudies.js +++ b/Packages/ohif-study-list/client/lib/queryStudies.js @@ -3,31 +3,55 @@ import { OHIF } from 'meteor/ohif:core'; /** * Queries requested studies to get their metadata from PACS * @param studiesToQuery Studies to query - * @param doneCallback Callback to call when the query is done */ -queryStudies = function(studiesToQuery, doneCallback) { - if (studiesToQuery.length < 1) { - return; - } +queryStudies = function(studiesToQuery, options) { + const studiesQueried = [], + numberOfStudiesToQuery = studiesToQuery.length, + notify = (options || {}).notify || function() { /* noop */ } - var studiesQueried = []; - var numberOfStudiesToQuery = studiesToQuery.length; + return new Promise((resolve, reject) => { + if (studiesToQuery.length < 1) { + return reject(); + } - OHIF.studylist.progressDialog.show("Querying Studies...", numberOfStudiesToQuery); + studiesToQuery.forEach(function(studyToQuery) { + getStudyMetadata(studyToQuery.studyInstanceUid, function(study) { + studiesQueried.push(study); - studiesToQuery.forEach(function(studyToQuery) { - getStudyMetadata(studyToQuery.studyInstanceUid, function(study) { - studiesQueried.push(study); + notify({ + total: numberOfStudiesToQuery, + processed: studiesQueried.length + }); - var numberOfStudiesQueried = studiesQueried.length; - - OHIF.studylist.progressDialog.update(numberOfStudiesQueried); - - if (numberOfStudiesQueried === numberOfStudiesToQuery) { - doneCallback(studiesQueried); - } + if (studiesQueried.length === numberOfStudiesToQuery) { + resolve(studiesQueried); + } + }); }); }); +} + +queryStudiesWithProgress = function(studiesToQuery) { + return OHIF.ui.showFormDialog('dialogProgress', { + title: 'Querying Studies...', + message: `Queried: 0 / ${studiesToQuery.length}`, + total: studiesToQuery.length, + task: { + run: (dialog) => { + queryStudies(studiesToQuery, { + notify: stats => { + dialog.update(stats.processed); + dialog.setMessage(`Queried: ${stats.processed} / ${stats.total}`); + } + }) + .then(studiesQueried => { + dialog.done(studiesQueried); + }, () => { + dialog.cancel(); + }); + } + } + }); }; /** diff --git a/Packages/ohif-viewerbase/client/lib/helpers/formatNumberPrecision.js b/Packages/ohif-viewerbase/client/lib/helpers/formatNumberPrecision.js index 069718c09..a2571b52e 100644 --- a/Packages/ohif-viewerbase/client/lib/helpers/formatNumberPrecision.js +++ b/Packages/ohif-viewerbase/client/lib/helpers/formatNumberPrecision.js @@ -2,7 +2,7 @@ * A global Blaze UI helper to format a float value to a specified precision */ UI.registerHelper('formatNumberPrecision', function(context, precision) { - if (context) { + if (context != null) { return parseFloat(context).toFixed(precision); } }); \ No newline at end of file