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.
This commit is contained in:
parent
1be5613946
commit
c4ff2c2f09
@ -45,6 +45,7 @@ module.exports = {
|
|||||||
'@babel/plugin-proposal-object-rest-spread',
|
'@babel/plugin-proposal-object-rest-spread',
|
||||||
'@babel/plugin-syntax-dynamic-import',
|
'@babel/plugin-syntax-dynamic-import',
|
||||||
'@babel/plugin-transform-regenerator',
|
'@babel/plugin-transform-regenerator',
|
||||||
|
'@babel/transform-destructuring',
|
||||||
'@babel/plugin-transform-runtime',
|
'@babel/plugin-transform-runtime',
|
||||||
'@babel/plugin-transform-typescript',
|
'@babel/plugin-transform-typescript',
|
||||||
],
|
],
|
||||||
|
|||||||
1
extensions/default/babel.config.js
Normal file
1
extensions/default/babel.config.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
module.exports = require('../../babel.config.js');
|
||||||
17
extensions/default/jest.config.js
Normal file
17
extensions/default/jest.config.js
Normal file
@ -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/(.*)': '<rootDir>/../../platform/$1/src',
|
||||||
|
},
|
||||||
|
// rootDir: "../.."
|
||||||
|
// testMatch: [
|
||||||
|
// //`<rootDir>/platform/${pack.name}/**/*.spec.js`
|
||||||
|
// "<rootDir>/platform/app/**/*.test.js"
|
||||||
|
// ]
|
||||||
|
};
|
||||||
@ -1,14 +1,14 @@
|
|||||||
import ContextMenuItemsBuilder from './ContextMenuItemsBuilder';
|
import * as ContextMenuItemsBuilder from './ContextMenuItemsBuilder';
|
||||||
|
|
||||||
const menus = [
|
const menus = [
|
||||||
{
|
{
|
||||||
id: 'one',
|
id: 'one',
|
||||||
selector: ({ value }) => value === 'one',
|
selector: ({ value } = {}) => value === 'one',
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'two',
|
id: 'two',
|
||||||
selector: ({ value }) => value === 'two',
|
selector: ({ value } = {}) => value === 'two',
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -17,13 +17,13 @@ const menus = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const menuBuilder = new ContextMenuItemsBuilder();
|
|
||||||
|
|
||||||
describe('ContextMenuItemsBuilder', () => {
|
describe('ContextMenuItemsBuilder', () => {
|
||||||
test('findMenuDefault', () => {
|
test('findMenuDefault', () => {
|
||||||
expect(menuBuilder.findMenuDefault(menus, {})).toBe(menus[2]);
|
expect(ContextMenuItemsBuilder.findMenuDefault(menus, {})).toBe(menus[2]);
|
||||||
expect(menuBuilder.findMenuDefault(menus, { value: 'two' })).toBe(menus[1]);
|
expect(
|
||||||
expect(menuBuilder.findMenuDefault([], {})).toBeUndefined();
|
ContextMenuItemsBuilder.findMenuDefault(menus, { selectorProps: { value: 'two' } })
|
||||||
expect(menuBuilder.findMenuDefault(undefined, undefined)).toBeNull();
|
).toBe(menus[1]);
|
||||||
|
expect(ContextMenuItemsBuilder.findMenuDefault([], {})).toBeUndefined();
|
||||||
|
expect(ContextMenuItemsBuilder.findMenuDefault(undefined, undefined)).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -140,7 +140,7 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
|
|||||||
instances: {
|
instances: {
|
||||||
search: (studyInstanceUid, queryParameters) => {
|
search: (studyInstanceUid, queryParameters) => {
|
||||||
qidoDicomWebClient.headers = getAuthrorizationHeader();
|
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,
|
enableStudyLazyLoad,
|
||||||
filters,
|
filters,
|
||||||
sortCriteria,
|
sortCriteria,
|
||||||
sortFunction
|
sortFunction,
|
||||||
|
dicomWebConfig
|
||||||
);
|
);
|
||||||
|
|
||||||
// first naturalize the data
|
// first naturalize the data
|
||||||
@ -314,6 +315,8 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
|
|||||||
Object.keys(instancesPerSeries).forEach(seriesInstanceUID =>
|
Object.keys(instancesPerSeries).forEach(seriesInstanceUID =>
|
||||||
DicomMetadataStore.addInstances(instancesPerSeries[seriesInstanceUID], madeInClient)
|
DicomMetadataStore.addInstances(instancesPerSeries[seriesInstanceUID], madeInClient)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return seriesSummaryMetadata;
|
||||||
},
|
},
|
||||||
|
|
||||||
_retrieveSeriesMetadataAsync: async (
|
_retrieveSeriesMetadataAsync: async (
|
||||||
@ -333,7 +336,8 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
|
|||||||
enableStudyLazyLoad,
|
enableStudyLazyLoad,
|
||||||
filters,
|
filters,
|
||||||
sortCriteria,
|
sortCriteria,
|
||||||
sortFunction
|
sortFunction,
|
||||||
|
dicomWebConfig
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -444,6 +448,8 @@ function createDicomWebApi(dicomWebConfig, userAuthenticationService) {
|
|||||||
);
|
);
|
||||||
await Promise.all(seriesDeliveredPromises);
|
await Promise.all(seriesDeliveredPromises);
|
||||||
setSuccessFlag();
|
setSuccessFlag();
|
||||||
|
|
||||||
|
return seriesSummaryMetadata;
|
||||||
},
|
},
|
||||||
deleteStudyMetadataPromise,
|
deleteStudyMetadataPromise,
|
||||||
getImageIdsForDisplaySet(displaySet) {
|
getImageIdsForDisplaySet(displaySet) {
|
||||||
|
|||||||
@ -25,7 +25,8 @@ export function retrieveStudyMetadata(
|
|||||||
enableStudyLazyLoad,
|
enableStudyLazyLoad,
|
||||||
filters,
|
filters,
|
||||||
sortCriteria,
|
sortCriteria,
|
||||||
sortFunction
|
sortFunction,
|
||||||
|
dicomWebConfig = {}
|
||||||
) {
|
) {
|
||||||
// @TODO: Whenever a study metadata request has failed, its related promise will be rejected once and for all
|
// @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
|
// 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.`);
|
throw new Error(`${moduleName}: Required 'StudyInstanceUID' parameter not provided.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const promiseId = `${dicomWebConfig.name}:${StudyInstanceUID}`;
|
||||||
|
|
||||||
// Already waiting on result? Return cached promise
|
// Already waiting on result? Return cached promise
|
||||||
if (StudyMetaDataPromises.has(StudyInstanceUID)) {
|
if (StudyMetaDataPromises.has(promiseId)) {
|
||||||
return StudyMetaDataPromises.get(StudyInstanceUID);
|
return StudyMetaDataPromises.get(promiseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
let promise;
|
let promise;
|
||||||
@ -71,7 +74,7 @@ export function retrieveStudyMetadata(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store the promise in cache
|
// Store the promise in cache
|
||||||
StudyMetaDataPromises.set(StudyInstanceUID, promise);
|
StudyMetaDataPromises.set(promiseId, promise);
|
||||||
|
|
||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|||||||
203
extensions/default/src/MergeDataSource/index.test.js
Normal file
203
extensions/default/src/MergeDataSource/index.test.js
Normal file
@ -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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
252
extensions/default/src/MergeDataSource/index.ts
Normal file
252
extensions/default/src/MergeDataSource/index.ts
Normal file
@ -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<unknown[]>} - 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 };
|
||||||
44
extensions/default/src/MergeDataSource/types.ts
Normal file
44
extensions/default/src/MergeDataSource/types.ts
Normal file
@ -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;
|
||||||
|
};
|
||||||
|
};
|
||||||
@ -6,6 +6,7 @@ import { createDicomWebApi } from './DicomWebDataSource/index.js';
|
|||||||
import { createDicomJSONApi } from './DicomJSONDataSource/index.js';
|
import { createDicomJSONApi } from './DicomJSONDataSource/index.js';
|
||||||
import { createDicomLocalApi } from './DicomLocalDataSource/index.js';
|
import { createDicomLocalApi } from './DicomLocalDataSource/index.js';
|
||||||
import { createDicomWebProxyApi } from './DicomWebProxyDataSource/index.js';
|
import { createDicomWebProxyApi } from './DicomWebProxyDataSource/index.js';
|
||||||
|
import { createMergeDataSourceApi } from './MergeDataSource/index';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -32,6 +33,11 @@ function getDataSourcesModule() {
|
|||||||
type: 'localApi',
|
type: 'localApi',
|
||||||
createDataSource: createDicomLocalApi,
|
createDataSource: createDicomLocalApi,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'merge',
|
||||||
|
type: 'mergeApi',
|
||||||
|
createDataSource: createMergeDataSourceApi,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -464,7 +464,8 @@ export default class ExtensionManager extends PubSubService {
|
|||||||
const { userAuthenticationService } = this._servicesManager.services;
|
const { userAuthenticationService } = this._servicesManager.services;
|
||||||
const dataSourceInstance = module.createDataSource(
|
const dataSourceInstance = module.createDataSource(
|
||||||
dataSourceDef.configuration,
|
dataSourceDef.configuration,
|
||||||
userAuthenticationService
|
userAuthenticationService,
|
||||||
|
this
|
||||||
);
|
);
|
||||||
|
|
||||||
this.dataSourceMap[dataSourceDef.sourceName] = [dataSourceInstance];
|
this.dataSourceMap[dataSourceDef.sourceName] = [dataSourceInstance];
|
||||||
|
|||||||
@ -186,6 +186,18 @@ const BaseImplementation = {
|
|||||||
madeInClient,
|
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) {
|
addSeriesMetadata(seriesSummaryMetadata, madeInClient = false) {
|
||||||
if (!seriesSummaryMetadata || !seriesSummaryMetadata.length || !seriesSummaryMetadata[0]) {
|
if (!seriesSummaryMetadata || !seriesSummaryMetadata.length || !seriesSummaryMetadata[0]) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -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
|
`extensions/default/src/DicomWebDataSource/index.js` but here here are some
|
||||||
important api endpoints that you need to implement:
|
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
|
- `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.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.
|
- `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.
|
- `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.
|
- `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
|
## Static WADO Client
|
||||||
|
|
||||||
If the configuration for the data source has the value staticWado set, then it
|
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',
|
||||||
|
...
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user