Skip to content

Commit 6b66a28

Browse files
authored
Implement bulk import (#1457)
* Implement bulk import * Name jobs based on dataset ID, instead of name * Fix test against pipeline run working directory name * Wrap text for dataset name and import path * Remove unnecessary eslint rule ignore * Display number of currently selected datasets in bulk import dialog * Handle bulk import with no results * Fix sporatic failures in bulk import Run imports sequentially, to prevent ffprobe from failing. ffprobe seems to fail if run with sufficiently many concurrent processes. * Split up import dialogs * Prevent v-data-table overflow on smaller screen sizes
1 parent 347cb5e commit 6b66a28

File tree

10 files changed

+353
-34
lines changed

10 files changed

+353
-34
lines changed

client/dive-common/apispec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ interface Api {
161161
saveAttributeTrackFilters(datasetId: string,
162162
args: SaveAttributeTrackFilterArgs): Promise<unknown>;
163163
// Non-Endpoint shared functions
164-
openFromDisk(datasetType: DatasetType | 'calibration' | 'annotation' | 'text' | 'zip', directory?: boolean):
164+
openFromDisk(datasetType: DatasetType | 'bulk' | 'calibration' | 'annotation' | 'text' | 'zip', directory?: boolean):
165165
Promise<{canceled?: boolean; filePaths: string[]; fileList?: File[]; root?: string}>;
166166
getTiles?(itemId: string, projection?: string): Promise<StringKeyObject>;
167167
// eslint-disable-next-line @typescript-eslint/no-explicit-any

client/dive-common/components/ImportButton.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export default defineComponent({
2020
required: true,
2121
},
2222
openType: {
23-
type: String as PropType<DatasetType | 'zip'>,
23+
type: String as PropType<DatasetType | 'zip' | 'bulk'>,
2424
required: true,
2525
},
2626
multiCamImport: { //TODO: Temporarily used to hide the stereo settings from users

client/platform/desktop/backend/ipcService.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ export default function register() {
7070
return defaults;
7171
});
7272

73+
ipcMain.handle('bulk-import-media', async (event, { path }: { path: string }) => {
74+
const results = await common.bulkMediaImport(path);
75+
return results;
76+
});
77+
7378
ipcMain.handle('import-media', async (event, { path }: { path: string }) => {
7479
const ret = await common.beginMediaImport(path);
7580
return ret;

client/platform/desktop/backend/native/common.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,7 @@ describe('native.common', () => {
448448
name: 'myproject1_name',
449449
createdAt: (new Date()).toString(),
450450
originalBasePath: '/foo/bar/baz',
451-
id: 'myproject1',
451+
id: 'myproject1_name_tktfgyv2g9',
452452
originalImageFiles: [],
453453
transcodedImageFiles: [],
454454
originalVideoFile: '',
@@ -461,7 +461,7 @@ describe('native.common', () => {
461461
const contents = fs.readdirSync(result);
462462
expect(stat.isDirectory()).toBe(true);
463463
expect(contents).toEqual([]);
464-
expect(result).toMatch(/DIVE_Jobs\/myproject1_name_mypipeline\.pipe_/);
464+
expect(result).toMatch(/DIVE_Jobs\/myproject1_name_tktfgyv2g9_mypipeline\.pipe_/);
465465
});
466466

467467
it('beginMediaImport image sequence success', async () => {

client/platform/desktop/backend/native/common.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,59 @@ async function findTrackandMetaFileinFolder(path: string) {
801801
return { trackFileAbsPath, metaFileAbsPath };
802802
}
803803

804+
/**
805+
* Attempt a media import on the provided path, which may or may not be a valid dataset.
806+
*/
807+
async function attemptMediaImport(path: string) {
808+
try {
809+
// Must await here, as otherwise the try/catch isn't correctly executed.
810+
return await beginMediaImport(path);
811+
} catch (e) {
812+
console.warn(
813+
`*** Failed to import at path "${path}", with message: "${(e as Error).message}".`
814+
+ ' This is expected if this file or directory does not contain a dataset.',
815+
);
816+
}
817+
818+
return undefined;
819+
}
820+
821+
/**
822+
* Recursively import all datasets in this directory, using a "breadth-first" approach.
823+
* This function only recurses into a directory if the import of that directory fails.
824+
*/
825+
async function bulkMediaImport(path: string): Promise<DesktopMediaImportResponse[]> {
826+
const children = await fs.readdir(path, { withFileTypes: true });
827+
const results: {path: fs.Dirent, result: DesktopMediaImportResponse | undefined}[] = [];
828+
829+
// Use a for-of loop, to run imports sequentially. If run concurrently, they can fail behind the scenes.
830+
// eslint-disable-next-line no-restricted-syntax
831+
for (const dirent of children) {
832+
// eslint-disable-next-line no-await-in-loop
833+
const result = await attemptMediaImport(npath.resolve(path, dirent.name));
834+
results.push({
835+
path: dirent,
836+
result,
837+
});
838+
}
839+
840+
// Filter successful imports
841+
const importResults = results.filter((r) => r.result !== undefined).map((r) => r.result as DesktopMediaImportResponse);
842+
843+
// If the result was undefined and was a directory, recurse.
844+
const toRecurse = results.filter((r) => r.result === undefined && r.path.isDirectory());
845+
846+
// Use a for-of loop, to run imports sequentially. If run concurrently, they can fail behind the scenes.
847+
// eslint-disable-next-line no-restricted-syntax
848+
for (const r of toRecurse) {
849+
// eslint-disable-next-line no-await-in-loop
850+
const results = await bulkMediaImport(npath.resolve(path, r.path.name));
851+
importResults.push(...results);
852+
}
853+
854+
return importResults;
855+
}
856+
804857
/**
805858
* Begin a dataset import.
806859
*/
@@ -1133,6 +1186,7 @@ export {
11331186
ProjectsFolderName,
11341187
JobsFolderName,
11351188
autodiscoverData,
1189+
bulkMediaImport,
11361190
beginMediaImport,
11371191
dataFileImport,
11381192
deleteDataset,

client/platform/desktop/backend/native/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ async function createWorkingDirectory(settings: Settings, jsonMetaList: JsonMeta
9191
const jobFolderPath = path.join(settings.dataPath, JobsFolderName);
9292
// eslint won't recognize \. as valid escape
9393
// eslint-disable-next-line no-useless-escape
94-
const safeDatasetName = jsonMetaList[0].name.replace(/[\.\s/]+/g, '_');
94+
const safeDatasetName = jsonMetaList[0].id.replace(/[\.\s/]+/g, '_');
9595
const runFolderName = moment().format(`[${safeDatasetName}_${pipeline}]_MM-DD-yy_hh-mm-ss.SSS`);
9696
const runFolderPath = path.join(jobFolderPath, runFolderName);
9797
if (!fs.existsSync(jobFolderPath)) {

client/platform/desktop/frontend/api.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import {
2626
* Native functions that run entirely in the renderer
2727
*/
2828

29-
async function openFromDisk(datasetType: DatasetType | 'calibration' | 'annotation' | 'text', directory = false) {
29+
async function openFromDisk(datasetType: DatasetType | 'bulk' | 'calibration' | 'annotation' | 'text', directory = false) {
3030
let filters: FileFilter[] = [];
3131
const allFiles = { name: 'All Files', extensions: ['*'] };
3232
if (datasetType === 'video') {
@@ -53,7 +53,7 @@ async function openFromDisk(datasetType: DatasetType | 'calibration' | 'annotati
5353
allFiles,
5454
];
5555
}
56-
const props = (datasetType === 'image-sequence' || directory) ? 'openDirectory' : 'openFile';
56+
const props = (['image-sequence', 'bulk'].includes(datasetType) || directory) ? 'openDirectory' : 'openFile';
5757
const results = await dialog.showOpenDialog({
5858
properties: [props],
5959
filters,
@@ -117,6 +117,10 @@ function importMedia(path: string): Promise<DesktopMediaImportResponse> {
117117
return ipcRenderer.invoke('import-media', { path });
118118
}
119119

120+
function bulkImportMedia(path: string): Promise<DesktopMediaImportResponse[]> {
121+
return ipcRenderer.invoke('bulk-import-media', { path });
122+
}
123+
120124
function deleteDataset(datasetId: string): Promise<boolean> {
121125
return ipcRenderer.invoke('delete-dataset', { datasetId });
122126
}
@@ -228,6 +232,7 @@ export {
228232
exportConfiguration,
229233
finalizeImport,
230234
importMedia,
235+
bulkImportMedia,
231236
deleteDataset,
232237
checkDataset,
233238
importAnnotationFile,
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
<script lang="ts">
2+
import {
3+
defineComponent, ref, PropType,
4+
computed,
5+
} from 'vue';
6+
import { DesktopMediaImportResponse } from 'platform/desktop/constants';
7+
import { clone, cloneDeep } from 'lodash';
8+
import ImportDialog from './ImportDialog.vue';
9+
10+
const headers = [
11+
{
12+
text: 'Dataset Name',
13+
align: 'start',
14+
sortable: false,
15+
value: 'name',
16+
},
17+
{
18+
text: 'Path',
19+
align: 'start',
20+
sortable: false,
21+
value: 'path',
22+
},
23+
{
24+
text: 'Dataset Type',
25+
align: 'start',
26+
sortable: false,
27+
value: 'jsonMeta.type',
28+
width: '150',
29+
},
30+
{
31+
text: 'Config',
32+
align: 'end',
33+
sortable: false,
34+
value: 'config',
35+
},
36+
];
37+
38+
export default defineComponent({
39+
name: 'BulkImportDialog',
40+
components: {
41+
ImportDialog,
42+
},
43+
props: {
44+
importData: {
45+
type: Array as PropType<DesktopMediaImportResponse[]>,
46+
required: true,
47+
},
48+
},
49+
setup(props, ctx) {
50+
// Map imports to include generated "id" field, used in rendering.
51+
const imports = ref(props.importData.map((im) => {
52+
const cloned = cloneDeep(im);
53+
cloned.jsonMeta.id = (Math.random() + 1).toString(36).substring(2);
54+
55+
return cloned;
56+
}));
57+
58+
// The dataset import currently being configured
59+
const currentImport = ref<DesktopMediaImportResponse>();
60+
61+
// Selected state and selected imports maintain the ordering of `imports`
62+
const selectedState = ref(imports.value.map(() => true));
63+
const selectedImports = computed(() => imports.value.filter((_, index) => selectedState.value[index]));
64+
function updateSelected(val: DesktopMediaImportResponse[]) {
65+
selectedState.value = imports.value.map((im) => val.includes(im));
66+
}
67+
68+
function stripId(item: DesktopMediaImportResponse) {
69+
const cloned = cloneDeep(item);
70+
cloned.jsonMeta.id = '';
71+
return item;
72+
}
73+
74+
function finalizeImport() {
75+
const finalImports = imports.value.filter((im) => selectedImports.value.includes(im)).map((im) => stripId(im));
76+
ctx.emit('finalize-import', finalImports);
77+
}
78+
79+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
80+
function updateImportConfig(oldItem: DesktopMediaImportResponse, newItem: DesktopMediaImportResponse) {
81+
const itemIndex = imports.value.indexOf(oldItem);
82+
83+
// Need to modify the imports array ref to contain the new item
84+
const newArray = clone(imports.value);
85+
newArray.splice(itemIndex, 1, newItem);
86+
imports.value = newArray;
87+
88+
currentImport.value = undefined;
89+
}
90+
91+
function formatPath(item: DesktopMediaImportResponse) {
92+
let path = item.jsonMeta.originalBasePath;
93+
94+
if (item.jsonMeta.originalVideoFile !== '') {
95+
path = `${path}/${item.jsonMeta.originalVideoFile}`;
96+
}
97+
98+
return path;
99+
}
100+
101+
return {
102+
updateSelected,
103+
updateImportConfig,
104+
formatPath,
105+
imports,
106+
headers,
107+
selectedImports,
108+
currentImport,
109+
finalizeImport,
110+
};
111+
},
112+
});
113+
</script>
114+
115+
<template>
116+
<v-card outlined class="import-card" style="overflow-x: hidden;">
117+
<v-card-title class="text-h5">
118+
Bulk Import (Selecting {{ selectedImports.length }} of {{ imports.length }})
119+
</v-card-title>
120+
121+
<v-dialog :value="currentImport !== undefined" width="800">
122+
<ImportDialog
123+
v-if="currentImport !== undefined"
124+
:import-data="currentImport"
125+
:embedded="true"
126+
@abort="currentImport = undefined"
127+
@finalize-import="updateImportConfig(currentImport, $event)"
128+
/>
129+
</v-dialog>
130+
131+
<v-data-table
132+
:value="selectedImports"
133+
:items="imports"
134+
item-key="jsonMeta.id"
135+
:headers="headers"
136+
show-select
137+
disable-sort
138+
@input="updateSelected"
139+
>
140+
<template #item.name="{ item }">
141+
<div class="text-wrap" style="word-break: break-word;">
142+
{{ item.jsonMeta.name }}
143+
</div>
144+
</template>
145+
146+
<template #item.path="{ item }">
147+
<div class="text-wrap" style="word-break: break-word;">
148+
{{ formatPath(item) }}
149+
</div>
150+
</template>
151+
152+
<template #item.config="{ item }">
153+
<v-btn
154+
icon
155+
:disabled="!selectedImports.includes(item)"
156+
@click="currentImport = item"
157+
>
158+
<v-icon>
159+
mdi-cog
160+
</v-icon>
161+
</v-btn>
162+
</template>
163+
</v-data-table>
164+
165+
<v-card-text>
166+
<div class="d-flex flex-row mt-4">
167+
<v-spacer />
168+
<v-btn
169+
text
170+
outlined
171+
class="mr-5"
172+
@click="$emit('abort')"
173+
>
174+
Cancel
175+
</v-btn>
176+
<v-btn
177+
color="primary"
178+
@click="finalizeImport"
179+
>
180+
Import Selected
181+
</v-btn>
182+
</div>
183+
</v-card-text>
184+
</v-card>
185+
</template>
186+
187+
<style lang="scss">
188+
@import 'dive-common/components/styles/KeyValueTable.scss';
189+
190+
.v-data-table__selected {
191+
background: unset !important;
192+
}
193+
</style>

client/platform/desktop/frontend/components/ImportDialog.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export default defineComponent({
2222
type: Boolean,
2323
default: false,
2424
},
25+
// If being embedded into the bulk import dialog
26+
embedded: {
27+
type: Boolean,
28+
default: false,
29+
},
2530
},
2631
setup(props) {
2732
const argCopy = ref(cloneDeep(props.importData));
@@ -321,7 +326,7 @@ export default defineComponent({
321326
:disabled="!ready || disabled"
322327
@click="$emit('finalize-import', argCopy)"
323328
>
324-
Finish Import
329+
{{ embedded ? "Save" : "Finish Import" }}
325330
</v-btn>
326331
</div>
327332
</v-card-text>

0 commit comments

Comments
 (0)