Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 85 additions & 5 deletions app/components/DragAndDrop.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { computed, onMounted, onUnmounted, ref } from "vue";
import DragAndDropInline from "./DragAndDropInternal/DragAndDropInline.vue";
import DragAndDropOverlay from "./DragAndDropInternal/DragAndDropOverlay.vue";
import type { UploadFile } from "@ogw_front/utils/upload_path";

interface DragAndDropTexts {
idle: string;
Expand All @@ -18,6 +19,7 @@ interface Props {
inline?: boolean;
showOverlay?: boolean;
texts?: DragAndDropTexts;
directory?: boolean;
}

const {
Expand All @@ -33,10 +35,20 @@ const {
drop: "Drop files here",
loading: "Loading...",
},
directory = false,
// oxlint-disable-next-line vue/max-props
} = defineProps<Props>();

const displayed_texts = computed<DragAndDropTexts>(() =>
directory
? { ...texts, idle: "Click or drag and drop a folder", drop: "Drop the folder here" }
: texts,
);

const emit = defineEmits<{
"files-selected": [files: File[]];
"files-selected": [files: UploadFile[]];
"folders-ignored": [];
"folder-read-error": [message: string];
}>();

const isDragging = ref(false);
Expand Down Expand Up @@ -115,10 +127,70 @@ function onDragOver(event: DragEvent): void {
}
}

async function readAllEntries(reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> {
// oxlint-disable-next-line promise/avoid-new
const batch = await new Promise<FileSystemEntry[]>((resolve, reject) => {
reader.readEntries(resolve, reject);
});
if (batch.length === 0) {
return [];
}
return [...batch, ...(await readAllEntries(reader))];
}

async function filesFromEntry(entry: FileSystemEntry, path: string): Promise<UploadFile[]> {
if (entry.isFile) {
// oxlint-disable-next-line promise/avoid-new
const file = await new Promise<File>((resolve, reject) => {
// oxlint-disable-next-line no-unsafe-type-assertion -- isFile guarantees a FileSystemFileEntry
(entry as FileSystemFileEntry).file(resolve, reject);
});
return [Object.assign(file, { relativePath: path })];
}
// oxlint-disable-next-line no-unsafe-type-assertion -- not a file, so a FileSystemDirectoryEntry
const children = await readAllEntries((entry as FileSystemDirectoryEntry).createReader());
const nested = await Promise.all(
children.map((child) => filesFromEntry(child, `${path}/${child.name}`)),
);
return nested.flat();
}

async function emitDroppedEntries(entries: FileSystemEntry[]): Promise<void> {
try {
const groups = await Promise.all(
entries.map(async (entry) => {
const files = await filesFromEntry(entry, entry.name);
// Files inside a dropped folder are all needed: never filtered out.
return entry.isDirectory ? files : files.filter((file) => isFileAccepted(file, accept));
}),
);
const files = groups.flat();
if (files.length > 0) {
emit("files-selected", files);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
emit("folder-read-error", message);
}
}

function onDrop(event: DragEvent): void {
event.preventDefault();
dragCounter.value = 0;
isDragging.value = false;
// Entries must be read synchronously: the DataTransfer is emptied once the event returns.
const entries = [...(event.dataTransfer?.items ?? [])]
.map((item) => item.webkitGetAsEntry?.())
.filter((entry): entry is FileSystemEntry => entry !== null && entry !== undefined);
if (entries.some((entry) => entry.isDirectory)) {
if (directory) {
void emitDroppedEntries(entries);
return;
}
emit("folders-ignored");
void emitDroppedEntries(entries.filter((entry) => entry.isFile));
return;
}
const files = [...(event.dataTransfer?.files ?? [])].filter((file) =>
isFileAccepted(file, accept),
);
Expand Down Expand Up @@ -181,7 +253,7 @@ defineExpose({ triggerFileDialog });
v-if="inline"
:is-dragging
:loading
:texts
:texts="displayed_texts"
:accept
:show-extensions
@click="triggerFileDialog"
Expand All @@ -193,13 +265,21 @@ defineExpose({ triggerFileDialog });
:show-overlay
:fullscreen
:loading
:texts
:texts="displayed_texts"
:multiple
:accept
:show-extensions
/>

<input ref="fileInput" type="file" class="d-none" :multiple :accept @change="handleFileSelect" />
<input
ref="fileInput"
type="file"
class="d-none"
:multiple
:accept
:webkitdirectory="directory"
@change="handleFileSelect"
/>
</template>

<style>
Expand Down
24 changes: 20 additions & 4 deletions app/components/FileSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useBackStore } from "@ogw_front/stores/back";
const schema = schemas.opengeodeweb_back.allowed_files;

interface Emits {
update_values: [values: { files: unknown[]; autoUpload: boolean }];
update_values: [values: { files: unknown[]; autoUpload: boolean; time_series?: string[] }];
increment_step: [];
decrement_step: [];
}
Expand All @@ -20,13 +20,21 @@ interface Props {
files?: File[];
autoUpload?: boolean;
showOverlay?: boolean;
timeSeries?: boolean;
}

const { multiple, files = [], autoUpload = true, showOverlay = true } = defineProps<Props>();
const {
multiple,
files = [],
autoUpload = true,
showOverlay = true,
timeSeries = false,
} = defineProps<Props>();

const internal_files = ref<File[]>(files);
const internal_auto_upload = ref<boolean>(autoUpload);
const accept = ref<string>("");
const time_series = ref<string[]>([]);
const loading = ref<boolean>(false);

watch(
Expand All @@ -47,7 +55,12 @@ const toggle_loading = useToggle(loading);

function files_uploaded_event(value: unknown[]): void {
if (value.length > 0) {
emit("update_values", { files: value, autoUpload: false });
emit(
"update_values",
timeSeries
? { files: value, autoUpload: false, time_series: time_series.value }
: { files: value, autoUpload: false },
);
emit("increment_step");
}
}
Expand All @@ -56,7 +69,10 @@ async function get_allowed_files(): Promise<void> {
toggle_loading();
const backStore = useBackStore();
const response = await backStore.request({ schema });
accept.value = response.extensions.map((extension) => `.${extension}`).join(",");
time_series.value = timeSeries ? response.time_series_extensions : [];
accept.value = [...response.data_extensions, ...time_series.value]
.map((extension) => `.${extension}`)
.join(",");
toggle_loading();
}

Expand Down
8 changes: 8 additions & 0 deletions app/components/FileUploader.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<script setup lang="ts">
import { foldersIgnoredWarning } from "@ogw_front/utils/upload_path";
import { useBackStore } from "@ogw_front/stores/back";
import { useFeedbackStore } from "@ogw_front/stores/feedback";

import CsvPreviewer from "@ogw_front/components/csv-preview/CsvPreviewer.vue";
import DragAndDrop from "@ogw_front/components/DragAndDrop.vue";
Expand Down Expand Up @@ -72,6 +74,10 @@ async function onCsvConfirm(result: unknown): Promise<void> {
csv_dialog.value = false;
}

function warnFoldersIgnored(): void {
useFeedbackStore().add_warning(foldersIgnoredWarning);
}

function processSelectedFiles(selected_files: UploadFile[]): void {
if (multiple) {
internal_files.value = [...internal_files.value, ...selected_files];
Expand Down Expand Up @@ -154,6 +160,7 @@ watch(
:accept
:inline="false"
@files-selected="processSelectedFiles"
@folders-ignored="warnFoldersIgnored"
/>
</template>
<DragAndDrop
Expand All @@ -166,6 +173,7 @@ watch(
:inline="!internal_files.length"
:show-overlay="showOverlay"
@files-selected="processSelectedFiles"
@folders-ignored="warnFoldersIgnored"
/>

<v-card-text v-if="internal_files.length" class="mt-6 pa-0">
Expand Down
80 changes: 76 additions & 4 deletions app/components/MissingFilesSelector.vue
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
<script setup lang="ts">
import schemas from "@geode/opengeodeweb-back/opengeodeweb_back_typed_schemas.js";

import { matchExpectedFiles, uploadPath } from "@ogw_front/utils/upload_path";
import DragAndDrop from "@ogw_front/components/DragAndDrop.vue";
import FetchingData from "@ogw_front/components/FetchingData.vue";
import FileUploader from "@ogw_front/components/FileUploader.vue";
import { useBackStore } from "@ogw_front/stores/back";
import { useFeedbackStore } from "@ogw_front/stores/feedback";

// Files carry extra app-specific bookkeeping fields once picked up here.
type UploadFile = File & { isConfigured?: boolean };
type UploadFile = File & { isConfigured?: boolean; relativePath?: string };
interface FilePlan {
has_missing_files: boolean;
mandatory_files: string[];
additional_files: string[];
}

const schema = schemas.opengeodeweb_back.missing_files;
const schema = schemas.opengeodeweb_back.data_missing_files;
const time_series_schema = schemas.opengeodeweb_back.time_series_missing_files;

interface Emits {
update_values: [value: { additional_files: UploadFile[] }];
Expand All @@ -28,16 +32,24 @@ interface Props {
geodeObjectType: string;
filenames: string[];
files?: UploadFile[];
timeSeries?: boolean;
}

const { multiple, geodeObjectType, filenames, files = [] } = defineProps<Props>();
const {
multiple,
geodeObjectType,
filenames,
files = [],
timeSeries = false,
} = defineProps<Props>();

const accept = ref<string>("");
const loading = ref<boolean>(false);
const has_missing_files = ref<boolean>(false);
const mandatory_files = ref<string[]>([]);
const additional_files = ref<string[]>([]);
const toggle_loading = useToggle(loading);
const uploading = ref<boolean>(false);

function files_uploaded_event(value: UploadFile[]): void {
emit("update_values", { additional_files: value });
Expand All @@ -63,6 +75,9 @@ async function missing_files(): Promise<void> {
additional_files: [],
});
}
if (timeSeries) {
return backStore.request({ schema: time_series_schema, params: { filename } });
}
const params = { geode_object_type: geodeObjectType, filename };
return backStore.request({ schema, params });
});
Expand All @@ -88,12 +103,61 @@ async function missing_files(): Promise<void> {
toggle_loading();
}

// Time series: uploads the missing files found in the selected folder, then the files they
// Reference in turn, until nothing is missing. The folder layout does not matter.
async function upload_missing_from(
folder_files: UploadFile[],
uploaded: UploadFile[],
): Promise<void> {
const expected = [...mandatory_files.value, ...additional_files.value];
const match = matchExpectedFiles(
folder_files.map((file) => uploadPath(file)),
expected,
);
if (match.status === "ambiguous") {
useFeedbackStore().add_warning(
`Several matching files for ${match.path}, select a more specific folder`,
);
return;
}
const found = expected.flatMap((expected_path, index) => {
const file = folder_files[match.indices[index] ?? -1];
return file && !uploaded.includes(file)
? [Object.assign(file, { relativePath: expected_path })]
: [];
});
if (found.length === 0) {
useFeedbackStore().add_warning(`The selected folder does not contain: ${expected.join(", ")}`);
return;
}
const backStore = useBackStore();
await Promise.all(found.map((file) => backStore.upload(file)));
emit("update_values", { additional_files: [...uploaded, ...found] });
await missing_files();
if (has_missing_files.value) {
await upload_missing_from(folder_files, [...uploaded, ...found]);
}
}

async function upload_folder(folder_files: UploadFile[]): Promise<void> {
uploading.value = true;
try {
await upload_missing_from(folder_files, []);
} finally {
uploading.value = false;
}
}

function warn_folder_read_error(message: string): void {
useFeedbackStore().add_warning(`Could not read the dropped folder: ${message}`);
}

// oxlint-disable-next-line no-top-level-await
await missing_files();
</script>

<template>
<FetchingData v-if="loading" />
<FetchingData v-if="loading || uploading" />
<v-container v-else-if="has_missing_files">
<v-row v-if="mandatory_files.length" align="center">
<v-col cols="auto" class="pa-0">
Expand All @@ -115,7 +179,15 @@ await missing_files();
</v-row>
<v-row>
<v-col cols="12">
<DragAndDrop
v-if="timeSeries"
directory
:show-extensions="false"
@files-selected="upload_folder"
@folder-read-error="warn_folder_read_error"
/>
<FileUploader
v-else
v-bind="{ multiple, accept, files, autoUpload: false }"
@files_uploaded="files_uploaded_event"
/>
Expand Down
Loading
Loading