LT-366: StudyList :: Export workflow issues

This commit is contained in:
Leonardo Campos 2016-11-28 02:32:34 -02:00
parent 87d61298b4
commit 79b2907085
6 changed files with 250 additions and 125 deletions

View File

@ -1,13 +1,17 @@
<template name="dialogProgress"> <template name="dialogProgress">
{{#dialogForm (extend this {{#dialogForm (extend this
dialogClass='modal-sm modal-progress' dialogClass='modal-sm modal-progress'
title=(choose this.title 'Processing...') title=(choose this.title 'Processing...')
)}} )}}
<div class="status"> <div class="status">
<div class="progress-bar progress-bar-striped active" role="progressbar" style="width: {{progress}}%">{{formatNumberPrecision progress 0}}%</div> <div class="progress-bar-container">
<div class="progress-bar progress-bar-striped active" role="progressbar" style="width: {{progress}}%">
<div class="percentage">{{formatNumberPrecision progress 0}}%</div>
</div>
</div> </div>
<div class="message"> </div>
{{{message}}} <div class="message">
</div> {{{message}}}
{{/dialogForm}} </div>
{{/dialogForm}}
</template> </template>

View File

@ -3,20 +3,15 @@ import { Session } from 'meteor/session';
import { _ } from 'meteor/underscore'; import { _ } from 'meteor/underscore';
Template.dialogProgress.onCreated(() => { Template.dialogProgress.onCreated(() => {
console.log('dialogProgress :: onCreated'); const instance = Template.instance();
});
Template.dialogProgress.onCreated(() => {
Session.set('progressDialogState', { Session.set('progressDialogState', {
progress: 0, processed: 0,
message: 'Test Message' total: instance.data.total,
message: instance.data.message
}); });
}) })
Template.dialogProgress.onDestroyed(() => {
Session.set('progressDialogState', undefined);
})
Template.dialogProgress.onRendered(() => { Template.dialogProgress.onRendered(() => {
const instance = Template.instance(); const instance = Template.instance();
const task = instance.data.task; const task = instance.data.task;
@ -25,10 +20,10 @@ Template.dialogProgress.onRendered(() => {
const progressDialog = { const progressDialog = {
promise: instance.data.promise, promise: instance.data.promise,
done: () => { done: value => {
// Hide the modal, removing the backdrop // Hide the modal, removing the backdrop
instance.$('.modal').on('hidden.bs.modal', event => { instance.$('.modal').on('hidden.bs.modal', event => {
instance.data.promiseResolve(); instance.data.promiseResolve(value);
}).modal('hide'); }).modal('hide');
}, },
@ -39,9 +34,16 @@ Template.dialogProgress.onRendered(() => {
}).modal('hide'); }).modal('hide');
}, },
setProgress: _.throttle(progress => { update: _.throttle(processed => {
const state = Session.get('progressDialogState'); 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); Session.set('progressDialogState', state);
}, 100), }, 100),
@ -54,21 +56,23 @@ Template.dialogProgress.onRendered(() => {
}, 100) }, 100)
} }
task.start(progressDialog); task.run(progressDialog);
}); });
Template.dialogProgress.helpers({ Template.dialogProgress.helpers({
progress() { progress() {
const state = Session.get('progressDialogState'); 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() { message() {
const state = Session.get('progressDialogState'); const state = Session.get('progressDialogState');
if (!state) { if (!state) {
return; return;
} }

View File

@ -1,7 +1,14 @@
.modal-progress .modal-progress
.status .status
.progress-bar .progress-bar-container
float: none; border: solid 1px #28405E
overflow: auto;
margin: 0 0 10px
border-radius: 3px
.percentage
margin: 0 5px
.btn-confirm .btn-confirm
display: none display: none

View File

@ -1,7 +1,28 @@
import { JSZip } from 'meteor/silentcicero:jszip'; import { JSZip } from 'meteor/silentcicero:jszip';
import { OHIF } from 'meteor/ohif:core'; 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 * Exports requested studies
@ -12,105 +33,170 @@ OHIF.studylist.exportStudies = studiesToExport => {
return; return;
} }
queryStudies(studiesToExport, exportQueriedStudies); queryStudiesWithProgress(studiesToExport)
.then(exportQueriedStudiesWithProgress);
}; };
const exportQueriedStudies = studiesToExport => { const exportQueriedStudiesWithProgress = studiesToExport => {
let numberOfFilesToExport = 0; const exportFilesCount = getNumberOfFilesToExport(studiesToExport);
studiesToExport.forEach(study => { let exportHandler;
numberOfFilesToExport += getNumberOfFilesInStudy(study);
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 exportQueriedStudies = (studiesToExport, options) => {
const zip = new JSZip(); const zip = new JSZip(),
promises = [],
pendingDownloads = [],
exportFilesCount = getNumberOfFilesToExport(studiesToExport),
notify = (options || {}).notify || function() { /* noop */ };
exportFailed = false; const cancelDownloads = () => {
let numberOfFilesExported = 0; while(pendingDownloads.length) {
const download = pendingDownloads.pop();
const onExportFailed = err => { download.cancel();
exportFailed = true; }
OHIF.studylist.progressDialog.close();
//TODO: Export failed and dialog closed, so let user know
OHIF.log.error('Failed to export studies!', err);
}; };
let totalBytes = 0;
studiesToExport.forEach(study => { studiesToExport.forEach(study => {
sortStudy(study);
const studyFolder = zip.folder(study.studyInstanceUid);
study.seriesList.forEach(series => { study.seriesList.forEach(series => {
const seriesFolder = studyFolder.folder(series.seriesInstanceUid);
series.instances.forEach(instance => { series.instances.forEach(instance => {
if (!instance.wadouri) { if (!instance.wadouri) {
return; return;
} }
// If failed to download a dicom file, skip others const download = downloadDicomFile(instance);
if (exportFailed) { pendingDownloads.push(download);
return;
}
// Download and Zip the dicom file const promise = download.promise
const xhr = new XMLHttpRequest(); .then(data => {
xhr.open('GET', instance.wadouri, true); const downloadIndex = pendingDownloads.indexOf(download);
xhr.responseType = 'blob';
// Downloaded the dicom file completely totalBytes += data && data.size ? data.size : 0;
xhr.onload = () => {
// If failed to download a dicom file, skip others if(downloadIndex > -1) {
if (exportFailed) { pendingDownloads.splice(downloadIndex, 1);
return;
} }
// Failed to export a file notify({
if (xhr.readyState === 4 && xhr.status !== 200) { total: exportFilesCount,
onExportFailed(`File not downloaded: ${instance.wadouri}`); processed: exportFilesCount - pendingDownloads.length,
return; 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(); promises.push(promise);
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();
}); });
}); });
}); });
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;

View File

@ -3,31 +3,55 @@ import { OHIF } from 'meteor/ohif:core';
/** /**
* Queries requested studies to get their metadata from PACS * Queries requested studies to get their metadata from PACS
* @param studiesToQuery Studies to query * @param studiesToQuery Studies to query
* @param doneCallback Callback to call when the query is done
*/ */
queryStudies = function(studiesToQuery, doneCallback) { queryStudies = function(studiesToQuery, options) {
if (studiesToQuery.length < 1) { const studiesQueried = [],
return; numberOfStudiesToQuery = studiesToQuery.length,
} notify = (options || {}).notify || function() { /* noop */ }
var studiesQueried = []; return new Promise((resolve, reject) => {
var numberOfStudiesToQuery = studiesToQuery.length; 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) { notify({
getStudyMetadata(studyToQuery.studyInstanceUid, function(study) { total: numberOfStudiesToQuery,
studiesQueried.push(study); processed: studiesQueried.length
});
var numberOfStudiesQueried = studiesQueried.length; if (studiesQueried.length === numberOfStudiesToQuery) {
resolve(studiesQueried);
OHIF.studylist.progressDialog.update(numberOfStudiesQueried); }
});
if (numberOfStudiesQueried === numberOfStudiesToQuery) {
doneCallback(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();
});
}
}
});
}; };
/** /**

View File

@ -2,7 +2,7 @@
* A global Blaze UI helper to format a float value to a specified precision * A global Blaze UI helper to format a float value to a specified precision
*/ */
UI.registerHelper('formatNumberPrecision', function(context, precision) { UI.registerHelper('formatNumberPrecision', function(context, precision) {
if (context) { if (context != null) {
return parseFloat(context).toFixed(precision); return parseFloat(context).toFixed(precision);
} }
}); });