Skip to content

Commit 5b2b4b9

Browse files
authored
Merge pull request #860 from Kitware/segment-group-zip-names-slicer-io
Use segment group name as filename in volview.zip
2 parents 65d4507 + badead6 commit 5b2b4b9

17 files changed

Lines changed: 404 additions & 23 deletions

src/components/SaveSegmentGroupDialog.vue

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<v-form v-model="valid" @submit.prevent="saveSegmentGroup">
88
<v-text-field
99
v-model="fileName"
10-
hint="Filename that will appear in downloads."
10+
hint="Filename used for downloads."
1111
label="Filename"
1212
:rules="[validFileName]"
1313
required
@@ -37,12 +37,13 @@
3737
</template>
3838

3939
<script setup lang="ts">
40-
import { onMounted, ref } from 'vue';
40+
import { computed, onMounted, ref } from 'vue';
4141
import { onKeyDown } from '@vueuse/core';
4242
import { saveAs } from 'file-saver';
4343
import { useSegmentGroupStore } from '@/src/store/segmentGroups';
4444
import { writeSegmentation } from '@/src/io/readWriteImage';
4545
import { useErrorMessage } from '@/src/composables/useErrorMessage';
46+
import { sanitizeSegmentGroupFileStem } from '@/src/io/state-file/segmentGroupArchivePath';
4647
4748
const EXTENSIONS = [
4849
'seg.nrrd',
@@ -63,12 +64,18 @@ const props = defineProps<{
6364
6465
const emit = defineEmits(['done']);
6566
66-
const fileName = ref('');
67+
const fileNameValue = ref('');
6768
const valid = ref(true);
6869
const saving = ref(false);
6970
const fileFormat = ref(EXTENSIONS[0]);
7071
7172
const segmentGroupStore = useSegmentGroupStore();
73+
const fileName = computed({
74+
get: () => fileNameValue.value,
75+
set: (value: string) => {
76+
fileNameValue.value = sanitizeSegmentGroupFileStem(value, '');
77+
},
78+
});
7279
7380
async function saveSegmentGroup() {
7481
if (fileName.value.trim().length === 0) {
@@ -77,20 +84,24 @@ async function saveSegmentGroup() {
7784
7885
saving.value = true;
7986
await useErrorMessage('Failed to save segment group', async () => {
87+
const sanitizedFileName = sanitizeSegmentGroupFileStem(fileName.value);
88+
fileNameValue.value = sanitizedFileName;
8089
const serialized = await writeSegmentation(
8190
fileFormat.value,
8291
segmentGroupStore.dataIndex[props.id],
8392
segmentGroupStore.metadataByID[props.id]
8493
);
85-
saveAs(new Blob([serialized]), `${fileName.value}.${fileFormat.value}`);
94+
saveAs(new Blob([serialized]), `${sanitizedFileName}.${fileFormat.value}`);
8695
});
8796
saving.value = false;
8897
emit('done');
8998
}
9099
91100
onMounted(() => {
92101
// trigger form validation check so can immediately save with default value
93-
fileName.value = segmentGroupStore.metadataByID[props.id].name;
102+
fileNameValue.value = sanitizeSegmentGroupFileStem(
103+
segmentGroupStore.metadataByID[props.id].name
104+
);
94105
});
95106
96107
onKeyDown('Enter', () => {

src/components/SegmentGroupControls.vue

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ function deleteSelected() {
335335
</v-tooltip>
336336
</v-btn>
337337
<v-btn
338+
data-testid="segment-group-save-button"
338339
icon="mdi-content-save"
339340
size="small"
340341
variant="text"
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { shouldIgnoreKeyboardShortcuts } from '../useKeyboardShortcuts';
3+
4+
describe('shouldIgnoreKeyboardShortcuts', () => {
5+
it('ignores shortcuts while an input is focused', () => {
6+
const input = document.createElement('input');
7+
expect(shouldIgnoreKeyboardShortcuts(input)).toBe(true);
8+
});
9+
10+
it('ignores shortcuts while a textarea is focused', () => {
11+
const textarea = document.createElement('textarea');
12+
expect(shouldIgnoreKeyboardShortcuts(textarea)).toBe(true);
13+
});
14+
15+
it('ignores shortcuts while a contenteditable element is focused', () => {
16+
const editable = document.createElement('div');
17+
editable.contentEditable = 'true';
18+
expect(shouldIgnoreKeyboardShortcuts(editable)).toBe(true);
19+
});
20+
21+
it('does not ignore shortcuts for non-editable controls', () => {
22+
const button = document.createElement('button');
23+
expect(shouldIgnoreKeyboardShortcuts(button)).toBe(false);
24+
});
25+
});

src/composables/useKeyboardShortcuts.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ import { ACTION_TO_FUNC } from './actions';
77

88
export const actionToKey = ref(ACTION_TO_KEY);
99

10+
export function shouldIgnoreKeyboardShortcuts(
11+
activeElement: Element | null = document.activeElement
12+
) {
13+
if (!(activeElement instanceof HTMLElement)) {
14+
return false;
15+
}
16+
17+
return (
18+
activeElement.isContentEditable ||
19+
activeElement.closest('input, textarea, select, [role="textbox"]') !== null
20+
);
21+
}
22+
1023
export function useKeyboardShortcuts() {
1124
const keys = useMagicKeys();
1225
let unwatchFuncs = [] as Array<ReturnType<typeof whenever>>;
@@ -21,6 +34,10 @@ export function useKeyboardShortcuts() {
2134
const lastKey = individualKeys[individualKeys.length - 1];
2235

2336
return whenever(keys[key], () => {
37+
if (shouldIgnoreKeyboardShortcuts()) {
38+
return;
39+
}
40+
2441
const shiftPressed = keys.current.has('shift');
2542
const lastPressedKey = Array.from(keys.current).pop();
2643
const currentKeyWithCase = shiftPressed

src/io/__tests__/fileName.spec.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { DEFAULT_FILE_STEM, sanitizeFileStem } from '@/src/io/fileName';
2+
import { describe, expect, it } from 'vitest';
3+
4+
describe('io/fileName', () => {
5+
describe('sanitizeFileStem', () => {
6+
it('replaces invalid filename characters with readable spacing', () => {
7+
expect(sanitizeFileStem('Liver: left/right*?')).to.equal(
8+
'Liver left right'
9+
);
10+
});
11+
12+
it('collapses repeated whitespace and trims trailing dots and spaces', () => {
13+
expect(sanitizeFileStem(' Liver left. ')).to.equal('Liver left');
14+
});
15+
16+
it('handles reserved Windows filenames', () => {
17+
expect(sanitizeFileStem('CON')).to.equal('CON_');
18+
});
19+
20+
it('falls back when the sanitized stem would be empty', () => {
21+
expect(sanitizeFileStem(' ..../\\\\**** ')).to.equal(
22+
DEFAULT_FILE_STEM
23+
);
24+
});
25+
26+
it('preserves already-valid names', () => {
27+
expect(sanitizeFileStem('Prostate Segmentation')).to.equal(
28+
'Prostate Segmentation'
29+
);
30+
});
31+
});
32+
});

src/io/dicom.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,23 @@ async function runTask(
4747
inputs: any[],
4848
outputs: any[]
4949
) {
50-
return runPipeline(module, args, outputs, inputs, {
50+
const result = await runPipeline(module, args, outputs, inputs, {
5151
webWorker: getWorker(),
5252
pipelineBaseUrl: itkConfig.pipelinesUrl,
5353
pipelineWorkerUrl: itkConfig.pipelineWorkerUrl,
54+
}).catch((error) => {
55+
throw new Error(
56+
`itk-wasm pipeline "${module}" crashed (check browser console for details)`,
57+
{ cause: error }
58+
);
5459
});
60+
if (result.returnValue !== 0) {
61+
const detail = result.stderr?.trim() || 'unknown error';
62+
throw new Error(
63+
`itk-wasm pipeline "${module}" exited with code ${result.returnValue}: ${detail}`
64+
);
65+
}
66+
return result;
5567
}
5668

5769
/**

src/io/fileName.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
const TRAILING_DOTS_AND_SPACES = /[. ]+$/g;
2+
const REPEATED_WHITESPACE = /\s+/g;
3+
const WINDOWS_RESERVED_FILE_NAME = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i;
4+
const INVALID_FILE_STEM_CHARS = new Set([
5+
'<',
6+
'>',
7+
':',
8+
'"',
9+
'/',
10+
'\\',
11+
'|',
12+
'?',
13+
'*',
14+
]);
15+
16+
export const DEFAULT_FILE_STEM = 'File';
17+
18+
export function sanitizeFileStem(name: string, fallback = DEFAULT_FILE_STEM) {
19+
let sanitized = name
20+
.split('')
21+
.map((char) => {
22+
const isControlCharacter = char.charCodeAt(0) < 32;
23+
return isControlCharacter || INVALID_FILE_STEM_CHARS.has(char)
24+
? ' '
25+
: char;
26+
})
27+
.join('')
28+
.replace(REPEATED_WHITESPACE, ' ')
29+
.trim()
30+
.replace(TRAILING_DOTS_AND_SPACES, '');
31+
32+
if (WINDOWS_RESERVED_FILE_NAME.test(sanitized)) {
33+
sanitized = `${sanitized}_`;
34+
}
35+
36+
return sanitized || fallback;
37+
}

src/io/import/processors/handleDicomFile.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ReadDicomTagsFunction } from '@/src/core/streaming/dicom/dicomMetaLoade
66
import { ImportHandler, asIntermediateResult } from '@/src/io/import/common';
77
import { getWorker } from '@/src/io/itk/worker';
88
import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes';
9+
import { getErrorDetail } from '@/src/utils';
910
import { readDicomTags } from '@itk-wasm/dicom';
1011

1112
/**
@@ -22,8 +23,18 @@ const handleDicomFile: ImportHandler = async (dataSource) => {
2223
}
2324

2425
const readTags: ReadDicomTagsFunction = async (file) => {
25-
const result = await readDicomTags(file, { webWorker: getWorker() });
26-
return result.tags;
26+
try {
27+
const result = await readDicomTags(file, { webWorker: getWorker() });
28+
return result.tags;
29+
} catch (error) {
30+
const detail = getErrorDetail(
31+
error,
32+
'the file could not be parsed as valid DICOM (check browser console for details)'
33+
);
34+
throw new Error(`Failed to read DICOM tags: ${detail}`, {
35+
cause: error,
36+
});
37+
}
2738
};
2839

2940
const metaLoader = new DicomFileMetaLoader(dataSource.file, readTags);

src/io/import/processors/handleDicomStream.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from '@/src/io/import/common';
1515
import { getWorker } from '@/src/io/itk/worker';
1616
import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes';
17+
import { getErrorDetail } from '@/src/utils';
1718
import { readDicomTags } from '@itk-wasm/dicom';
1819
import { Tags } from '@/src/core/dicomTags';
1920
import { useMessageStore } from '@/src/store/messages';
@@ -34,8 +35,13 @@ const handleDicomStream: ImportHandler = async (dataSource) => {
3435
const result = await readDicomTags(file, { webWorker: getWorker() });
3536
return result.tags;
3637
} catch (error) {
38+
const detail = getErrorDetail(
39+
error,
40+
'the file could not be parsed as valid DICOM (check browser console for details)'
41+
);
3742
throw new Error(
38-
`Failed to read DICOM tags from ${dataSource.uri}: ${error}`
43+
`Failed to read DICOM tags from ${dataSource.uri}: ${detail}`,
44+
{ cause: error }
3945
);
4046
}
4147
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { makeSegmentGroupArchivePath } from '@/src/io/state-file/segmentGroupArchivePath';
2+
import { describe, expect, it } from 'vitest';
3+
4+
describe('io/state-file/segmentGroupArchivePath', () => {
5+
describe('makeSegmentGroupArchivePath', () => {
6+
it('uses a sanitized segment group stem in the archive path', () => {
7+
const usedPaths = new Set<string>();
8+
9+
expect(
10+
makeSegmentGroupArchivePath('Liver: left/right*?', 'vti', usedPaths)
11+
).to.equal('segmentations/Liver left right.vti');
12+
});
13+
14+
it('deduplicates colliding sanitized names case-insensitively', () => {
15+
const usedPaths = new Set<string>();
16+
17+
expect(
18+
makeSegmentGroupArchivePath('Liver/Left', 'vti', usedPaths)
19+
).to.equal('segmentations/Liver Left.vti');
20+
expect(
21+
makeSegmentGroupArchivePath('liver:left', 'vti', usedPaths)
22+
).to.equal('segmentations/liver left (2).vti');
23+
});
24+
});
25+
});

0 commit comments

Comments
 (0)