From c4ff2c2f09546ce8b72eab9c5e7beed611e3cab0 Mon Sep 17 00:00:00 2001 From: Igor Octaviano Date: Fri, 24 Nov 2023 10:46:54 -0300 Subject: [PATCH] feat: Merge Data Source (#3788) Add the ability to merge two different series queries to generate a complete study query result. Provides basic support for other types of merges, but those aren't yet added as full features. --- babel.config.js | 1 + extensions/default/babel.config.js | 1 + extensions/default/jest.config.js | 17 ++ .../ContextMenuItemsBuilder.test.js | 18 +- .../default/src/DicomWebDataSource/index.js | 12 +- .../retrieveStudyMetadata.js | 11 +- .../default/src/MergeDataSource/index.test.js | 203 ++++++++++++++ .../default/src/MergeDataSource/index.ts | 252 ++++++++++++++++++ .../default/src/MergeDataSource/types.ts | 44 +++ .../default/src/getDataSourcesModule.js | 6 + .../core/src/extensions/ExtensionManager.ts | 3 +- .../DicomMetadataStore/DicomMetadataStore.ts | 12 + .../extensions/modules/data-source.md | 44 ++- 13 files changed, 604 insertions(+), 20 deletions(-) create mode 100644 extensions/default/babel.config.js create mode 100644 extensions/default/jest.config.js create mode 100644 extensions/default/src/MergeDataSource/index.test.js create mode 100644 extensions/default/src/MergeDataSource/index.ts create mode 100644 extensions/default/src/MergeDataSource/types.ts diff --git a/babel.config.js b/babel.config.js index b55cbfdfa0e..7c669be359b 100644 --- a/babel.config.js +++ b/babel.config.js @@ -45,6 +45,7 @@ module.exports = { '@babel/plugin-proposal-object-rest-spread', '@babel/plugin-syntax-dynamic-import', '@babel/plugin-transform-regenerator', + '@babel/transform-destructuring', '@babel/plugin-transform-runtime', '@babel/plugin-transform-typescript', ], diff --git a/extensions/default/babel.config.js b/extensions/default/babel.config.js new file mode 100644 index 00000000000..325ca2a8ee7 --- /dev/null +++ b/extensions/default/babel.config.js @@ -0,0 +1 @@ +module.exports = require('../../babel.config.js'); diff --git a/extensions/default/jest.config.js b/extensions/default/jest.config.js new file mode 100644 index 00000000000..ba90c0c4724 --- /dev/null +++ b/extensions/default/jest.config.js @@ -0,0 +1,17 @@ +const base = require('../../jest.config.base.js'); +const pkg = require('./package'); + +module.exports = { + ...base, + name: pkg.name, + displayName: pkg.name, + moduleNameMapper: { + ...base.moduleNameMapper, + '@ohif/(.*)': '/../../platform/$1/src', + }, + // rootDir: "../.." + // testMatch: [ + // //`/platform/${pack.name}/**/*.spec.js` + // "/platform/app/**/*.test.js" + // ] +}; diff --git a/extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.test.js b/extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.test.js index 00ee469dd58..2231f750341 100644 --- a/extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.test.js +++ b/extensions/default/src/CustomizableContextMenu/ContextMenuItemsBuilder.test.js @@ -1,14 +1,14 @@ -import ContextMenuItemsBuilder from './ContextMenuItemsBuilder'; +import * as ContextMenuItemsBuilder from './ContextMenuItemsBuilder'; const menus = [ { id: 'one', - selector: ({ value }) => value === 'one', + selector: ({ value } = {}) => value === 'one', items: [], }, { id: 'two', - selector: ({ value }) => value === 'two', + selector: ({ value } = {}) => value === 'two', items: [], }, { @@ -17,13 +17,13 @@ const menus = [ }, ]; -const menuBuilder = new ContextMenuItemsBuilder(); - describe('ContextMenuItemsBuilder', () => { test('findMenuDefault', () => { - expect(menuBuilder.findMenuDefault(menus, {})).toBe(menus[2]); - expect(menuBuilder.findMenuDefault(menus, { value: 'two' })).toBe(menus[1]); - expect(menuBuilder.findMenuDefault([], {})).toBeUndefined(); - expect(menuBuilder.findMenuDefault(undefined, undefined)).toBeNull(); + expect(ContextMenuItemsBuilder.findMenuDefault(menus, {})).toBe(menus[2]); + expect( + ContextMenuItemsBuilder.findMenuDefault(menus, { selectorProps: { value: 'two' } }) + ).toBe(menus[1]); + expect(ContextMenuItemsBuilder.findMenuDefault([], {})).toBeUndefined(); + expect(ContextMenuItemsBuilder.findMenuDefault(undefined, undefined)).toBeNull(); }); }); diff --git a/extensions/default/src/DicomWebDataSource/index.js b/extensions/default/src/DicomWebDataSource/index.js index 679e552c1b2..2d26a90b866 100644 --- a/extensions/default/src/DicomWebDataSource/index.js +++ b/extensions/default/src/DicomWebDataSource/index.js @@ -140,7 +140,7 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) { instances: { search: (studyInstanceUid, queryParameters) => { qidoDicomWebClient.headers = getAuthrorizationHeader(); - qidoSearch.call(undefined, qidoDicomWebClient, studyInstanceUid, null, queryParameters); + return qidoSearch.call(undefined, qidoDicomWebClient, studyInstanceUid, null, queryParameters); }, }, }, @@ -262,7 +262,8 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) { enableStudyLazyLoad, filters, sortCriteria, - sortFunction + sortFunction, + dicomWebConfig ); // first naturalize the data @@ -314,6 +315,8 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) { Object.keys(instancesPerSeries).forEach(seriesInstanceUID => DicomMetadataStore.addInstances(instancesPerSeries[seriesInstanceUID], madeInClient) ); + + return seriesSummaryMetadata; }, _retrieveSeriesMetadataAsync: async ( @@ -333,7 +336,8 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) { enableStudyLazyLoad, filters, sortCriteria, - sortFunction + sortFunction, + dicomWebConfig ); /** @@ -444,6 +448,8 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) { ); await Promise.all(seriesDeliveredPromises); setSuccessFlag(); + + return seriesSummaryMetadata; }, deleteStudyMetadataPromise, getImageIdsForDisplaySet(displaySet) { diff --git a/extensions/default/src/DicomWebDataSource/retrieveStudyMetadata.js b/extensions/default/src/DicomWebDataSource/retrieveStudyMetadata.js index 7ee73289fdf..f05115333a5 100644 --- a/extensions/default/src/DicomWebDataSource/retrieveStudyMetadata.js +++ b/extensions/default/src/DicomWebDataSource/retrieveStudyMetadata.js @@ -25,7 +25,8 @@ export function retrieveStudyMetadata( enableStudyLazyLoad, filters, sortCriteria, - sortFunction + sortFunction, + dicomWebConfig = {} ) { // @TODO: Whenever a study metadata request has failed, its related promise will be rejected once and for all // and further requests for that metadata will always fail. On failure, we probably need to remove the @@ -38,9 +39,11 @@ export function retrieveStudyMetadata( throw new Error(`${moduleName}: Required 'StudyInstanceUID' parameter not provided.`); } + const promiseId = `${dicomWebConfig.name}:${StudyInstanceUID}`; + // Already waiting on result? Return cached promise - if (StudyMetaDataPromises.has(StudyInstanceUID)) { - return StudyMetaDataPromises.get(StudyInstanceUID); + if (StudyMetaDataPromises.has(promiseId)) { + return StudyMetaDataPromises.get(promiseId); } let promise; @@ -71,7 +74,7 @@ export function retrieveStudyMetadata( } // Store the promise in cache - StudyMetaDataPromises.set(StudyInstanceUID, promise); + StudyMetaDataPromises.set(promiseId, promise); return promise; } diff --git a/extensions/default/src/MergeDataSource/index.test.js b/extensions/default/src/MergeDataSource/index.test.js new file mode 100644 index 00000000000..2b915e32968 --- /dev/null +++ b/extensions/default/src/MergeDataSource/index.test.js @@ -0,0 +1,203 @@ +import { DicomMetadataStore, IWebApiDataSource } from '@ohif/core'; +import { + mergeMap, + callForAllDataSourcesAsync, + callForAllDataSources, + callForDefaultDataSource, + callByRetrieveAETitle, + createMergeDataSourceApi, +} from './index'; + +jest.mock('@ohif/core'); + +describe('MergeDataSource', () => { + let path, + sourceName, + mergeConfig, + extensionManager, + series1, + series2, + series3, + series4, + mergeKey, + tagFunc, + dataSourceAndSeriesMap, + dataSourceAndUIDsMap, + dataSourceAndDSMap, + pathSync; + + beforeAll(() => { + path = 'query.series.search'; + pathSync = 'getImageIdsForInstance'; + tagFunc = jest.fn((data, sourceName) => + data.map(item => ({ ...item, RetrieveAETitle: sourceName })) + ); + sourceName = 'dicomweb1'; + mergeKey = 'seriesInstanceUid'; + series1 = { [mergeKey]: '123' }; + series2 = { [mergeKey]: '234' }; + series3 = { [mergeKey]: '345' }; + series4 = { [mergeKey]: '456' }; + mergeConfig = { + seriesMerge: { + dataSourceNames: ['dicomweb1', 'dicomweb2'], + defaultDataSourceName: 'dicomweb1', + }, + }; + dataSourceAndSeriesMap = { + dataSource1: series1, + dataSource2: series2, + dataSource3: series3, + }; + dataSourceAndUIDsMap = { + dataSource1: ['123'], + dataSource2: ['234'], + dataSource3: ['345'], + }; + dataSourceAndDSMap = { + dataSource1: { + displaySet: { + StudyInstanceUID: '123', + SeriesInstanceUID: '123', + }, + }, + dataSource2: { + displaySet: { + StudyInstanceUID: '234', + SeriesInstanceUID: '234', + }, + }, + dataSource3: { + displaySet: { + StudyInstanceUID: '345', + SeriesInstanceUID: '345', + }, + }, + }; + extensionManager = { + dataSourceDefs: { + dataSource1: { + sourceName: 'dataSource1', + configuration: {}, + }, + dataSource2: { + sourceName: 'dataSource2', + configuration: {}, + }, + dataSource3: { + sourceName: 'dataSource3', + configuration: {}, + }, + }, + getDataSources: jest.fn(dataSourceName => [ + { + [path]: jest.fn().mockResolvedValue([dataSourceAndSeriesMap[dataSourceName]]), + }, + ]), + }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('callForAllDataSourcesAsync', () => { + it('should call the correct functions and return the merged data', async () => { + /** Arrange */ + extensionManager.getDataSources = jest.fn(dataSourceName => [ + { + [path]: jest.fn().mockResolvedValue([dataSourceAndSeriesMap[dataSourceName]]), + }, + ]); + + /** Act */ + const data = await callForAllDataSourcesAsync({ + mergeMap, + path, + args: [], + extensionManager, + dataSourceNames: ['dataSource1', 'dataSource2'], + }); + + /** Assert */ + expect(extensionManager.getDataSources).toHaveBeenCalledTimes(2); + expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource1'); + expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource2'); + expect(data).toEqual([series1, series2]); + }); + }); + + describe('callForAllDataSources', () => { + it('should call the correct functions and return the merged data', () => { + /** Arrange */ + extensionManager.getDataSources = jest.fn(dataSourceName => [ + { + [pathSync]: () => dataSourceAndUIDsMap[dataSourceName], + }, + ]); + + /** Act */ + const data = callForAllDataSources({ + path: pathSync, + args: [], + extensionManager, + dataSourceNames: ['dataSource2', 'dataSource3'], + }); + + /** Assert */ + expect(extensionManager.getDataSources).toHaveBeenCalledTimes(2); + expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource2'); + expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource3'); + expect(data).toEqual(['234', '345']); + }); + }); + + describe('callForDefaultDataSource', () => { + it('should call the correct function and return the data', () => { + /** Arrange */ + extensionManager.getDataSources = jest.fn(dataSourceName => [ + { + [pathSync]: () => dataSourceAndUIDsMap[dataSourceName], + }, + ]); + + /** Act */ + const data = callForDefaultDataSource({ + path: pathSync, + args: [], + extensionManager, + defaultDataSourceName: 'dataSource2', + }); + + /** Assert */ + expect(extensionManager.getDataSources).toHaveBeenCalledTimes(1); + expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource2'); + expect(data).toEqual(['234']); + }); + }); + + describe('callByRetrieveAETitle', () => { + it('should call the correct function and return the data', () => { + /** Arrange */ + DicomMetadataStore.getSeries.mockImplementationOnce(() => [series2]); + extensionManager.getDataSources = jest.fn(dataSourceName => [ + { + [pathSync]: () => dataSourceAndUIDsMap[dataSourceName], + }, + ]); + + /** Act */ + const data = callByRetrieveAETitle({ + path: pathSync, + args: [dataSourceAndDSMap['dataSource2']], + extensionManager, + defaultDataSourceName: 'dataSource2', + }); + + /** Assert */ + expect(extensionManager.getDataSources).toHaveBeenCalledTimes(1); + expect(extensionManager.getDataSources).toHaveBeenCalledWith('dataSource2'); + expect(data).toEqual(['234']); + }); + }); +}); diff --git a/extensions/default/src/MergeDataSource/index.ts b/extensions/default/src/MergeDataSource/index.ts new file mode 100644 index 00000000000..7143ee89430 --- /dev/null +++ b/extensions/default/src/MergeDataSource/index.ts @@ -0,0 +1,252 @@ +import { DicomMetadataStore, IWebApiDataSource } from '@ohif/core'; +import { get, uniqBy } from 'lodash'; +import { + MergeConfig, + CallForAllDataSourcesAsyncOptions, + CallForAllDataSourcesOptions, + CallForDefaultDataSourceOptions, + CallByRetrieveAETitleOptions, + MergeMap, +} from './types'; + +export const mergeMap: MergeMap = { + 'query.studies.search': { + mergeKey: 'studyInstanceUid', + tagFunc: x => x, + }, + 'query.series.search': { + mergeKey: 'seriesInstanceUid', + tagFunc: (series, sourceName) => { + series.forEach(series => { + series.RetrieveAETitle = sourceName; + DicomMetadataStore.updateSeriesMetadata(series); + }); + return series; + }, + }, +}; + +/** + * Calls all data sources asynchronously and merges the results. + * @param {CallForAllDataSourcesAsyncOptions} options - The options for calling all data sources. + * @param {string} options.path - The path to the function to be called on each data source. + * @param {unknown[]} options.args - The arguments to be passed to the function. + * @param {ExtensionManager} options.extensionManager - The extension manager. + * @param {string[]} options.dataSourceNames - The names of the data sources to be called. + * @returns {Promise} - A promise that resolves to the merged data from all data sources. + */ +export const callForAllDataSourcesAsync = async ({ + mergeMap, + path, + args, + extensionManager, + dataSourceNames, +}: CallForAllDataSourcesAsyncOptions) => { + const { mergeKey, tagFunc } = mergeMap[path] || { tagFunc: x => x }; + + const dataSourceDefs = Object.values(extensionManager.dataSourceDefs); + const promises = []; + const mergedData = []; + + for (const dataSourceDef of dataSourceDefs) { + const { configuration, sourceName } = dataSourceDef; + if (!!configuration && dataSourceNames.includes(sourceName)) { + const [dataSource] = extensionManager.getDataSources(sourceName); + const func = get(dataSource, path); + const promise = func.apply(dataSource, args); + promises.push(promise.then(data => mergedData.push(tagFunc(data, sourceName)))); + } + } + + await Promise.allSettled(promises); + + return uniqBy(mergedData.flat(), obj => obj[mergeKey]); +}; + +/** + * Calls all data sources that match the provided names and merges their data. + * @param options - The options for calling all data sources. + * @param options.path - The path to the function to be called on each data source. + * @param options.args - The arguments to be passed to the function. + * @param options.extensionManager - The extension manager instance. + * @param options.dataSourceNames - The names of the data sources to be called. + * @returns The merged data from all the matching data sources. + */ +export const callForAllDataSources = ({ + path, + args, + extensionManager, + dataSourceNames, +}: CallForAllDataSourcesOptions) => { + const dataSourceDefs = Object.values(extensionManager.dataSourceDefs); + const mergedData = []; + for (const dataSourceDef of dataSourceDefs) { + const { configuration, sourceName } = dataSourceDef; + if (!!configuration && dataSourceNames.includes(sourceName)) { + const [dataSource] = extensionManager.getDataSources(sourceName); + const func = get(dataSource, path); + const data = func.apply(dataSource, args); + mergedData.push(data); + } + } + return mergedData.flat(); +}; + +/** + * Calls the default data source function specified by the given path with the provided arguments. + * @param {CallForDefaultDataSourceOptions} options - The options for calling the default data source. + * @param {string} options.path - The path to the function within the default data source. + * @param {unknown[]} options.args - The arguments to pass to the function. + * @param {string} options.defaultDataSourceName - The name of the default data source. + * @param {ExtensionManager} options.extensionManager - The extension manager instance. + * @returns {unknown} - The result of calling the default data source function. + */ +export const callForDefaultDataSource = ({ + path, + args, + defaultDataSourceName, + extensionManager, +}: CallForDefaultDataSourceOptions) => { + const [dataSource] = extensionManager.getDataSources(defaultDataSourceName); + const func = get(dataSource, path); + return func.apply(dataSource, args); +}; + +/** + * Calls the data source specified by the RetrieveAETitle of the given display set. + * @typedef {Object} CallByRetrieveAETitleOptions + * @property {string} path - The path of the method to call on the data source. + * @property {unknown[]} args - The arguments to pass to the method. + * @property {string} defaultDataSourceName - The name of the default data source. + * @property {ExtensionManager} extensionManager - The extension manager. + */ +export const callByRetrieveAETitle = ({ + path, + args, + defaultDataSourceName, + extensionManager, +}: CallByRetrieveAETitleOptions) => { + const [displaySet] = args; + const seriesMetadata = DicomMetadataStore.getSeries( + displaySet.StudyInstanceUID, + displaySet.SeriesInstanceUID + ); + const [dataSource] = extensionManager.getDataSources( + seriesMetadata.RetrieveAETitle || defaultDataSourceName + ); + return dataSource[path](...args); +}; + +function createMergeDataSourceApi( + mergeConfig: MergeConfig, + UserAuthenticationService: unknown, + extensionManager +) { + const { seriesMerge } = mergeConfig; + const { dataSourceNames, defaultDataSourceName } = seriesMerge; + + const implementation = { + initialize: (...args: unknown[]) => + callForAllDataSources({ path: 'initialize', args, extensionManager, dataSourceNames }), + query: { + studies: { + search: (...args: unknown[]) => + callForAllDataSourcesAsync({ + mergeMap, + path: 'query.studies.search', + args, + extensionManager, + dataSourceNames, + }), + }, + series: { + search: (...args: unknown[]) => + callForAllDataSourcesAsync({ + mergeMap, + path: 'query.series.search', + args, + extensionManager, + dataSourceNames, + }), + }, + instances: { + search: (...args: unknown[]) => + callForAllDataSourcesAsync({ + mergeMap, + path: 'query.instances.search', + args, + extensionManager, + dataSourceNames, + }), + }, + }, + retrieve: { + bulkDataURI: (...args: unknown[]) => + callForAllDataSourcesAsync({ + mergeMap, + path: 'retrieve.bulkDataURI', + args, + extensionManager, + dataSourceNames, + }), + directURL: (...args: unknown[]) => + callForDefaultDataSource({ + path: 'retrieve.directURL', + args, + defaultDataSourceName, + extensionManager, + }), + series: { + metadata: (...args: unknown[]) => + callForAllDataSourcesAsync({ + mergeMap, + path: 'retrieve.series.metadata', + args, + extensionManager, + dataSourceNames, + }), + }, + }, + store: { + dicom: (...args: unknown[]) => + callForDefaultDataSource({ + path: 'store.dicom', + args, + defaultDataSourceName, + extensionManager, + }), + }, + deleteStudyMetadataPromise: (...args: unknown[]) => + callForAllDataSources({ + path: 'deleteStudyMetadataPromise', + args, + extensionManager, + dataSourceNames, + }), + getImageIdsForDisplaySet: (...args: unknown[]) => + callByRetrieveAETitle({ + path: 'getImageIdsForDisplaySet', + args, + defaultDataSourceName, + extensionManager, + }), + getImageIdsForInstance: (...args: unknown[]) => + callByRetrieveAETitle({ + path: 'getImageIdsForDisplaySet', + args, + defaultDataSourceName, + extensionManager, + }), + getStudyInstanceUIDs: (...args: unknown[]) => + callForAllDataSources({ + path: 'getStudyInstanceUIDs', + args, + extensionManager, + dataSourceNames, + }), + }; + + return IWebApiDataSource.create(implementation); +} + +export { createMergeDataSourceApi }; diff --git a/extensions/default/src/MergeDataSource/types.ts b/extensions/default/src/MergeDataSource/types.ts new file mode 100644 index 00000000000..3a0a3f83aa4 --- /dev/null +++ b/extensions/default/src/MergeDataSource/types.ts @@ -0,0 +1,44 @@ +import { ExtensionManager } from '@ohif/core'; + +export type MergeMap = { + [key: string]: { + mergeKey: string; + tagFunc: (data: unknown[], sourceName: string) => unknown[]; + }; +}; + +export type CallForAllDataSourcesAsyncOptions = { + mergeMap: object; + path: string; + args: unknown[]; + dataSourceNames: string[]; + extensionManager: ExtensionManager; +}; + +export type CallForAllDataSourcesOptions = { + path: string; + args: unknown[]; + dataSourceNames: string[]; + extensionManager: ExtensionManager; +}; + +export type CallForDefaultDataSourceOptions = { + path: string; + args: unknown[]; + defaultDataSourceName: string; + extensionManager: ExtensionManager; +}; + +export type CallByRetrieveAETitleOptions = { + path: string; + args: unknown[]; + defaultDataSourceName: string; + extensionManager: ExtensionManager; +}; + +export type MergeConfig = { + seriesMerge: { + dataSourceNames: string[]; + defaultDataSourceName: string; + }; +}; diff --git a/extensions/default/src/getDataSourcesModule.js b/extensions/default/src/getDataSourcesModule.js index 6eab56a2fad..8c918e0c6ac 100644 --- a/extensions/default/src/getDataSourcesModule.js +++ b/extensions/default/src/getDataSourcesModule.js @@ -6,6 +6,7 @@ import { createDicomWebApi } from './DicomWebDataSource/index.js'; import { createDicomJSONApi } from './DicomJSONDataSource/index.js'; import { createDicomLocalApi } from './DicomLocalDataSource/index.js'; import { createDicomWebProxyApi } from './DicomWebProxyDataSource/index.js'; +import { createMergeDataSourceApi } from './MergeDataSource/index'; /** * @@ -32,6 +33,11 @@ function getDataSourcesModule() { type: 'localApi', createDataSource: createDicomLocalApi, }, + { + name: 'merge', + type: 'mergeApi', + createDataSource: createMergeDataSourceApi, + }, ]; } diff --git a/platform/core/src/extensions/ExtensionManager.ts b/platform/core/src/extensions/ExtensionManager.ts index aaf6df8918a..5b2e4b56086 100644 --- a/platform/core/src/extensions/ExtensionManager.ts +++ b/platform/core/src/extensions/ExtensionManager.ts @@ -464,7 +464,8 @@ export default class ExtensionManager extends PubSubService { const { userAuthenticationService } = this._servicesManager.services; const dataSourceInstance = module.createDataSource( dataSourceDef.configuration, - userAuthenticationService + userAuthenticationService, + this ); this.dataSourceMap[dataSourceDef.sourceName] = [dataSourceInstance]; diff --git a/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.ts b/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.ts index 79b5f96166c..99843e810f2 100644 --- a/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.ts +++ b/platform/core/src/services/DicomMetadataStore/DicomMetadataStore.ts @@ -186,6 +186,18 @@ const BaseImplementation = { madeInClient, }); }, + updateSeriesMetadata(seriesMetadata) { + const { StudyInstanceUID, SeriesInstanceUID } = seriesMetadata; + const series = _getSeries(StudyInstanceUID, SeriesInstanceUID); + if (!series) { + return; + } + + const study = _getStudy(StudyInstanceUID); + if (study) { + study.setSeriesMetadata(SeriesInstanceUID, seriesMetadata); + } + }, addSeriesMetadata(seriesSummaryMetadata, madeInClient = false) { if (!seriesSummaryMetadata || !seriesSummaryMetadata.length || !seriesSummaryMetadata[0]) { return; diff --git a/platform/docs/docs/platform/extensions/modules/data-source.md b/platform/docs/docs/platform/extensions/modules/data-source.md index 6cbb741d36d..7b7a2d5060c 100644 --- a/platform/docs/docs/platform/extensions/modules/data-source.md +++ b/platform/docs/docs/platform/extensions/modules/data-source.md @@ -81,7 +81,6 @@ You can take a look at `dicomweb` data source implementation to get an idea `extensions/default/src/DicomWebDataSource/index.js` but here here are some important api endpoints that you need to implement: - - `initialize`: This method is called when the data source is first created in the mode.tsx, it is used to initialize the data source and set the configuration. For instance, `dicomwebDatasource` uses this method to grab the StudyInstanceUID from the URL and set it as the active study, as opposed to `dicomJSONDatasource` which uses url in the browser to fetch the data and store it in a cache - `query.studies.search`: This is used in the study panel on the left to fetch the prior studies for the same MRN which is then used to display on the `All` tab. it is also used in the Worklist to show all the studies from the server. - `query.series.search`: This is used to fetch the series information for a given study that is expanded in the Worklist. @@ -89,8 +88,6 @@ important api endpoints that you need to implement: - `retrieve.series.metadata`: It is a crucial end point that is used to fetch series level metadata which for hanging displaySets and displaySet creation. - `store.dicom`: If you don't need store functionality, you can skip this method. This is used to store the data in the backend. - - ## Static WADO Client If the configuration for the data source has the value staticWado set, then it @@ -172,3 +169,44 @@ extensionManager.updateDataSourceConfiguration( "dicomweb", }, ); ``` + +## Merge Data Source +The built-in merge data source is a useful tool for combining results from multiple data sources. +Currently, this data source only supports merging at the series level. This means that series from data source 'A' +and series from data source 'B' will be retrieved under the same study. If the same series exists in both data sources, +the first series arrived is the one that gets stored, and any other conflicting series will be ignored. + +The merge data source is particularly useful when dealing with derived data that is generated and stored in different servers. +For example, it can be used to retrieve annotation series from one data source and input data (images) from another data source. + +A default data source can be defined as shown below. This allows defining which of the servers should be the +fallback server in case something goes wrong. + +Configuration Example: +```js +window.config = { + ... + dataSources: [ + { + sourceName: 'merge', + namespace: '@ohif/extension-default.dataSourcesModule.merge', + configuration: { + name: 'merge', + friendlyName: 'Merge dicomweb-1 and dicomweb-2 data at the series level', + seriesMerge: { + dataSourceNames: ['dicomweb-1', 'dicomweb-2'], + defaultDataSourceName: 'dicomweb-1' + }, + }, + }, + { + sourceName: 'dicomweb-1', + ... + }, + { + sourceName: 'dicomweb-2', + ... + }, + ], +}; +```