diff --git a/src/actions/loadUserFiles.ts b/src/actions/loadUserFiles.ts index f0fa45f68..2b76ba54e 100644 --- a/src/actions/loadUserFiles.ts +++ b/src/actions/loadUserFiles.ts @@ -29,6 +29,8 @@ import { } from '@/src/io/import/common'; import { isDicomImage } from '@/src/utils/dataSelection'; import { useViewStore } from '@/src/store/views'; +import { useMessageStore } from '@/src/store/messages'; +import { shouldSkipImportSource } from '@/src/io/import/shouldSkipImportSource'; // higher value priority is preferred for picking a primary selection const BASE_MODALITY_TYPES = { @@ -304,6 +306,19 @@ function loadDataSourcesWithOutcome( sources: DataSource[], importer: DataSourceImporter ): Promise { + if (sources.length && sources.every(shouldSkipImportSource)) { + const names = sources.map((source) => `- ${getDataSourceName(source)}`); + useMessageStore().addInfo( + 'No supported files to load', + `These files were skipped:\n${names.join('\n')}` + ); + return Promise.resolve({ + datasetIds: [], + hadErrors: false, + completed: true, + }); + } + const loadDataStore = useLoadDataStore(); const viewStore = useViewStore(); diff --git a/src/io/import/importDataSources.ts b/src/io/import/importDataSources.ts index 378d170eb..b3936c94b 100644 --- a/src/io/import/importDataSources.ts +++ b/src/io/import/importDataSources.ts @@ -1,4 +1,5 @@ import { + ImportContext, ImportHandler, ImportResult, asErrorResult, @@ -47,6 +48,7 @@ import { Chunk } from '@/src/core/streaming/chunk'; import { useDatasetStore } from '@/src/store/datasets'; import { useDICOMStore } from '@/src/store/datasets-dicom'; import { useMessageStore } from '@/src/store/messages'; +import { shouldSkipImportSource } from '@/src/io/import/shouldSkipImportSource'; const unhandledResource: ImportHandler = (dataSource) => { return asErrorResult(new Error('Failed to handle resource'), dataSource); @@ -137,6 +139,67 @@ export const appRestoreProcessors = (): RestoreProcessors => ({ completeStateFileRestore, }); +async function processImportQueue( + dataSources: DataSource[], + handlers: ImportHandler[], + importContext: ImportContext +) { + const chunkSources: DataSource[] = []; + const configResults: ConfigResult[] = []; + const stateFileSetups: StateFileSetupResult[] = []; + const results: ImportDataSourcesResult[] = []; + + const enqueue = (sources: DataSource[]) => + sources + .filter((src) => !shouldSkipImportSource(src)) + .map((src) => ({ + promise: evaluateChain(src, handlers, importContext), + source: src, + })); + + let queue = enqueue(dataSources); + + while (queue.length) { + const { index } = await asyncSelect(queue.map((item) => item.promise)); + const { promise, source } = queue[index]; + const result = await promise.catch((err) => asErrorResult(err, source)); + queue = queue.filter((_, i) => i !== index); + + switch (result.type) { + case 'stateFileSetup': + stateFileSetups.push(result); + // fallthrough to handle dataSources + case 'intermediate': { + const [chunks, otherSources] = partition( + (ds) => ds.type === 'chunk', + result.dataSources + ); + chunkSources.push(...chunks); + + queue.push(...enqueue(otherSources)); + break; + } + case 'config': + configResults.push(result); + try { + await applyPreStateConfig(result.config); + } catch (err) { + results.push(asErrorResult(ensureError(err), result.dataSource)); + } + break; + case 'ok': + case 'error': + case 'data': + results.push(result); + break; + default: + throw new Error(`Invalid result: ${result}`); + } + } + + return { chunkSources, configResults, stateFileSetups, results }; +} + async function importDataSourcesWithPolicy( dataSources: DataSource[], policy: ImportPolicy, @@ -189,58 +252,8 @@ async function importDataSourcesWithPolicy( unhandledResource, ]; - const chunkSources: DataSource[] = []; - const configResults: ConfigResult[] = []; - const stateFileSetups: StateFileSetupResult[] = []; - const results: ImportDataSourcesResult[] = []; - - let queue = dataSources.map((src) => ({ - promise: evaluateChain(src, handlers, importContext), - source: src, - })); - - while (queue.length) { - const { index } = await asyncSelect(queue.map((item) => item.promise)); - const { promise, source } = queue[index]; - const result = await promise.catch((err) => asErrorResult(err, source)); - queue = queue.filter((_, i) => i !== index); - - switch (result.type) { - case 'stateFileSetup': - stateFileSetups.push(result); - // fallthrough to handle dataSources - case 'intermediate': { - const [chunks, otherSources] = partition( - (ds) => ds.type === 'chunk', - result.dataSources - ); - chunkSources.push(...chunks); - - queue.push( - ...otherSources.map((src) => ({ - promise: evaluateChain(src, handlers, importContext), - source: src, - })) - ); - break; - } - case 'config': - configResults.push(result); - try { - await applyPreStateConfig(result.config); - } catch (err) { - results.push(asErrorResult(ensureError(err), result.dataSource)); - } - break; - case 'ok': - case 'error': - case 'data': - results.push(result); - break; - default: - throw new Error(`Invalid result: ${result}`); - } - } + const { chunkSources, configResults, stateFileSetups, results } = + await processImportQueue(dataSources, handlers, importContext); cleanup(); diff --git a/src/io/import/shouldSkipImportSource.ts b/src/io/import/shouldSkipImportSource.ts new file mode 100644 index 000000000..4f9e245ad --- /dev/null +++ b/src/io/import/shouldSkipImportSource.ts @@ -0,0 +1,13 @@ +import { DataSource } from '@/src/io/import/dataSource'; +import { basename } from '@/src/utils/path'; + +// LICENSE files and macOS metadata ship beside real data but never load. +// `__MACOSX/` holds only `._*` files, so the file name alone suffices. +export const isIgnoredFileName = (name: string) => + name.toLowerCase() === 'license' || + name === '.DS_Store' || + name.startsWith('._'); + +export const shouldSkipImportSource = (source: DataSource) => + (source.type === 'file' && isIgnoredFileName(source.file.name)) || + (source.type === 'uri' && isIgnoredFileName(basename(source.name))); diff --git a/src/io/zip.ts b/src/io/zip.ts index e4a05afb2..25c85cea9 100644 --- a/src/io/zip.ts +++ b/src/io/zip.ts @@ -1,5 +1,6 @@ import JSZip from 'jszip'; import { basename, dirname } from '@/src/utils/path'; +import { isIgnoredFileName } from '@/src/io/import/shouldSkipImportSource'; import { FileEntry } from './types'; export async function extractFilesFromZip(zipFile: File): Promise { @@ -8,7 +9,7 @@ export async function extractFilesFromZip(zipFile: File): Promise { const paths: string[] = []; zip.forEach((relPath, file) => { const fileName = basename(file.name); - if (!file.dir && fileName.toLowerCase() !== 'license') { + if (!file.dir && !isIgnoredFileName(fileName)) { const path = dirname(file.name); const fileEntry = zip.file(file.name); if (fileEntry) { diff --git a/tests/specs/metadata-imports.e2e.ts b/tests/specs/metadata-imports.e2e.ts new file mode 100644 index 000000000..cee983236 --- /dev/null +++ b/tests/specs/metadata-imports.e2e.ts @@ -0,0 +1,55 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import JSZip from 'jszip'; +import { TEMP_DIR } from '../../wdio.shared.conf'; +import { volViewPage } from '../pageobjects/volview.page'; +import { makeTempDir, openThroughDialog, writeMetaImage } from './utils'; + +const STEM = 'metadata-imports'; + +const notificationTitles = async () => { + await volViewPage.notifications.click(); + const titles = $$('.message-center .v-expansion-panel-title .header > span'); + await titles[0].waitForDisplayed(); + return titles.map((title) => title.getText()); +}; + +describe('Importing beside macOS metadata and LICENSE files', () => { + it('loads the image and skips the other files without errors', async () => { + const directory = makeTempDir(STEM); + const image = writeMetaImage('metadata-imports-scan.mha'); + // The layout macOS Archive Utility produces for a compressed folder. + const zip = new JSZip(); + zip.file(`scan/${image}`, readFileSync(path.join(TEMP_DIR, image))); + zip.file('scan/.DS_Store', 'Finder metadata'); + zip.file('scan/LICENSE', 'Terms'); + zip.file('__MACOSX/._scan', 'AppleDouble'); + zip.file(`__MACOSX/scan/._${image}`, 'AppleDouble'); + writeFileSync( + path.join(directory, 'scan.zip'), + await zip.generateAsync({ type: 'nodebuffer' }) + ); + writeFileSync(path.join(directory, '.DS_Store'), 'Finder metadata'); + writeFileSync(path.join(directory, `._${image}`), 'AppleDouble'); + const urls = ['scan.zip', '.DS_Store', `._${image}`].map( + (name) => `tmp/${STEM}/${name}` + ); + + await volViewPage.open(`?urls=[${urls.join(',')}]`); + + await volViewPage.waitForViews(); + await expect($(`.image-list-card [title="${image}"]`)).toBeDisplayed(); + expect(await volViewPage.getNotificationsCount()).toBe(0); + }); + + it('explains why opening only a LICENSE file loads nothing', async () => { + const directory = makeTempDir(STEM); + const license = path.join(directory, 'LICENSE'); + writeFileSync(license, 'Terms'); + + await volViewPage.open(); + await openThroughDialog(license); + + expect(await notificationTitles()).toEqual(['No supported files to load']); + }); +});