Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/actions/loadUserFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -304,6 +306,19 @@ function loadDataSourcesWithOutcome(
sources: DataSource[],
importer: DataSourceImporter
): Promise<LoadDataSourcesOutcome> {
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();

Expand Down
117 changes: 65 additions & 52 deletions src/io/import/importDataSources.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ImportContext,
ImportHandler,
ImportResult,
asErrorResult,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();

Expand Down
13 changes: 13 additions & 0 deletions src/io/import/shouldSkipImportSource.ts
Original file line number Diff line number Diff line change
@@ -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)));
3 changes: 2 additions & 1 deletion src/io/zip.ts
Original file line number Diff line number Diff line change
@@ -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<FileEntry[]> {
Expand All @@ -8,7 +9,7 @@ export async function extractFilesFromZip(zipFile: File): Promise<FileEntry[]> {
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) {
Expand Down
55 changes: 55 additions & 0 deletions tests/specs/metadata-imports.e2e.ts
Original file line number Diff line number Diff line change
@@ -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']);
});
});
Loading