From c8e90ac51ea7b71f436dcef06f0fa144b5d50d6e Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sat, 7 Nov 2026 10:28:44 -0500 Subject: [PATCH 1/2] fix(import): centralize ignored filename filtering Skip LICENSE, .DS_Store and ._* files wherever they enter the import queue, including ZIP members before decompression. Report when every selected file is skipped. --- src/actions/loadUserFiles.ts | 15 +++++++ src/io/import/importDataSources.ts | 21 +++++----- src/io/import/shouldSkipImportSource.ts | 13 ++++++ src/io/zip.ts | 3 +- tests/specs/metadata-imports.e2e.ts | 55 +++++++++++++++++++++++++ 5 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 src/io/import/shouldSkipImportSource.ts create mode 100644 tests/specs/metadata-imports.e2e.ts 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..7d5b8d01b 100644 --- a/src/io/import/importDataSources.ts +++ b/src/io/import/importDataSources.ts @@ -47,6 +47,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); @@ -194,10 +195,15 @@ async function importDataSourcesWithPolicy( const stateFileSetups: StateFileSetupResult[] = []; const results: ImportDataSourcesResult[] = []; - let queue = dataSources.map((src) => ({ - promise: evaluateChain(src, handlers, importContext), - source: src, - })); + 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)); @@ -216,12 +222,7 @@ async function importDataSourcesWithPolicy( ); chunkSources.push(...chunks); - queue.push( - ...otherSources.map((src) => ({ - promise: evaluateChain(src, handlers, importContext), - source: src, - })) - ); + queue.push(...enqueue(otherSources)); break; } case 'config': 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']); + }); +}); From 7707fa48d956cf66ec1d530e4fb370278793f6cc Mon Sep 17 00:00:00 2001 From: Paul Elliott Date: Sat, 7 Nov 2026 11:19:16 -0500 Subject: [PATCH 2/2] refactor(import): extract import queue processing --- src/io/import/importDataSources.ts | 114 ++++++++++++++++------------- 1 file changed, 63 insertions(+), 51 deletions(-) diff --git a/src/io/import/importDataSources.ts b/src/io/import/importDataSources.ts index 7d5b8d01b..b3936c94b 100644 --- a/src/io/import/importDataSources.ts +++ b/src/io/import/importDataSources.ts @@ -1,4 +1,5 @@ import { + ImportContext, ImportHandler, ImportResult, asErrorResult, @@ -138,58 +139,11 @@ export const appRestoreProcessors = (): RestoreProcessors => ({ completeStateFileRestore, }); -async function importDataSourcesWithPolicy( +async function processImportQueue( dataSources: DataSource[], - policy: ImportPolicy, - restore: RestoreProcessors = appRestoreProcessors() -): Promise { - const cleanupHandlers: Array<() => void> = []; - const onCleanup = (fn: () => void) => { - cleanupHandlers.push(fn); - }; - const cleanup = () => { - while (cleanupHandlers.length) cleanupHandlers.pop()!(); - }; - - const importContext = { - fetchFileCache: new Map(), - onCleanup, - importDataSources: (sources: DataSource[]) => - importDataSourcesWithPolicy(sources, policy, restore), - }; - - // State files first: a manifest's top-level `segments` is also a config key. - const applicationHandlers = - policy === 'application' - ? [restore.restoreStateFile, handleConfig, handleRemoteManifest] - : []; - - const handlers = [ - handleCollections, - - openUriStream, - - // updating the file/uri type should be first step in the pipeline - updateFileMimeType, - updateUriType, - // before extractArchive as .zip extension is part of state file check - ...applicationHandlers, - handleGoogleCloudStorage, - handleAmazonS3, - - // stream handling - handleDicomStream, - downloadStream, - - extractArchive, - extractArchiveTarget, - // should be before importSingleFile, since DICOM is more specific - handleDicomFile, // collect DICOM files to import later - importSingleFile, - // catch any unhandled resource - unhandledResource, - ]; - + handlers: ImportHandler[], + importContext: ImportContext +) { const chunkSources: DataSource[] = []; const configResults: ConfigResult[] = []; const stateFileSetups: StateFileSetupResult[] = []; @@ -243,6 +197,64 @@ async function importDataSourcesWithPolicy( } } + return { chunkSources, configResults, stateFileSetups, results }; +} + +async function importDataSourcesWithPolicy( + dataSources: DataSource[], + policy: ImportPolicy, + restore: RestoreProcessors = appRestoreProcessors() +): Promise { + const cleanupHandlers: Array<() => void> = []; + const onCleanup = (fn: () => void) => { + cleanupHandlers.push(fn); + }; + const cleanup = () => { + while (cleanupHandlers.length) cleanupHandlers.pop()!(); + }; + + const importContext = { + fetchFileCache: new Map(), + onCleanup, + importDataSources: (sources: DataSource[]) => + importDataSourcesWithPolicy(sources, policy, restore), + }; + + // State files first: a manifest's top-level `segments` is also a config key. + const applicationHandlers = + policy === 'application' + ? [restore.restoreStateFile, handleConfig, handleRemoteManifest] + : []; + + const handlers = [ + handleCollections, + + openUriStream, + + // updating the file/uri type should be first step in the pipeline + updateFileMimeType, + updateUriType, + // before extractArchive as .zip extension is part of state file check + ...applicationHandlers, + handleGoogleCloudStorage, + handleAmazonS3, + + // stream handling + handleDicomStream, + downloadStream, + + extractArchive, + extractArchiveTarget, + // should be before importSingleFile, since DICOM is more specific + handleDicomFile, // collect DICOM files to import later + importSingleFile, + // catch any unhandled resource + unhandledResource, + ]; + + const { chunkSources, configResults, stateFileSetups, results } = + await processImportQueue(dataSources, handlers, importContext); + cleanup(); const dicomChunkSources = chunkSources.filter(