Skip to content

Commit

Permalink
chore(file-download): enable downloading folder/file contents from fi…
Browse files Browse the repository at this point in the history
…le directory
  • Loading branch information
priyakanabar-crest committed Nov 15, 2024
1 parent ed20ff2 commit 17565b9
Show file tree
Hide file tree
Showing 5 changed files with 286 additions and 6 deletions.
1 change: 1 addition & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ install_requires =
setuptools<=68.2.2
pygwalker<=0.4.8.1
openai<=1.1.0
aiofiles<=24.1.0
python-multipart<=0.0.9
ruff<=0.6.7
python_requires = >=3.9
Expand Down
5 changes: 5 additions & 0 deletions zt_backend/models/api/request.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,8 @@ class RenameItemRequest(BaseModel):
class DeleteItemRequest(BaseModel):
path: str
name: str

class DownloadRequest(BaseModel):
path: str
filename: str
isFolder: bool
136 changes: 135 additions & 1 deletion zt_backend/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
from zt_backend.models.state.user_state import UserState
from zt_backend.models.state.app_state import AppState
from zt_backend.models.state.notebook_state import UploadState
from fastapi.responses import HTMLResponse
from fastapi.responses import HTMLResponse, StreamingResponse, Response
from fastapi.requests import Request
from pathlib import Path
import logging
import uuid
Expand All @@ -46,6 +47,11 @@
import requests
import re
from zt_backend.utils.file_utils import upload_queue, process_upload
import mimetypes
from typing import Dict, Tuple, Optional
import aiofiles
import tempfile
import zipfile

router = APIRouter()
manager = ConnectionManager()
Expand Down Expand Up @@ -727,7 +733,135 @@ def delete_item(delete_request: request.DeleteItemRequest):
status_code=500, detail=f"An unexpected error occurred: {str(e)}"
)

def initialize_mime_types():
"""Initialize and customize MIME types."""
mimetypes.init()

custom_mime_types: Dict[str, str] = {
'.md': 'text/markdown',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.7z': 'application/x-7z-compressed',
'.rar': 'application/vnd.rar',
'.webp': 'image/webp',
# Add more custom MIME types here as needed
}

for ext, mime_type in custom_mime_types.items():
mimetypes.add_type(mime_type, ext)

def get_mime_type(filename: str) -> str:
"""Determine the MIME type of a file based on its filename."""
initialize_mime_types()
return mimetypes.guess_type(filename)[0] or 'application/octet-stream'

def validate_path(path: Path, filename: str):
"""Validate the path and raise appropriate exceptions if invalid."""
if not path.exists():
raise HTTPException(status_code=404, detail=f"Path not found: {filename}")

async def create_zip_file(folder_path: Path, temp_zip_path: str):
"""Create a zip file from a folder."""
with zipfile.ZipFile(temp_zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, _, files in os.walk(folder_path):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, start=str(folder_path))
zipf.write(file_path, arcname)

async def parse_range_header(range_header: Optional[str], file_size: int) -> Tuple[int, int]:
"""Parse Range header and return start and end bytes."""
if not range_header:
return 0, file_size - 1

try:
range_str = range_header.replace('bytes=', '')
start_str, end_str = range_str.split('-')
start = int(start_str)
end = int(end_str) if end_str else file_size - 1
return start, min(end, file_size - 1)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid range header")

async def stream_file_range(file_path: str, start: int, end: int, chunk_size: int = 8192):
"""Stream file content for the specified byte range."""
async with aiofiles.open(file_path, mode='rb') as file:
await file.seek(start)
bytes_remaining = end - start + 1

while bytes_remaining > 0:
chunk_size = min(chunk_size, bytes_remaining)
chunk = await file.read(chunk_size)
if not chunk:
break
yield chunk
bytes_remaining -= len(chunk)

@router.get("/api/download")
async def download_item(
request: Request,
path: str,
filename: str,
isFolder: bool,
chunk_size: int = 8192
):
"""Stream download with range support and error handling for both files and folders."""
try:
file_path = Path(path)
validate_path(file_path, filename)

if isFolder:
# Create a temporary zip file
with tempfile.NamedTemporaryFile(delete=False, suffix='.zip') as temp_zip:
temp_zip_path = temp_zip.name

# Create a zip file containing the folder contents
await create_zip_file(file_path, temp_zip_path)
file_path = Path(temp_zip_path)
filename = f"{filename}.zip"
elif not file_path.is_file():
raise HTTPException(status_code=400, detail=f"The path specified is not a file: {filename}")

file_size = file_path.stat().st_size
start, end = await parse_range_header(
request.headers.get('range'),
file_size
)

# Create response with proper headers
response = StreamingResponse(
stream_file_range(str(file_path), start, end, chunk_size),
status_code=206 if request.headers.get('range') else 200,
media_type=get_mime_type(filename)
)

# Set headers
response.headers.update({
"Content-Range": f"bytes {start}-{end}/{file_size}",
"Content-Length": str(end - start + 1),
"Content-Disposition": f"attachment; filename={filename}",
"Accept-Ranges": "bytes",
"Cache-Control": "no-cache",
"Content-Encoding": "identity"
})

if isFolder:
# Clean up the temporary zip file after streaming
async def cleanup_temp_file():
yield
os.unlink(temp_zip_path)

response.background = cleanup_temp_file()

return response

except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Download failed: {str(e)}"
)

def get_file_type(name):
extension = name.split(".")[-1]
if extension in ["html", "js", "json", "md", "pdf", "png", "txt", "xls"]:
Expand Down
42 changes: 37 additions & 5 deletions zt_frontend/src/components/FileExplorer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,10 @@
<v-icon v-else>{{ fileIcon(item.file) }}</v-icon>
</template>

<v-list-item-title @click="handleItemClick(item)">{{ item.title }}</v-list-item-title>
<v-list-item-title @click="handleItemClick(item)" :class="{'clickable-item': item.file === 'folder'}">{{ item.title }}</v-list-item-title>

<template v-slot:append>
<v-menu v-if="!isProtectedFile(item.title)">
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
icon
Expand All @@ -54,10 +54,18 @@
</v-btn>
</template>
<v-list>
<v-list-item @click="openRenameDialog(item)">
<v-list-item
@click="openDownloadDialog(item)"
>
<v-list-item-title>
<v-icon size="small" class="mr-2">mdi-download</v-icon>
Download
</v-list-item-title>
</v-list-item>
<v-list-item v-if="!isProtectedFile(item.title)" @click="openRenameDialog(item)">
<v-list-item-title>Rename</v-list-item-title>
</v-list-item>
<v-list-item @click="openDeleteDialog(item)">
<v-list-item v-if="!isProtectedFile(item.title)" @click="openDeleteDialog(item)">
<v-list-item-title>Delete</v-list-item-title>
</v-list-item>
</v-list>
Expand All @@ -84,6 +92,12 @@
</template>
</v-snackbar>

<FileFolderDownloadDialog
ref="downloadDialog"
:current-path="currentPath"
@file-downloaded="refreshFiles"
/>

<RenameDialog
ref="renameDialog"
:current-path="currentPath"
Expand All @@ -108,6 +122,7 @@ import FileUploader from "@/components/FileUploader.vue";
import FileFolderCreator from "@/components/FileFolderCreator.vue";
import RenameDialog from "@/components/FileFolderRenameDialog.vue";
import DeleteDialog from "@/components/FileFolderDeleteDialog.vue";
import FileFolderDownloadDialog from "@/components/FileFolderDownloadDialog.vue";
Expand All @@ -117,7 +132,8 @@ export default defineComponent({
FileUploader,
FileFolderCreator,
RenameDialog,
DeleteDialog
DeleteDialog,
FileFolderDownloadDialog
},
props: {
drawer: Boolean,
Expand Down Expand Up @@ -147,9 +163,13 @@ export default defineComponent({
return protectedFiles.value.includes(filename);
};
const downloadDialog = ref<InstanceType<typeof FileFolderDownloadDialog> | null>(null);
const renameDialog = ref<InstanceType<typeof RenameDialog> | null>(null);
const deleteDialog = ref<InstanceType<typeof DeleteDialog> | null>(null);
const openDownloadDialog = (item: any) => {
downloadDialog.value?.openDialog(item);
};
const openRenameDialog = (item: any) => {
renameDialog.value?.openDialog(item);
Expand Down Expand Up @@ -250,6 +270,8 @@ export default defineComponent({
fileIcon,
newItemName,
itemTypes,
downloadDialog,
openDownloadDialog,
renameDialog,
openRenameDialog,
openDeleteDialog,
Expand All @@ -261,3 +283,13 @@ export default defineComponent({
},
});
</script>

<style scoped>
.clickable-item {
cursor: pointer;
}
.clickable-item:hover {
opacity: 0.8;
text-decoration: underline;
}
</style>
108 changes: 108 additions & 0 deletions zt_frontend/src/components/FileFolderDownloadDialog.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<template>
<div>
<v-dialog v-model="dialogVisible" max-width="500px" persistent>
<v-card>
<v-card-title>Confirm Download</v-card-title>
<v-card-text>
Do you want to download "{{ itemToDownload?.title }}"{{ itemToDownload?.file === 'folder' ? ' as a ZIP file' : '' }}?
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn color="blue-darken-1" @click="closeDialog">Cancel</v-btn>
<v-btn
color="primary"
@click="downloadItem"
:loading="isDownloading"
>
Download
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="showError" color="error" :timeout="5000">
{{ errorMessage }}
<template v-slot:actions>
<v-btn color="white" variant="text" @click="showError = false">
Close
</v-btn>
</template>
</v-snackbar>
</div>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import axios from 'axios';
export default defineComponent({
name: 'DownloadItem',
props: {
currentPath: {
type: String,
required: true
},
},
emits: ['itemDownloaded'],
setup(props, { emit }) {
const dialogVisible = ref(false);
const itemToDownload = ref<any>(null);
const errorMessage = ref('');
const showError = ref(false);
const isDownloading = ref(false);
const openDialog = (item: any) => {
itemToDownload.value = item;
dialogVisible.value = true;
};
const closeDialog = () => {
dialogVisible.value = false;
itemToDownload.value = null;
isDownloading.value = false;
};
const displayError = (message: string) => {
errorMessage.value = message;
showError.value = true;
};
const downloadItem = async () => {
if (!itemToDownload.value) return;
isDownloading.value = true;
try {
const response = await axios({
url: `${import.meta.env.VITE_BACKEND_URL}api/download`,
method: 'GET',
params: {
path: `${props.currentPath}/${itemToDownload.value.title}`,
filename: itemToDownload.value.title,
isFolder: itemToDownload.value.file === 'folder'
},
responseType: 'blob'
});
// Create blob link to download
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', itemToDownload.value.title + (itemToDownload.value.file === 'folder' ? '.zip' : ''));
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
emit('itemDownloaded');
closeDialog();
} catch (error) {
console.error('Error downloading item:', error);
displayError("Error downloading the item. Please try again.");
} finally {
isDownloading.value = false;
}
};
return {
dialogVisible,
itemToDownload,
openDialog,
closeDialog,
downloadItem,
errorMessage,
showError,
isDownloading,
};
}
});
</script>

0 comments on commit 17565b9

Please sign in to comment.