Skip to content

Commit ac3b740

Browse files
committed
feat(segmentGroups): use segment group name as filname
When zipping up session.volview.zip files.
1 parent 1f7e99b commit ac3b740

12 files changed

Lines changed: 357 additions & 15 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. Invalid filename characters are replaced automatically."
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/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+
}
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('labels/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('labels/Liver Left.vti');
20+
expect(
21+
makeSegmentGroupArchivePath('liver:left', 'vti', usedPaths)
22+
).to.equal('labels/liver left (2).vti');
23+
});
24+
});
25+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { normalize } from '@/src/utils/path';
2+
import { sanitizeFileStem } from '@/src/io/fileName';
3+
4+
export const DEFAULT_SEGMENT_GROUP_ARCHIVE_STEM = 'Segment Group';
5+
6+
export function sanitizeSegmentGroupFileStem(
7+
name: string,
8+
fallback = DEFAULT_SEGMENT_GROUP_ARCHIVE_STEM
9+
) {
10+
return sanitizeFileStem(name, fallback);
11+
}
12+
13+
function makeArchivePathKey(path: string) {
14+
return normalize(path).toLowerCase();
15+
}
16+
17+
export function makeSegmentGroupArchivePath(
18+
name: string,
19+
extension: string,
20+
usedPaths: Set<string>
21+
) {
22+
const stem = sanitizeSegmentGroupFileStem(name);
23+
24+
let index = 1;
25+
let path = normalize(`labels/${stem}.${extension}`);
26+
while (usedPaths.has(makeArchivePathKey(path))) {
27+
index += 1;
28+
path = normalize(`labels/${stem} (${index}).${extension}`);
29+
}
30+
31+
usedPaths.add(makeArchivePathKey(path));
32+
return path;
33+
}

src/store/segmentGroups.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
SegmentGroupMetadata,
2828
SegmentGroup,
2929
} from '../io/state-file/schema';
30+
import { makeSegmentGroupArchivePath } from '../io/state-file/segmentGroupArchivePath';
3031
import { FileEntry } from '../io/types';
3132
import { ensureSameSpace } from '../io/resample/resample';
3233
import { untilLoaded } from '../composables/untilLoaded';
@@ -459,6 +460,7 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => {
459460
*/
460461
async function serialize(state: StateFile) {
461462
const { zip } = state;
463+
const usedArchivePaths = new Set<string>();
462464

463465
// orderByParent is implicitly preserved based on
464466
// the order of serialized entries.
@@ -470,7 +472,11 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => {
470472
const metadata = metadataByID[id];
471473
return {
472474
id,
473-
path: `labels/${id}.${saveFormat.value}`,
475+
path: makeSegmentGroupArchivePath(
476+
metadata.name,
477+
saveFormat.value,
478+
usedArchivePaths
479+
),
474480
metadata: {
475481
...metadata,
476482
parentImage: metadata.parentImage,

tests/pageobjects/volview.page.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,14 @@ export const setValueVueInput = async (
1414
input: ChainablePromiseElement,
1515
value: string
1616
) => {
17-
// input.setValue does not clear existing input, so click and backspace
17+
// input.setValue does not clear existing input, so select all and replace.
1818
await input.click();
1919
const oldValue = await input.getValue();
2020
if (oldValue) {
21-
const backspaces = new Array(oldValue.length).fill(Key.Backspace);
22-
await browser.keys([Key.ArrowRight, ...backspaces]);
21+
const selectAllModifier =
22+
process.platform === 'darwin' ? 'Meta' : 'Control';
23+
await browser.keys([selectAllModifier, 'a']);
24+
await browser.keys(Key.Backspace);
2325
}
2426
await input.setValue(value);
2527
};
@@ -149,6 +151,22 @@ class VolViewPage extends Page {
149151
return $('button span i[class~=mdi-content-save-all]');
150152
}
151153

154+
get annotationsModuleTab() {
155+
return $('button[data-testid="module-tab-Annotations"]');
156+
}
157+
158+
get newSegmentGroupButton() {
159+
return $('button*=New Group');
160+
}
161+
162+
get activeDialog() {
163+
return $('div[role="dialog"]');
164+
}
165+
166+
get activeDialogInput() {
167+
return this.activeDialog.$('input[placeholder="Unnamed Segment Group"]');
168+
}
169+
152170
get saveSessionFilenameInput() {
153171
return $('#session-state-filename');
154172
}
@@ -157,6 +175,33 @@ class VolViewPage extends Page {
157175
return $('span[data-testid="save-session-confirm-button"]');
158176
}
159177

178+
get segmentGroupsTab() {
179+
return $('button.v-tab*=Segment Groups');
180+
}
181+
182+
get segmentGroupSaveButtons() {
183+
return $$('button[data-testid="segment-group-save-button"]');
184+
}
185+
186+
get saveSegmentGroupFilenameInput() {
187+
return this.activeDialog.$('#filename');
188+
}
189+
190+
get saveSegmentGroupConfirmButton() {
191+
return this.activeDialog.$('button=Save');
192+
}
193+
194+
async clickFirstSegmentGroupSaveButton() {
195+
await browser.waitUntil(async () => {
196+
const buttons = await this.segmentGroupSaveButtons;
197+
return (await buttons.length) >= 1;
198+
});
199+
const buttons = await this.segmentGroupSaveButtons;
200+
await buttons[0].scrollIntoView();
201+
await buttons[0].waitForClickable();
202+
await buttons[0].click();
203+
}
204+
160205
async saveSession() {
161206
const save = this.saveButton;
162207
await save.click();
@@ -177,6 +222,20 @@ class VolViewPage extends Page {
177222
return fileName;
178223
}
179224

225+
async createSegmentGroup(name: string) {
226+
const annotationsTab = await this.annotationsModuleTab;
227+
await annotationsTab.click();
228+
229+
const newGroup = await this.newSegmentGroupButton;
230+
await newGroup.waitForClickable();
231+
await newGroup.click();
232+
233+
const input = await this.activeDialogInput;
234+
await input.waitForDisplayed();
235+
await setValueVueInput(input, name);
236+
await browser.keys([Key.Enter]);
237+
}
238+
180239
get editLabelButtons() {
181240
return $$('button[data-testid="edit-label-button"]');
182241
}

0 commit comments

Comments
 (0)