From a2124c03e96537c97cefd65ce44ef610bafb49fb Mon Sep 17 00:00:00 2001 From: Evren Ozkan Date: Wed, 30 Mar 2016 18:38:29 -0400 Subject: [PATCH] LT-66 Import DICOM Studies - Add a toolbar in the top of worklist - Add Import button in the worklist toolbar - Upload all files in the selected folder to import from client to server - Import uploaded files via DIMSE - Delete uploaded files from server when import is completed - Handle import status in client - Show/Update progress dialog in client while uploading and importing files - Show Import button only if import is supported for the default service type (DICOMWeb is currently not supported) --- .jshintrc | 3 +- Packages/dimseservice/server/DIMSE.js | 4 +- Packages/viewerbase/both/collections.js | 1 + Packages/viewerbase/client/collections.js | 2 + Packages/viewerbase/lib/importStudies.js | 107 +++++++++++++++ Packages/viewerbase/package.js | 10 ++ Packages/viewerbase/server/collections.js | 3 + .../server/methods/importStudies.js | 125 ++++++++++++++++++ .../worklistResult/worklistResult.html | 1 + .../worklistToolbar/worklistToolbar.html | 12 ++ .../worklistToolbar/worklistToolbar.js | 28 ++++ .../worklistToolbar/worklistToolbar.styl | 27 ++++ Packages/worklist/package.js | 4 + 13 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 Packages/viewerbase/both/collections.js create mode 100644 Packages/viewerbase/lib/importStudies.js create mode 100644 Packages/viewerbase/server/collections.js create mode 100644 Packages/viewerbase/server/methods/importStudies.js create mode 100644 Packages/worklist/client/components/worklistToolbar/worklistToolbar.html create mode 100644 Packages/worklist/client/components/worklistToolbar/worklistToolbar.js create mode 100644 Packages/worklist/client/components/worklistToolbar/worklistToolbar.styl diff --git a/.jshintrc b/.jshintrc index 2f87081ae..2f7a6233b 100644 --- a/.jshintrc +++ b/.jshintrc @@ -115,6 +115,7 @@ ViewerStudies: true, LesionManager: true, Timepoints: true, - Measurements: true + Measurements: true, + StudyImportStatus: true } } \ No newline at end of file diff --git a/Packages/dimseservice/server/DIMSE.js b/Packages/dimseservice/server/DIMSE.js index dff41ccdf..e09f9451f 100755 --- a/Packages/dimseservice/server/DIMSE.js +++ b/Packages/dimseservice/server/DIMSE.js @@ -201,10 +201,10 @@ DIMSE.retrieveInstances = function(studyInstanceUID, seriesInstanceUID, params, return future.wait(); }; -DIMSE.storeInstances = function(fileList) { +DIMSE.storeInstances = function(fileList, callback) { var handle = conn.storeInstances(fileList); handle.on('file', function(err, file) { - console.log(err, file); + callback(err, file); }); }; diff --git a/Packages/viewerbase/both/collections.js b/Packages/viewerbase/both/collections.js new file mode 100644 index 000000000..02753c1b4 --- /dev/null +++ b/Packages/viewerbase/both/collections.js @@ -0,0 +1 @@ +StudyImportStatus = new Meteor.Collection('studyImportStatus'); \ No newline at end of file diff --git a/Packages/viewerbase/client/collections.js b/Packages/viewerbase/client/collections.js index 199657f79..bd31f8b7b 100644 --- a/Packages/viewerbase/client/collections.js +++ b/Packages/viewerbase/client/collections.js @@ -2,3 +2,5 @@ ViewerStudies = new Meteor.Collection(null); ViewerStudies._debugName = 'ViewerStudies'; ClientId = Random.id(); + +Meteor.subscribe('studyImportStatus'); \ No newline at end of file diff --git a/Packages/viewerbase/lib/importStudies.js b/Packages/viewerbase/lib/importStudies.js new file mode 100644 index 000000000..297f669fe --- /dev/null +++ b/Packages/viewerbase/lib/importStudies.js @@ -0,0 +1,107 @@ +/** + * Imports selected studies from local into worklist + * @param filesToImport Files located in the client machine to import + */ +importStudies = function(filesToImport, importCallback) { + if (filesToImport.length < 1) { + return; + } + var fileUploadStatus = { numberOfFilesUploaded: 0, numberOfFilesFailed: 0 }; + + var numberOfFilesToUpload = filesToImport.length; + var studiesToImport = []; + + progressDialog.show("Uploading Files...", numberOfFilesToUpload); + + // Upload files to the server + filesToImport.forEach(function(fileToUpload) { + var xhr = new XMLHttpRequest(); + xhr.open('POST', "/uploadFilesToImport", true); + xhr.setRequestHeader("filename", fileToUpload.name); + + xhr.onload = function() { + // Failed to upload a file + if (xhr.readyState === 4 && xhr.status !== 200) { + updateFileUploadStatus(fileUploadStatus, false); + return; + } + + studiesToImport.push(xhr.responseText); + + updateFileUploadStatus(fileUploadStatus, true); + + var numberOfFilesProcessedToUpload = fileUploadStatus.numberOfFilesUploaded + fileUploadStatus.numberOfFilesFailed; + progressDialog.update(numberOfFilesProcessedToUpload); + + if (numberOfFilesToUpload === numberOfFilesProcessedToUpload) { + // The upload is completed, so import files + importStudiesInternal(studiesToImport, importCallback); + + if (fileUploadStatus.numberOfFilesFailed > 0) { + //TODO: Some files failed to upload, so let user know + console.log("Failed to upload " + studyImportStatus.numberOfStudiesFailed + " of " + numberOfStudiesToImport + " files"); + } + } + }; + + // Failed to upload a file + xhr.onerror = function() { + updateFileUploadStatus(fileUploadStatus, false); + }; + + xhr.send(fileToUpload); + }); +}; + +function updateFileUploadStatus(fileUploadStatus, isSuccess) { + if (!isSuccess) { + fileUploadStatus.numberOfFilesFailed++; + } else { + fileUploadStatus.numberOfFilesUploaded++; + } +} + +function importStudiesInternal(studiesToImport, importCallback) { + var numberOfStudiesToImport = studiesToImport.length; + + progressDialog.show("Importing Studies...", numberOfStudiesToImport); + + // Create/Insert a new study import status item + Meteor.call("createStudyImportStatus", function(err, studyImportStatusId) { + if (err) { + console.log(err); + return; + } + + // Handle when it is updated + StudyImportStatus.find(studyImportStatusId).observe({ + changed: function(studyImportStatus) { + if (!studyImportStatus) { + return; + } + + var numberOfStudiesProcessedToImport = studyImportStatus.numberOfStudiesImported + studyImportStatus.numberOfStudiesFailed; + + progressDialog.update(numberOfStudiesProcessedToImport); + + if (numberOfStudiesProcessedToImport == numberOfStudiesToImport) { + // The entire import operation is completed, so remove the study import status item + Meteor.call("removeStudyImportStatus", studyImportStatus._id); + + if (studyImportStatus.numberOfStudiesFailed > 0) { + //TODO: Some files failed to import, so let user know + console.log("Failed to import " + studyImportStatus.numberOfStudiesFailed + " of " + numberOfStudiesToImport + " files"); + } + + // Let the caller know that import operation is completed + if (importCallback) { + importCallback(); + } + } + } + }); + + // Import studies with study import status id to get callbacks + Meteor.call("importStudies", studiesToImport, studyImportStatusId); + }); +} \ No newline at end of file diff --git a/Packages/viewerbase/package.js b/Packages/viewerbase/package.js index 181cd5173..5119cc2ab 100644 --- a/Packages/viewerbase/package.js +++ b/Packages/viewerbase/package.js @@ -147,6 +147,7 @@ Package.onUse(function(api) { api.addFiles('lib/setFocusToActiveViewport.js', 'client'); api.addFiles('lib/updateAllViewports.js', 'client'); api.addFiles('lib/exportStudies.js', 'client'); + api.addFiles('lib/importStudies.js', 'client'); api.addFiles('lib/encodeQueryData.js', 'server'); //api.export('accountsConfig', 'client'); @@ -166,6 +167,7 @@ Package.onUse(function(api) { api.export('setFocusToActiveViewport', 'client'); api.export('updateAllViewports', 'client'); api.export('exportStudies', 'client'); + api.export('importStudies', 'client'); api.export('getActiveViewportElement', 'client'); api.export('encodeQueryData', 'server'); @@ -198,6 +200,7 @@ Package.onUse(function(api) { api.addFiles('server/lib/namespace.js', 'server'); api.addFiles('server/methods/getStudyMetadata.js', 'server'); api.addFiles('server/methods/worklistSearch.js', 'server'); + api.addFiles('server/methods/importStudies.js', 'server'); // DICOMWeb instance, study, and metadata retrieval api.addFiles('server/services/qido/instances.js', 'server'); @@ -215,5 +218,12 @@ Package.onUse(function(api) { api.addFiles('server/services/remote/retrieveMetadata.js', 'server'); api.export('Services', 'server'); + api.export('importStudies', 'server'); + api.export('importSupported', 'server'); + + // Collections + api.addFiles('both/collections.js', [ 'client', 'server' ]); + api.addFiles('server/collections.js', 'server'); + api.export('StudyImportStatus', [ 'client', 'server' ]); }); diff --git a/Packages/viewerbase/server/collections.js b/Packages/viewerbase/server/collections.js new file mode 100644 index 000000000..d367af3ec --- /dev/null +++ b/Packages/viewerbase/server/collections.js @@ -0,0 +1,3 @@ +Meteor.publish('studyImportStatus', function() { + return StudyImportStatus.find(); +}); \ No newline at end of file diff --git a/Packages/viewerbase/server/methods/importStudies.js b/Packages/viewerbase/server/methods/importStudies.js new file mode 100644 index 000000000..202282e0b --- /dev/null +++ b/Packages/viewerbase/server/methods/importStudies.js @@ -0,0 +1,125 @@ +var fs = Npm.require('fs'); +var fiber = Npm.require('fibers'); + +WebApp.connectHandlers.use('/uploadFilesToImport', function(req, res) { + if (!req.headers.filename) { + // Response: BAD REQUEST (400) + res.statusCode = 400; + res.end(); + } + + // Store files in temp location (they will be deleted when their import operations are completed) + var dicomDir = '/tmp/dicomDir'; + createFolderIfNotExist(dicomDir); + + var fullFileName = dicomDir + '/' + req.headers.filename; + var file = fs.createWriteStream(fullFileName); + + file.on('error',function(error){ + console.log(error); + // Response: INTERNAL SERVER ERROR (500) + res.statusCode = 400; + res.end(); + }); + file.on('finish',function(){ + // Response: SUCCESS (200) + res.writeHead(200, {'Content-Type': 'text/plain'}); + res.end(fullFileName); + }); + + // Pipe the request to the file + req.pipe(file); +}); + +Meteor.methods({ + /** + * Returns true if import is supported for default service type + * @returns {boolean} + */ + importSupported: function() { + if (Meteor.settings.dimse && Meteor.settings.defaultServiceType === 'dimse') { + return true; + } + //TODO: Support importing studies into dicomWeb + return false; + }, + /** + * Imports studies from local into worklist + * @param studiesToImport Studies to import + * @param studyImportStatusId Study import status collection id to track import status + */ + importStudies: function(studiesToImport, studyImportStatusId) { + if (Meteor.settings.dicomWeb && Meteor.settings.defaultServiceType === 'dicomWeb') { + //TODO: Support importing studies into dicomWeb + console.log('Importing studies into dicomWeb is currently not supported.'); + } else if (Meteor.settings.dimse && Meteor.settings.defaultServiceType === 'dimse') { + importStudiesDIMSE(studiesToImport, studyImportStatusId); + } else { + throw 'No properly configured server was available over DICOMWeb or DIMSE.'; + } + }, + /** + * Create a new study import status item and insert it into the collection to track import status + * @returns {studyImportStatusId: string} + */ + createStudyImportStatus: function() { + var studyImportStatus = { numberOfStudiesImported: 0, numberOfStudiesFailed: 0 }; + return StudyImportStatus.insert(studyImportStatus); + }, + /** + * Remove the study import status item from the collection + * @param id Collection id of the study import status in the collection + */ + removeStudyImportStatus: function(id) { + StudyImportStatus.remove(id); + } +}); + +function importStudiesDIMSE(studiesToImport, studyImportStatusId) { + // Perform C-Store to import studies and handle the callbacks to update import status + DIMSE.storeInstances(studiesToImport, function(err, file) { + // Use fiber to be able to modify meteor collection in callback + fiber(function() { + // Update the import status + if (err) { + StudyImportStatus.update({_id: studyImportStatusId}, {$inc: {'numberOfStudiesFailed': 1}}); + console.log("Failed to import study via DIMSE: ", file, err); + } else { + StudyImportStatus.update({_id: studyImportStatusId}, {$inc: {'numberOfStudiesImported': 1}}); + console.log("Study successfully imported via DIMSE: ", file); + } + + // The import operation of this file is completed, so delete it if still exists + if (fileExists(file)) { + fs.unlink(file); + } + }).run(); + }); +} + +function createFolderIfNotExist(folder) { + var folderParts = folder.split('/'); + var folderPart = folderParts[0]; + for (var i = 1; i < folderParts.length; i++) { + folderPart += '/' + folderParts[i]; + if (!folderExists(folderPart)) { + fs.mkdirSync(folderPart); + } + } +} + +function fileExists(folder) { + try { + return fs.statSync(folder).isFile(); + } catch (err) { + return false; + } +} + +function folderExists(folder) { + try { + return fs.statSync(folder).isDirectory(); + } catch (err) { + return false; + } +} \ No newline at end of file diff --git a/Packages/worklist/client/components/worklistResult/worklistResult.html b/Packages/worklist/client/components/worklistResult/worklistResult.html index 31ea706ef..7c72d2394 100644 --- a/Packages/worklist/client/components/worklistResult/worklistResult.html +++ b/Packages/worklist/client/components/worklistResult/worklistResult.html @@ -1,4 +1,5 @@