diff --git a/extensions/ohif-gradienthealth-extension/src/DicomJSONDataSource/index.js b/extensions/ohif-gradienthealth-extension/src/DicomJSONDataSource/index.js index e780fc6..2a3110e 100644 --- a/extensions/ohif-gradienthealth-extension/src/DicomJSONDataSource/index.js +++ b/extensions/ohif-gradienthealth-extension/src/DicomJSONDataSource/index.js @@ -10,6 +10,11 @@ import { import getImageId from '../DicomWebDataSource/utils/getImageId'; import _ from 'lodash'; +import { + getCurrentLiveVersion, + removeObjectsVersions, +} from '../utils/cloudObjectVersionActions'; +import parseUrlToBucketAndFileName from '../utils/parseUrlToBucketAndFileName'; const metadataProvider = classes.MetadataProvider; const { datasetToBlob } = dcmjs.data; @@ -254,6 +259,40 @@ const mapSegSeriesFromDataSet = (dataSet) => { }; }; +const removeCurrentSegVersionsIfNecessary = async ( + bucket, + currentDicomVersionDetails, + currentJsonVersionDetails, + headers +) => { + const objectsVersionsAndFileNames = []; + + // Remove the current version of metadata json file since we only need a single version. + if (currentJsonVersionDetails) { + objectsVersionsAndFileNames.push(currentJsonVersionDetails); + } + + if ( + currentDicomVersionDetails && + Date.now() - Date.parse(currentDicomVersionDetails.version.updated) <= + 5 * 60 * 1000 // 5 minutes + ) { + // Remove the current version of segmentation dicom files if the last modification is within 5 minutes + objectsVersionsAndFileNames.push(currentDicomVersionDetails); + } + + return removeObjectsVersions( + bucket, + objectsVersionsAndFileNames, + headers + ).catch((error) => { + throw new Error( + error.message || + 'Failed to remove previous version of dicom file and/or json file' + ); + }); +}; + const storeDicomSeg = async (naturalizedReport, headers, displaySetService) => { const { StudyInstanceUID, @@ -271,22 +310,34 @@ const storeDicomSeg = async (naturalizedReport, headers, displaySetService) => { const segPrefix = params.get('seg-prefix') || prefix const filteredDescription = SeriesDescription.replace(/[/]/g, ''); - let fileName = `${segPrefix}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/instances/${SOPInstanceUID}/${encodeURIComponent( + let dicomFileName = `${segPrefix}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/instances/${SOPInstanceUID}/${encodeURIComponent( filteredDescription )}.dcm`; + const jsonFileName = `${segPrefix}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/metadata`; const segDisplaySet = displaySetService.getDisplaySetsBy( (ds) => ds.SeriesInstanceUID === SeriesInstanceUID && ds.instance.SOPInstanceUID === SOPInstanceUID )[0]; + + let currentDicomVersionDetails, currentJsonVersionDetails; if (segDisplaySet) { const url = segDisplaySet.instance.url; - segBucket = url.split('https://storage.googleapis.com/')[1].split('/')[0]; - fileName = url.split(`https://storage.googleapis.com/${segBucket}/`)[1]; + ({ bucket: segBucket, fileName: dicomFileName } = + parseUrlToBucketAndFileName(url)); + + currentDicomVersionDetails = { + fileName: dicomFileName, + version: await getCurrentLiveVersion(segBucket, dicomFileName, headers), + }; + currentJsonVersionDetails = { + fileName: jsonFileName, + version: await getCurrentLiveVersion(segBucket, jsonFileName, headers), + }; } - const segUploadUri = `https://storage.googleapis.com/upload/storage/v1/b/${segBucket}/o?uploadType=media&name=${fileName}&contentEncoding=gzip`; + const segUploadUri = `https://storage.googleapis.com/upload/storage/v1/b/${segBucket}/o?uploadType=media&name=${dicomFileName}&contentEncoding=gzip`; const blob = datasetToBlob(naturalizedReport); const compressedFile = pako.gzip(await blob.arrayBuffer()); @@ -313,7 +364,7 @@ const storeDicomSeg = async (naturalizedReport, headers, displaySetService) => { const compressedFile = pako.gzip(JSON.stringify(segSeries)); return fetch( - `https://storage.googleapis.com/upload/storage/v1/b/${segBucket}/o?uploadType=media&name=${segPrefix}/studies/${StudyInstanceUID}/series/${SeriesInstanceUID}/metadata&contentEncoding=gzip`, + `https://storage.googleapis.com/upload/storage/v1/b/${segBucket}/o?uploadType=media&name=${jsonFileName}&contentEncoding=gzip`, { method: 'POST', headers: { @@ -326,10 +377,15 @@ const storeDicomSeg = async (naturalizedReport, headers, displaySetService) => { .then((response) => response.json()) .then((data) => { if (data.error) { - throw new Error( - `${data.error.code}: ${data.error.message}` - ); + throw new Error(`${data.error.code}: ${data.error.message}`); } + + return removeCurrentSegVersionsIfNecessary( + segBucket, + currentDicomVersionDetails, + currentJsonVersionDetails, + headers + ); }) .catch((error) => { throw new Error(error.message || 'Failed to store DicomSeg metadata') diff --git a/extensions/ohif-gradienthealth-extension/src/index.tsx b/extensions/ohif-gradienthealth-extension/src/index.tsx index ed01167..e8e646c 100644 --- a/extensions/ohif-gradienthealth-extension/src/index.tsx +++ b/extensions/ohif-gradienthealth-extension/src/index.tsx @@ -8,7 +8,13 @@ import GoogleSheetsService from './services/GoogleSheetsService'; import CropDisplayAreaService from './services/CropDisplayAreaService'; import CacheAPIService from './services/CacheAPIService'; import addSegmentationLabelModifier from './utils/addSegmentationLabelModifier'; - +import { + getCurrentLiveVersion, + getObjectVersions, + restoreObjectVersion, +} from './utils/cloudObjectVersionActions'; +import parseUrlToBucketAndFileName from './utils/parseUrlToBucketAndFileName'; +import confirmSEGVersionRestore from './utils/confirmSEGVersionRestore'; // import { CornerstoneEventTarget } from '@cornerstonejs/core/CornerstoneEventTarget'; // import { Events } from '@cornerstonejs/core/Events'; @@ -41,6 +47,20 @@ const gradientHealthExtension = { } ); }, + getUtilityModule() { + return [ + { + name: 'version', + exports: { + getObjectVersions, + getCurrentLiveVersion, + restoreObjectVersion, + parseUrlToBucketAndFileName, + confirmSEGVersionRestore, + }, + }, + ]; + }, }; export default gradientHealthExtension; diff --git a/extensions/ohif-gradienthealth-extension/src/services/CacheAPIService/CacheAPIService.ts b/extensions/ohif-gradienthealth-extension/src/services/CacheAPIService/CacheAPIService.ts index a1fc237..13ecda6 100644 --- a/extensions/ohif-gradienthealth-extension/src/services/CacheAPIService/CacheAPIService.ts +++ b/extensions/ohif-gradienthealth-extension/src/services/CacheAPIService/CacheAPIService.ts @@ -9,6 +9,8 @@ import { imageLoader, imageLoadPoolManager, } from '@cornerstonejs/core'; +import { getCurrentLiveVersion } from '../../utils/cloudObjectVersionActions'; +import parseUrlToBucketAndFileName from '../../utils/parseUrlToBucketAndFileName'; const LOCAL_EVENTS = { IMAGE_CACHE_PREFETCHED: 'event::gradienthealth::image_cache_prefetched', @@ -89,6 +91,10 @@ export default class CacheAPIService { } } + public getImageIdToFileUriMap() { + return new Map(this.imageIdToFileUriMap); + } + public async setViewedStudy(StudyInstanceUID) { await this.dataSource.retrieve.series.metadata({ StudyInstanceUID }); const study = DicomMetadataStore.getStudy(StudyInstanceUID); @@ -202,9 +208,28 @@ export default class CacheAPIService { const study = DicomMetadataStore.getStudy(studyInstanceUID); const headers = userAuthenticationService.getAuthorizationHeader(); - const promises = study.series.map((serie) => { - const { SOPClassUID, SeriesInstanceUID, url } = serie.instances[0]; + const promises = study.series.map(async (serie) => { + const { SOPClassUID, SeriesInstanceUID } = serie.instances[0]; + let { url } = serie.instances[0]; + if (segSOPClassUIDs.includes(SOPClassUID)) { + const urlObject = new URL(url); + if (!urlObject.searchParams.get('generation')) { + // Adding live generation params to the url if not present. + const { bucket, fileName } = parseUrlToBucketAndFileName(url); + const liveVersion = await getCurrentLiveVersion( + bucket, + fileName, + headers + ); + liveVersion && + urlObject.searchParams.set( + 'generation', + liveVersion.generation as string + ); + url = urlObject.toString(); + } + const { scheme, url: parsedUrl } = wadouri.parseImageId(url); if (scheme === 'dicomzip') { return wadouri.loadZipRequest(parsedUrl, url); @@ -222,6 +247,7 @@ export default class CacheAPIService { .then((buffer) => wadouri.fileManager.add(new Blob([buffer]))) .then((fileUri) => { this.imageIdToFileUriMap.set(url, fileUri); + displaySet.instances[0].url = displaySet.instance.url = url; displaySet.instance.imageId = fileUri; displaySet.instance.getImageId = () => fileUri; }); @@ -231,6 +257,29 @@ export default class CacheAPIService { await Promise.all(promises); } + public async cacheFiles(fileUrls, forceCache = false) { + const { userAuthenticationService } = this.servicesManager.services; + const headers = userAuthenticationService.getAuthorizationHeader(); + + const promises = fileUrls.map((url) => { + const { url: parsedUrl } = wadouri.parseImageId(url); + + if (!forceCache && this.imageIdToFileUriMap.get(url)) { + return; + } + + return fetch(parsedUrl, { headers }) + .then((response) => response.arrayBuffer()) + .then((buffer) => wadouri.fileManager.add(new Blob([buffer]))) + .then((fileUri) => { + this.imageIdToFileUriMap.set(url, fileUri); + }) + .catch((error) => console.warn(error)); + }); + + await Promise.all(promises); + } + public updateCachedFile(blob, displaySet) { const { url, imageId } = displaySet.instances[0]; const fileUri = wadouri.fileManager.add(blob); @@ -288,5 +337,7 @@ export default class CacheAPIService { 'CORNERSTONE_CACHE_QUOTA_EXCEEDED_ERROR', this.handleQuotaExceededWriteError ); + + this.imageIdToFileUriMap = new Map(); } } diff --git a/extensions/ohif-gradienthealth-extension/src/utils/cloudObjectVersionActions.ts b/extensions/ohif-gradienthealth-extension/src/utils/cloudObjectVersionActions.ts new file mode 100644 index 0000000..7efd746 --- /dev/null +++ b/extensions/ohif-gradienthealth-extension/src/utils/cloudObjectVersionActions.ts @@ -0,0 +1,79 @@ +const getObjectVersions = async ( + bucket: string, + fileName: string, + headers: Record +): Promise[]> => { + const data = await fetch( + `https://storage.googleapis.com/storage/v1/b/${bucket}/o?versions=true&prefix=${fileName}`, + { + method: 'GET', + headers: headers, + } + ).then((response) => response.json()); + + return data.items.filter((item) => item.name === fileName); +}; + +const getCurrentLiveVersion = async ( + bucket: string, + fileName: string, + headers: Record +): Promise | undefined> => { + const segDicomFileVersions = await getObjectVersions( + bucket, + fileName, + headers + ); + + return segDicomFileVersions.find((version) => !version.timeDeleted); +}; + +const removeObjectsVersions = async ( + bucket: string, + objectsVersionsAndFileNames: { + fileName: string; + version: Record; + }[], + headers: Record +): Promise => { + const promises = objectsVersionsAndFileNames.map(({ fileName, version }) => { + return fetch( + `https://storage.googleapis.com/${bucket}/${fileName}?generation=${version.generation}`, + { + method: 'DELETE', + headers: { ...headers, 'Content-Type': 'application/json' }, + } + ).catch((error) => { + throw new Error( + error.message || 'An error occured when deleting the version' + ); + }); + }); + + await Promise.all(promises); +}; + +const restoreObjectVersion = async ( + bucket: string, + fileName: string, + generation: number, + headers: Record +): Promise => { + const encodedFileName = encodeURIComponent(fileName); + const url = `https://storage.googleapis.com/storage/v1/b/${bucket}/o/${encodedFileName}/rewriteTo/b/${bucket}/o/${encodedFileName}?sourceGeneration=${generation}`; + await fetch(url, { + method: 'POST', + headers: { ...headers, 'Content-Length': '0' }, + }).catch((error) => { + throw new Error( + error.message || 'An error occured when restoring the version' + ); + }); +}; + +export { + getObjectVersions, + getCurrentLiveVersion, + removeObjectsVersions, + restoreObjectVersion, +}; diff --git a/extensions/ohif-gradienthealth-extension/src/utils/confirmSEGVersionRestore.ts b/extensions/ohif-gradienthealth-extension/src/utils/confirmSEGVersionRestore.ts new file mode 100644 index 0000000..20c619c --- /dev/null +++ b/extensions/ohif-gradienthealth-extension/src/utils/confirmSEGVersionRestore.ts @@ -0,0 +1,39 @@ +import { ButtonEnums } from '@ohif/ui'; + +const RESPONSE = { + CANCEL: 0, + CONFIRM: 1, +}; + +export default function (viewportId, servicesManager) { + const { uiViewportDialogService } = servicesManager.services; + uiViewportDialogService.hide(); + + return new Promise(function (resolve, reject) { + const message = 'Do you want to rollback to this Version?'; + const actions = [ + { + type: ButtonEnums.type.secondary, + text: 'No', + value: RESPONSE.CANCEL, + }, + { + type: ButtonEnums.type.primary, + text: 'Yes', + value: RESPONSE.CONFIRM, + }, + ]; + const onSubmit = (result) => { + uiViewportDialogService.hide(); + resolve(result); + }; + + uiViewportDialogService.show({ + viewportId, + type: 'info', + message, + actions, + onSubmit, + }); + }); +} diff --git a/extensions/ohif-gradienthealth-extension/src/utils/parseUrlToBucketAndFileName.ts b/extensions/ohif-gradienthealth-extension/src/utils/parseUrlToBucketAndFileName.ts new file mode 100644 index 0000000..10e0fab --- /dev/null +++ b/extensions/ohif-gradienthealth-extension/src/utils/parseUrlToBucketAndFileName.ts @@ -0,0 +1,11 @@ +export default function (url: string): { bucket: string; fileName: string } { + const urlObject = new URL(url); + urlObject.searchParams.delete('generation'); + url = urlObject.toString(); + + const domain = 'https://storage.googleapis.com'; + const bucket = url.split(`${domain}/`)[1].split('/')[0]; + const fileName = url.split(`${domain}/${bucket}/`)[1]; + + return { bucket, fileName }; +}