<script setup lang="ts"> import BootstrapModal from "@/components/modals/BootstrapModal.vue"; import { useS3ObjectStore } from "@/stores"; import { GetObjectCommand } from "@aws-sdk/client-s3"; import { computed, onMounted, reactive, watch } from "vue"; import { filesize } from "filesize"; import FontAwesomeIcon from "@/components/FontAwesomeIcon.vue"; import BootstrapToast from "@/components/BootstrapToast.vue"; import { Modal, Toast } from "bootstrap"; const props = defineProps<{ modalId: string; bucketName: string; keys: string[]; ignorePrefix?: string; }>(); const downloadState = reactive<{ downloading: boolean; doneFiles: number; totalFiles: number; fileSize: number; downloadedBytes: number; currentFile: string; folder: string; controller?: AbortController; downloadedFiles: Set<string>; errorFiles: Set<string>; }>({ downloading: false, doneFiles: 0, totalFiles: 0, fileSize: 0, downloadedBytes: 0, currentFile: "", folder: "", controller: undefined, downloadedFiles: new Set<string>(), errorFiles: new Set<string>(), }); type DownloadFile = { fullKey: string; fileName: string; }; const objKeys = computed<DownloadFile[]>(() => { return props.keys .map((key) => { if (key.endsWith("/")) { return objectRepository.objectMapping[props.bucketName] .filter((obj) => obj.Key?.startsWith(key)) .map((obj) => obj.Key ?? "") .filter((obj) => obj.length > 0) .filter((obj) => !obj.endsWith("/")) .map((obj) => { return { fullKey: obj, fileName: trimPrefix(obj), }; }); } return { fullKey: key, fileName: trimPrefix(key), }; }) .flat(); }); watch( () => props.keys, () => { if (!downloadState.downloading) { downloadState.downloading = false; downloadState.doneFiles = 0; downloadState.totalFiles = 0; downloadState.fileSize = 0; downloadState.downloadedBytes = 0; downloadState.currentFile = ""; downloadState.folder = ""; downloadState.controller = undefined; downloadState.downloadedFiles = new Set<string>(); downloadState.errorFiles = new Set<string>(); } }, ); interface Range { start: number; end: number; } interface RangeLength extends Range { length: number; } const objectRepository = useS3ObjectStore(); const PART_SIZE = 10 * 1024 * 1024; const randomIDSuffix = Math.random().toString(16).substring(2, 8); let downloadModal: Modal | null = null; let successToast: Toast | null = null; function getObjectRange( bucket: string, key: string, range?: Range, abortController?: AbortController, ) { const command = new GetObjectCommand({ Bucket: bucket, Key: key, Range: range != undefined ? `bytes=${range.start}-${range.end}` : undefined, }); return objectRepository.client.send(command, { abortSignal: abortController?.signal, }); } /** * @param {string | undefined} contentRange */ function getRangeAndLength(contentRange: string) { const [, numbers] = contentRange.split(" "); const [range, length] = numbers.split("/"); const [start, end] = range.split("-"); return { start: Number.parseInt(start), end: Number.parseInt(end), length: Number.parseInt(length), }; } function isComplete(range: RangeLength) { return range.end === range.length - 1; } async function downloadInChunks( bucket: string, key: string, handle: FileSystemWritableFileStream, abortController?: AbortController, ) { let rangeAndLength: RangeLength = { start: -1, end: -1, length: -1 }; downloadState.fileSize = 0; downloadState.downloadedBytes = 0; await objectRepository.fetchS3ObjectMeta(bucket, key); while (!isComplete(rangeAndLength)) { const nextRange: Range = { start: rangeAndLength.end + 1, end: rangeAndLength.end + PART_SIZE, }; if (rangeAndLength.length > 0) { downloadState.fileSize = rangeAndLength.length; nextRange.end = Math.min( rangeAndLength.length - 1, rangeAndLength.end + PART_SIZE, ); } try { const identifier = objectRepository.metaKey(bucket, key); // Skip download of files that have no content if (objectRepository.objectMetaMapping[identifier]?.ContentLength === 0) { return; } const response = await getObjectRange( bucket, key, objectRepository.objectMetaMapping[identifier]?.ContentLength != undefined && objectRepository.objectMetaMapping[identifier]?.ContentLength < PART_SIZE ? undefined : nextRange, abortController, ); if (response.Body != undefined) { await handle.write(await response.Body.transformToByteArray()); downloadState.downloadedBytes += PART_SIZE; if (response.ContentRange == undefined) { break; } rangeAndLength = getRangeAndLength(response.ContentRange); } else { break; } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore } catch (err: Error) { if (err.name === "InvalidRange") { break; } throw err; } } } async function downloadFiles() { let dirHandle: FileSystemDirectoryHandle; try { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore dirHandle = await window.showDirectoryPicker(); } catch { return; } downloadState.folder = dirHandle.name; downloadState.downloading = true; downloadState.totalFiles = objKeys.value.length; downloadState.doneFiles = 0; downloadState.controller = new AbortController(); downloadState.downloadedFiles = new Set<string>(); downloadState.errorFiles = new Set<string>(); let downloadError = false; outer: for (const file of objKeys.value) { let subHandle = dirHandle; const subFolders = file.fileName.split("/"); if (subFolders[subFolders.length - 1].length === 0) { continue; } downloadState.currentFile = file.fileName; for (const folder of subFolders.slice(0, subFolders.length - 1)) { try { subHandle = await subHandle.getDirectoryHandle(folder, { create: true, }); } catch { continue outer; } } const fileHandle = await subHandle.getFileHandle( subFolders[subFolders.length - 1], { create: true, }, ); const writeStream = await fileHandle.createWritable({ keepExistingData: false, }); try { await downloadInChunks( props.bucketName, file.fullKey, writeStream, downloadState.controller, ); downloadState.downloadedFiles.add(file.fileName); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore } catch (caught: Error) { downloadError = true; downloadState.errorFiles.add(file.fileName); await writeStream.truncate(0); if (caught.name === "AbortError") { break; } } finally { downloadState.doneFiles++; await writeStream.close(); } } if (!downloadError) { successToast?.show(); downloadModal?.hide(); } downloadState.controller = undefined; downloadState.downloading = false; } function trimPrefix(key: string): string { if (props.ignorePrefix != undefined && key.startsWith(props.ignorePrefix)) { return key.slice(props.ignorePrefix.length); } else { return key; } } function determineIcon(key: string): string { if (downloadState.errorFiles.has(key)) { return "circle-xmark"; } if (downloadState.downloadedFiles.has(key)) { return "circle-check"; } if (downloadState.currentFile === key) { return "circle-down"; } return "circle-pause"; } function determineColor(key: string): string { if (downloadState.errorFiles.has(key)) { return "text-danger"; } if (downloadState.downloadedFiles.has(key)) { return "text-success"; } if (downloadState.currentFile === key) { return "text-info"; } return "text-warning"; } function abortDownload() { downloadState.controller?.abort(); } // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore const enableDownload = typeof window.showDirectoryPicker === "function"; onMounted(() => { downloadModal = Modal.getOrCreateInstance("#" + props.modalId); successToast = new Toast("#successToast-" + randomIDSuffix); }); </script> <template> <bootstrap-toast :toast-id="'successToast-' + randomIDSuffix"> Successfully downloaded files </bootstrap-toast> <bootstrap-modal :modal-id="modalId" modal-label="Download Objects" static-backdrop size-modifier-modal="xl" v-on="{ 'hidden.bs.modal': abortDownload }" > <template #header>Download files</template> <template #body> <div class="row"> <h4>Files to download</h4> <div class="col overflow-auto" style="max-height: 70vh"> <div v-for="key in objKeys" :key="key.fullKey"> <font-awesome-icon :icon="`fa-solid fa-${determineIcon(key.fileName)}`" :class="determineColor(key.fileName)" /> {{ key.fileName }}<br /> <span v-if=" objectRepository.objectMetaMapping[ objectRepository.metaKey(bucketName, key.fullKey) ]?.ContentLength != undefined " >{{ filesize( objectRepository.objectMetaMapping[ objectRepository.metaKey(bucketName, key.fullKey) ]?.ContentLength ?? 0, ) }}</span > </div> </div> <div v-if="!enableDownload" class="col"> <p> Your browser doesn't support selecting a folder to download the files into. Look <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/API/Window/showDirectoryPicker#browser_compatibility" >here</a > for a compatibility table </p> </div> <div v-if="downloadState.downloading" class="col-4"> <p class="text-warning"> <font-awesome-icon icon="fa-solid fa-triangle-exclamation" class="me-2" /> Do not close the modal during the download </p> <p>Download into folder {{ downloadState.folder }}</p> <div v-if="downloadState.totalFiles > 1" class="progress mt-2" role="progressbar" aria-label="Example with label" :aria-valuenow=" Math.ceil( (downloadState.doneFiles * 100) / downloadState.totalFiles, ) " aria-valuemin="0" aria-valuemax="100" > <div class="progress-bar" :style="{ width: `${(downloadState.doneFiles * 100) / downloadState.totalFiles}%`, }" > {{ downloadState.doneFiles }} / {{ downloadState.totalFiles }} </div> </div> <div v-if="downloadState.fileSize > 0"> <div class="mt-2"> {{ filesize( Math.min( downloadState.downloadedBytes, downloadState.fileSize, ), ) }}/{{ filesize(downloadState.fileSize) }} </div> <div class="progress mt-2" role="progressbar" aria-label="Example with label" :aria-valuenow=" Math.min( Math.ceil( (downloadState.downloadedBytes * 100) / downloadState.fileSize, ), 100, ) " aria-valuemin="0" aria-valuemax="100" > <div class="progress-bar progress-bar-striped progress-bar-animated bg-success" :style="{ width: `${Math.min((downloadState.downloadedBytes * 100) / downloadState.fileSize, 100)}%`, }" > {{ Math.min( Math.ceil( (downloadState.downloadedBytes * 100) / downloadState.fileSize, ), 100, ) }}% </div> </div> </div> </div> </div> </template> <template #footer> <button v-if="!downloadState.downloading" type="button" class="btn btn-secondary" data-bs-dismiss="modal" > Close </button> <button v-if="!downloadState.downloading" type="button" class="btn btn-primary" :disabled="!enableDownload" @click="downloadFiles()" > Download </button> <button v-else type="button" class="btn btn-danger" @click="abortDownload" > Cancel </button> </template> </bootstrap-modal> </template> <style scoped></style>