diff --git a/src/io/import/common.ts b/src/io/import/common.ts index e69053714..f5e72c114 100644 --- a/src/io/import/common.ts +++ b/src/io/import/common.ts @@ -3,11 +3,13 @@ import { DataSource, FileSource } from '@/src/io/import/dataSource'; import { Handler } from '@/src/core/pipeline'; import { ARCHIVE_FILE_TYPES } from '@/src/io/mimeTypes'; import { Awaitable } from '@vueuse/core'; +import { Config } from '@/src/io/import/configSchema'; export interface ImportResult { dataSource: DataSource; dataID?: string; dataType?: 'image' | 'dicom' | 'model'; + config?: Config; } export type ArchiveContents = Record; diff --git a/src/io/import/configSchema.ts b/src/io/import/configSchema.ts new file mode 100644 index 000000000..900544463 --- /dev/null +++ b/src/io/import/configSchema.ts @@ -0,0 +1,115 @@ +import { z } from 'zod'; +import { zodEnumFromObjKeys } from '@/src/utils'; +import { ACTIONS } from '@/src/constants'; +import { Layouts } from '@/src/config'; + +// for applyConfig +import { useRectangleStore } from '@/src/store/tools/rectangles'; +import { useRulerStore } from '@/src/store/tools/rulers'; +import { useDataBrowserStore } from '@/src/store/data-browser'; +import { usePolygonStore } from '@/src/store/tools/polygons'; +import { useViewStore } from '@/src/store/views'; +import { actionToKey } from '@/src/composables/useKeyboardShortcuts'; + +const layout = z + .object({ + activeLayout: zodEnumFromObjKeys(Layouts).optional(), + }) + .optional(); + +const dataBrowser = z + .object({ + hideSampleData: z.boolean().optional(), + }) + .optional(); + +const shortcuts = z.record(zodEnumFromObjKeys(ACTIONS), z.string()).optional(); + +// -------------------------------------------------------------------------- +// Labels + +const color = z.string(); + +const label = z.object({ + color, + strokeWidth: z.number().optional(), +}); + +const rulerLabel = label; +const polygonLabel = label; + +const rectangleLabel = z.intersection( + label, + z.object({ + fillColor: color, + }) +); + +const labels = z + .object({ + defaultLabels: z.record(label).or(z.null()).optional(), + rulerLabels: z.record(rulerLabel).or(z.null()).optional(), + rectangleLabels: z.record(rectangleLabel).or(z.null()).optional(), + polygonLabels: z.record(polygonLabel).or(z.null()).optional(), + }) + .optional(); + +export const config = z.object({ + layout, + dataBrowser, + labels, + shortcuts, +}); + +export type Config = z.infer; + +export const readConfigFile = async (configFile: File) => { + const decoder = new TextDecoder(); + const ab = await configFile.arrayBuffer(); + const text = decoder.decode(new Uint8Array(ab)); + return config.parse(JSON.parse(text)); +}; + +const applyLabels = (manifest: Config) => { + if (!manifest.labels) return; + + // pass through null labels, use fallback labels if undefined + const labelsIfUndefined = ( + toolLabels: (typeof manifest.labels)[keyof typeof manifest.labels] + ) => { + if (toolLabels === undefined) return manifest.labels?.defaultLabels; + return toolLabels; + }; + + const { rulerLabels, rectangleLabels, polygonLabels } = manifest.labels; + useRulerStore().mergeLabels(labelsIfUndefined(rulerLabels)); + useRectangleStore().mergeLabels(labelsIfUndefined(rectangleLabels)); + usePolygonStore().mergeLabels(labelsIfUndefined(polygonLabels)); +}; + +const applySampleData = (manifest: Config) => { + useDataBrowserStore().hideSampleData = !!manifest.dataBrowser?.hideSampleData; +}; + +const applyLayout = (manifest: Config) => { + if (manifest.layout?.activeLayout) { + const startingLayout = Layouts[manifest.layout.activeLayout]; + useViewStore().setLayout(startingLayout); + } +}; + +const applyShortcuts = (manifest: Config) => { + if (!manifest.shortcuts) return; + + actionToKey.value = { + ...actionToKey.value, + ...manifest.shortcuts, + }; +}; + +export const applyConfig = (manifest: Config) => { + applyLayout(manifest); + applyLabels(manifest); + applySampleData(manifest); + applyShortcuts(manifest); +}; diff --git a/src/io/import/importDataSources.ts b/src/io/import/importDataSources.ts index 698bfd9af..ddd1b1dbc 100644 --- a/src/io/import/importDataSources.ts +++ b/src/io/import/importDataSources.ts @@ -1,11 +1,7 @@ -import Pipeline, { - PipelineResult, - PipelineResultError, - PipelineResultSuccess, - partitionResults, -} from '@/src/core/pipeline'; -import { ImportHandler, ImportResult } from '@/src/io/import/common'; +import Pipeline from '@/src/core/pipeline'; +import { ImportHandler } from '@/src/io/import/common'; import { DataSource, DataSourceWithFile } from '@/src/io/import/dataSource'; +import { nonNullable } from '@/src/utils'; import handleDicomFile from '@/src/io/import/processors/handleDicomFile'; import downloadUrl from '@/src/io/import/processors/downloadUrl'; import extractArchive from '@/src/io/import/processors/extractArchive'; @@ -19,13 +15,7 @@ import updateFileMimeType from '@/src/io/import/processors/updateFileMimeType'; import handleConfig from '@/src/io/import/processors/handleConfig'; import { useDICOMStore } from '@/src/store/datasets-dicom'; import { makeDICOMSelection, makeImageSelection } from '@/src/store/datasets'; -import { - EARLIEST_PRIORITY, - earliestPriority, - prioritizeJSON, - prioritizeStateFile, - PriorityResult, -} from './processors/prioritize'; +import { applyConfig } from '@/src/io/import/configSchema'; /** * Tries to turn a thrown object into a meaningful error string. @@ -50,92 +40,57 @@ export async function importDataSources(dataSources: DataSource[]) { dicomDataSources: [] as DataSourceWithFile[], }; - const prioritizes = [ + const middleware = [ // updating the file type should be first in the pipeline updateFileMimeType, - // before extractArchive as .zip file is part of state file check - prioritizeStateFile, - - // source generators + // before extractArchive as .zip extension is part of state file check + restoreStateFile, handleRemoteManifest, handleGoogleCloudStorage, handleAmazonS3, downloadUrl, extractArchiveTargetFromCache, extractArchive, - - // sinks - prioritizeJSON, - earliestPriority, - ]; - const prioritizer = new Pipeline< - DataSource, - PriorityResult | ImportResult, - typeof importContext - >(prioritizes); - - const priorityOrImportResult = await Promise.all( - dataSources.map((r) => prioritizer.execute(r, importContext)) - ); - - const [fetchSuccess, errorOnDataFetches] = partitionResults( - priorityOrImportResult - ); - - const withPriority = fetchSuccess - .flatMap((r) => r.data) - .map((importOrPriority) => { - return { - dataSource: importOrPriority.dataSource, - priority: - 'priority' in importOrPriority - ? importOrPriority.priority - : EARLIEST_PRIORITY, - }; - }); - - // Group same priorities, lowest priority first - const priorityBuckets = withPriority.reduce((buckets, result) => { - const { priority = 0 } = result; - if (!buckets[priority]) { - // eslint-disable-next-line no-param-reassign - buckets[priority] = []; - } - buckets[priority].push(result.dataSource); - return buckets; - }, [] as Array>); - - const loaders = [ - restoreStateFile, - handleConfig, + handleConfig, // collect to apply later // should be before importSingleFile, since DICOM is more specific handleDicomFile, importSingleFile, // catch any unhandled resource unhandledResource, ]; - const fileLoader = new Pipeline(loaders); + const loader = new Pipeline(middleware); - // Serially processes buckets by priority. - // Configuration in later priority buckets will override earlier ones. - const results = [] as Array>; - for (let i = 0; i < priorityBuckets.length; i++) { - const sources = priorityBuckets[i]; - // sparse array, so check for undefined - if (sources) { - // eslint-disable-next-line no-await-in-loop - const bucketResults = await Promise.all( - sources.map((dataSource) => - fileLoader.execute(dataSource, importContext) + const results = await Promise.all( + dataSources.map((r) => loader.execute(r, importContext)) + ); + + // Apply config.JSONs + const configResult = await (async () => { + try { + results + .flatMap((pipelineResult) => + pipelineResult.ok ? pipelineResult.data : [] ) - ); - results.push(...bucketResults); + .map((result) => result.config) + .filter(nonNullable) + .forEach(applyConfig); + return { + ok: true as const, + data: [], + }; + } catch (err) { + return { + ok: false as const, + errors: [ + { + message: toMeaningfulErrorString(err), + cause: err, + inputDataStackTrace: [], + }, + ], + }; } - } - - if (!importContext.dicomDataSources.length) { - return [...errorOnDataFetches, ...results]; - } + })(); // handle DICOM loading const dicomDataSource: DataSource = { @@ -145,20 +100,29 @@ export async function importDataSources(dataSources: DataSource[]) { }; const dicomResult = await (async () => { try { + if (!importContext.dicomDataSources.length) { + return { + ok: true as const, + data: [], + }; + } const volumeKeys = await useDICOMStore().importFiles( importContext.dicomDataSources ); return { - ok: true, - data: volumeKeys.map((key) => ({ - dataID: key, - dataType: 'dicom' as const, - dataSource: dicomDataSource, - })), - } as PipelineResultSuccess; + ok: true as const, + data: volumeKeys.map( + (key) => + ({ + dataID: key, + dataType: 'dicom' as const, + dataSource: dicomDataSource, + } as const) + ), + }; } catch (err) { return { - ok: false, + ok: false as const, errors: [ { message: toMeaningfulErrorString(err), @@ -166,16 +130,16 @@ export async function importDataSources(dataSources: DataSource[]) { inputDataStackTrace: [dicomDataSource], }, ], - } as PipelineResultError; + }; } })(); return [ - ...errorOnDataFetches, // remove all results that have no result data - ...results.filter((result) => !result.ok || result.data.length), + ...results, dicomResult, - ]; + configResult, + ].filter((result) => !result.ok || (result.ok && result.data.length)); } export type ImportDataSourcesResult = Awaited< diff --git a/src/io/import/processors/handleConfig.ts b/src/io/import/processors/handleConfig.ts index ef0577474..55ed2dea2 100644 --- a/src/io/import/processors/handleConfig.ts +++ b/src/io/import/processors/handleConfig.ts @@ -1,118 +1,6 @@ -import { z } from 'zod'; - import { ImportHandler } from '@/src/io/import/common'; -import { useRectangleStore } from '@/src/store/tools/rectangles'; -import { useRulerStore } from '@/src/store/tools/rulers'; -import { useDataBrowserStore } from '@/src/store/data-browser'; -import { usePolygonStore } from '@/src/store/tools/polygons'; -import { useViewStore } from '@/src/store/views'; -import { Layouts } from '@/src/config'; -import { ensureError, zodEnumFromObjKeys } from '@/src/utils'; -import { ACTIONS } from '@/src/constants'; -import { actionToKey } from '@/src/composables/useKeyboardShortcuts'; - -const layout = z - .object({ - activeLayout: zodEnumFromObjKeys(Layouts).optional(), - }) - .optional(); - -const dataBrowser = z - .object({ - hideSampleData: z.boolean().optional(), - }) - .optional(); - -const shortcuts = z.record(zodEnumFromObjKeys(ACTIONS), z.string()).optional(); - -// -------------------------------------------------------------------------- -// Labels - -const color = z.string(); - -const label = z.object({ - color, - strokeWidth: z.number().optional(), -}); - -const rulerLabel = label; -const polygonLabel = label; - -const rectangleLabel = z.intersection( - label, - z.object({ - fillColor: color, - }) -); - -const labels = z - .object({ - defaultLabels: z.record(label).or(z.null()).optional(), - rulerLabels: z.record(rulerLabel).or(z.null()).optional(), - rectangleLabels: z.record(rectangleLabel).or(z.null()).optional(), - polygonLabels: z.record(polygonLabel).or(z.null()).optional(), - }) - .optional(); - -const config = z.object({ - layout, - dataBrowser, - labels, - shortcuts, -}); - -type Config = z.infer; - -const readConfigFile = async (configFile: File) => { - const decoder = new TextDecoder(); - const ab = await configFile.arrayBuffer(); - const text = decoder.decode(new Uint8Array(ab)); - return config.parse(JSON.parse(text)); -}; - -const applyLabels = (manifest: Config) => { - if (!manifest.labels) return; - - // pass through null labels, use fallback labels if undefined - const labelsIfUndefined = ( - toolLabels: (typeof manifest.labels)[keyof typeof manifest.labels] - ) => { - if (toolLabels === undefined) return manifest.labels?.defaultLabels; - return toolLabels; - }; - - const { rulerLabels, rectangleLabels, polygonLabels } = manifest.labels; - useRulerStore().mergeLabels(labelsIfUndefined(rulerLabels)); - useRectangleStore().mergeLabels(labelsIfUndefined(rectangleLabels)); - usePolygonStore().mergeLabels(labelsIfUndefined(polygonLabels)); -}; - -const applySampleData = (manifest: Config) => { - useDataBrowserStore().hideSampleData = !!manifest.dataBrowser?.hideSampleData; -}; - -const applyLayout = (manifest: Config) => { - if (manifest.layout?.activeLayout) { - const startingLayout = Layouts[manifest.layout.activeLayout]; - useViewStore().setLayout(startingLayout); - } -}; - -const applyShortcuts = (manifest: Config) => { - if (!manifest.shortcuts) return; - - actionToKey.value = { - ...actionToKey.value, - ...manifest.shortcuts, - }; -}; - -const applyConfig = (manifest: Config) => { - applyLayout(manifest); - applyLabels(manifest); - applySampleData(manifest); - applyShortcuts(manifest); -}; +import { ensureError } from '@/src/utils'; +import { readConfigFile } from '@/src/io/import/configSchema'; /** * Reads a JSON file with label config and updates stores. @@ -124,14 +12,13 @@ const handleConfig: ImportHandler = async (dataSource, { done }) => { if (fileSrc?.fileType === 'application/json') { try { const manifest = await readConfigFile(fileSrc.file); - applyConfig(manifest); + return done({ dataSource, config: manifest }); } catch (err) { console.error(err); throw new Error('Failed to parse config file', { cause: ensureError(err), }); } - return done(); } return dataSource; }; diff --git a/src/io/import/processors/prioritize.ts b/src/io/import/processors/prioritize.ts deleted file mode 100644 index 2b8ee7893..000000000 --- a/src/io/import/processors/prioritize.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { isStateFile } from '@/src/io/state-file'; -import { ImportContext } from '@/src/io/import/common'; -import { Handler } from '@/src/core/pipeline'; -import { DataSource } from '../dataSource'; - -export const EARLIEST_PRIORITY = 0; -const CONFIG_PRIORITY = 1; - -export interface PriorityResult { - dataSource: DataSource; - priority?: number; -} - -type PriorityHandler = Handler; - -/** - * Assigns priority to JSON files - * @param src DataSource - */ -export const prioritizeJSON: PriorityHandler = async (src, { done }) => { - const { fileSrc } = src; - if (fileSrc?.fileType === 'application/json') { - // assume config.JSON file - done({ - dataSource: src, - priority: CONFIG_PRIORITY, - }); - } - - return src; -}; - -/** - * Assigns first in line priority - * @param src DataSource - */ -export const earliestPriority: PriorityHandler = async (src, { done }) => { - return done({ - dataSource: src, - priority: EARLIEST_PRIORITY, - }); -}; - -/** - * Consume state files before extractArchive as .zip file is part of state file check - * @param src DataSource - */ -export const prioritizeStateFile: PriorityHandler = async ( - dataSource, - { done } -) => { - const { fileSrc } = dataSource; - if (fileSrc && (await isStateFile(fileSrc.file))) { - done({ dataSource, priority: EARLIEST_PRIORITY }); - } - return dataSource; -};