Skip to content
Snippets Groups Projects
DownloadObjectsModal.vue 13.1 KiB
Newer Older
<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 !=
          objectRepository.objectMetaMapping[identifier]?.ContentLength <
            PART_SIZE
        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>();
  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);
      downloadModal?.hide();
      successToast?.show();
      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
      // @ts-ignore
    } catch (caught: Error) {
      downloadState.errorFiles.add(file.fileName);
      await writeStream.truncate(0);
      if (caught.name === "AbortError") {
        break;
      }
    } finally {
      downloadState.doneFiles++;
      await writeStream.close();
    }
  }
  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);
});
  <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()"
      >
      </button>
      <button
        v-else
        type="button"
        class="btn btn-danger"
        @click="abortDownload"
      >
        Cancel
      </button>
    </template>
  </bootstrap-modal>
</template>

<style scoped></style>