Browse Source

Add multi-file selection and bulk download to printer file browser
- Add checkbox selection for individual files
- Add Select All / Deselect All buttons
- Download single file directly, multiple files as ZIP archive
- Add POST /printers/{id}/files/download-zip endpoint
- Clear selection after successful download
- Support bulk delete for multiple files
- Update docs (changelog, wiki, website)

Closes #144

maziggy 4 months ago
parent
commit
e0e53cb680

+ 5 - 0
CHANGELOG.md

@@ -7,6 +7,11 @@ All notable changes to Bambuddy will be documented in this file.
 ### New Features
 ### New Features
 - **Recalculate Costs Button** - New button on Dashboard to recalculate all archive costs using current filament prices (Issue #120)
 - **Recalculate Costs Button** - New button on Dashboard to recalculate all archive costs using current filament prices (Issue #120)
 - **Create Folder from ZIP** - New option in File Manager upload to automatically create a folder named after the ZIP file (Issue #121)
 - **Create Folder from ZIP** - New option in File Manager upload to automatically create a folder named after the ZIP file (Issue #121)
+- **Multi-File Selection in Printer Files** - Printer card file browser now supports multiple file selection (Issue #144):
+  - Checkbox selection for individual files
+  - Select All / Deselect All buttons
+  - Bulk download as ZIP when multiple files selected
+  - Bulk delete for multiple files at once
 
 
 ### Fixes
 ### Fixes
 - **Reprint Cost Tracking** - Reprinting an archive now adds the cost to the existing total, so statistics accurately reflect total filament expenditure across all prints
 - **Reprint Cost Tracking** - Reprinting an archive now adds the cost to the existing total, so statistics accurately reflect total filament expenditure across all prints

+ 44 - 0
backend/app/api/routes/printers.py

@@ -707,6 +707,50 @@ async def download_printer_file(
     )
     )
 
 
 
 
+@router.post("/{printer_id}/files/download-zip")
+async def download_printer_files_as_zip(
+    printer_id: int,
+    request: dict,
+    db: AsyncSession = Depends(get_db),
+):
+    """Download multiple files from the printer as a ZIP archive."""
+    import io
+
+    paths = request.get("paths", [])
+    if not paths:
+        raise HTTPException(400, "No files specified")
+
+    result = await db.execute(select(Printer).where(Printer.id == printer_id))
+    printer = result.scalar_one_or_none()
+    if not printer:
+        raise HTTPException(404, "Printer not found")
+
+    # Create ZIP in memory
+    zip_buffer = io.BytesIO()
+    with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
+        for path in paths:
+            try:
+                data = await download_file_bytes_async(printer.ip_address, printer.access_code, path)
+                if data:
+                    filename = path.split("/")[-1]
+                    zf.writestr(filename, data)
+            except Exception as e:
+                logging.warning(f"Failed to add {path} to ZIP: {e}")
+                continue
+
+    zip_buffer.seek(0)
+    zip_data = zip_buffer.read()
+
+    if len(zip_data) == 0:
+        raise HTTPException(404, "No files could be downloaded")
+
+    return Response(
+        content=zip_data,
+        media_type="application/zip",
+        headers={"Content-Disposition": 'attachment; filename="printer-files.zip"'},
+    )
+
+
 @router.delete("/{printer_id}/files")
 @router.delete("/{printer_id}/files")
 async def delete_printer_file(
 async def delete_printer_file(
     printer_id: int,
     printer_id: int,

+ 12 - 0
frontend/src/api/client.ts

@@ -1625,6 +1625,18 @@ export const api = {
     }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
     }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`),
   getPrinterFileDownloadUrl: (printerId: number, path: string) =>
   getPrinterFileDownloadUrl: (printerId: number, path: string) =>
     `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
     `${API_BASE}/printers/${printerId}/files/download?path=${encodeURIComponent(path)}`,
+  downloadPrinterFilesAsZip: async (printerId: number, paths: string[]): Promise<Blob> => {
+    const response = await fetch(`${API_BASE}/printers/${printerId}/files/download-zip`, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ paths }),
+    });
+    if (!response.ok) {
+      const error = await response.json().catch(() => ({}));
+      throw new Error(error.detail || `HTTP ${response.status}`);
+    }
+    return response.blob();
+  },
   deletePrinterFile: (printerId: number, path: string) =>
   deletePrinterFile: (printerId: number, path: string) =>
     request<{ status: string; path: string }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`, {
     request<{ status: string; path: string }>(`/printers/${printerId}/files?path=${encodeURIComponent(path)}`, {
       method: 'DELETE',
       method: 'DELETE',

+ 147 - 33
frontend/src/components/FileManagerModal.tsx

@@ -16,6 +16,9 @@ import {
   Image,
   Image,
   Search,
   Search,
   ArrowUpDown,
   ArrowUpDown,
+  CheckSquare,
+  Square,
+  MinusSquare,
 } from 'lucide-react';
 } from 'lucide-react';
 import { api } from '../api/client';
 import { api } from '../api/client';
 import { Button } from './Button';
 import { Button } from './Button';
@@ -82,10 +85,11 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
   const { showToast } = useToast();
   const { showToast } = useToast();
   const queryClient = useQueryClient();
   const queryClient = useQueryClient();
   const [currentPath, setCurrentPath] = useState('/');
   const [currentPath, setCurrentPath] = useState('/');
-  const [selectedFile, setSelectedFile] = useState<string | null>(null);
+  const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
   const [searchQuery, setSearchQuery] = useState('');
   const [searchQuery, setSearchQuery] = useState('');
-  const [fileToDelete, setFileToDelete] = useState<string | null>(null);
+  const [filesToDelete, setFilesToDelete] = useState<string[]>([]);
   const [sortBy, setSortBy] = useState<SortOption>('name-asc');
   const [sortBy, setSortBy] = useState<SortOption>('name-asc');
+  const [downloadProgress, setDownloadProgress] = useState<{ current: number; total: number } | null>(null);
 
 
   // Close on Escape key
   // Close on Escape key
   useEffect(() => {
   useEffect(() => {
@@ -108,11 +112,17 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
   });
   });
 
 
   const deleteMutation = useMutation({
   const deleteMutation = useMutation({
-    mutationFn: (path: string) => api.deletePrinterFile(printerId, path),
-    onSuccess: (_, path) => {
-      showToast(`Deleted: ${path.split('/').pop()}`);
+    mutationFn: async (paths: string[]) => {
+      // Delete files one by one
+      for (const path of paths) {
+        await api.deletePrinterFile(printerId, path);
+      }
+    },
+    onSuccess: () => {
+      showToast(`Deleted ${filesToDelete.length} file${filesToDelete.length > 1 ? 's' : ''}`);
       queryClient.invalidateQueries({ queryKey: ['printerFiles', printerId] });
       queryClient.invalidateQueries({ queryKey: ['printerFiles', printerId] });
-      setSelectedFile(null);
+      setSelectedFiles(new Set());
+      setFilesToDelete([]);
     },
     },
     onError: (error: Error) => {
     onError: (error: Error) => {
       showToast(`Delete failed: ${error.message}`, 'error');
       showToast(`Delete failed: ${error.message}`, 'error');
@@ -121,7 +131,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
 
 
   const navigateToFolder = (path: string) => {
   const navigateToFolder = (path: string) => {
     setCurrentPath(path);
     setCurrentPath(path);
-    setSelectedFile(null);
+    setSelectedFiles(new Set());
   };
   };
 
 
   const navigateUp = () => {
   const navigateUp = () => {
@@ -129,15 +139,70 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
     const parts = currentPath.split('/').filter(Boolean);
     const parts = currentPath.split('/').filter(Boolean);
     parts.pop();
     parts.pop();
     setCurrentPath(parts.length ? '/' + parts.join('/') : '/');
     setCurrentPath(parts.length ? '/' + parts.join('/') : '/');
-    setSelectedFile(null);
+    setSelectedFiles(new Set());
+  };
+
+  const toggleFileSelection = (path: string, e: React.MouseEvent) => {
+    e.stopPropagation();
+    setSelectedFiles(prev => {
+      const next = new Set(prev);
+      if (next.has(path)) {
+        next.delete(path);
+      } else {
+        next.add(path);
+      }
+      return next;
+    });
+  };
+
+  const selectAllFiles = () => {
+    if (!data?.files) return;
+    const filePaths = data.files
+      .filter(f => !f.is_directory && (!searchQuery || f.name.toLowerCase().includes(searchQuery.toLowerCase())))
+      .map(f => f.path);
+    setSelectedFiles(new Set(filePaths));
   };
   };
 
 
-  const handleDownload = (path: string) => {
-    window.open(api.getPrinterFileDownloadUrl(printerId, path), '_blank');
+  const deselectAllFiles = () => {
+    setSelectedFiles(new Set());
   };
   };
 
 
-  const handleDelete = (path: string) => {
-    setFileToDelete(path);
+  const handleDownload = async () => {
+    if (selectedFiles.size === 0) return;
+
+    const paths = Array.from(selectedFiles);
+
+    if (paths.length === 1) {
+      // Single file - direct download
+      window.open(api.getPrinterFileDownloadUrl(printerId, paths[0]), '_blank');
+      setSelectedFiles(new Set());
+      return;
+    }
+
+    // Multiple files - download as ZIP
+    setDownloadProgress({ current: 0, total: paths.length });
+    try {
+      const blob = await api.downloadPrinterFilesAsZip(printerId, paths);
+      const url = URL.createObjectURL(blob);
+      const a = document.createElement('a');
+      a.href = url;
+      a.download = `${printerName.replace(/[^a-zA-Z0-9]/g, '_')}-files.zip`;
+      document.body.appendChild(a);
+      a.click();
+      document.body.removeChild(a);
+      URL.revokeObjectURL(url);
+      showToast(`Downloaded ${paths.length} files as ZIP`);
+      setSelectedFiles(new Set());
+    } catch (error) {
+      showToast(`Download failed: ${error instanceof Error ? error.message : 'Unknown error'}`, 'error');
+    } finally {
+      setDownloadProgress(null);
+    }
+  };
+
+  const handleDelete = () => {
+    if (selectedFiles.size === 0) return;
+    setFilesToDelete(Array.from(selectedFiles));
   };
   };
 
 
   // Quick navigation buttons for common directories
   // Quick navigation buttons for common directories
@@ -303,7 +368,7 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
                   })
                   })
                   .map((file) => {
                   .map((file) => {
                     const FileIcon = getFileIcon(file.name, file.is_directory);
                     const FileIcon = getFileIcon(file.name, file.is_directory);
-                    const isSelected = selectedFile === file.path;
+                    const isSelected = selectedFiles.has(file.path);
 
 
                     return (
                     return (
                       <div
                       <div
@@ -316,11 +381,22 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
                         onClick={() => {
                         onClick={() => {
                           if (file.is_directory) {
                           if (file.is_directory) {
                             navigateToFolder(file.path);
                             navigateToFolder(file.path);
-                          } else {
-                            setSelectedFile(isSelected ? null : file.path);
                           }
                           }
                         }}
                         }}
                       >
                       >
+                        {/* Checkbox for files only */}
+                        {!file.is_directory ? (
+                          <button
+                            onClick={(e) => toggleFileSelection(file.path, e)}
+                            className="flex-shrink-0 text-bambu-gray hover:text-white"
+                          >
+                            {isSelected ? (
+                              <CheckSquare className="w-5 h-5 text-bambu-green" />
+                            ) : (
+                              <Square className="w-5 h-5" />
+                            )}
+                          </button>
+                        ) : null}
                         <FileIcon
                         <FileIcon
                           className={`w-5 h-5 flex-shrink-0 ${
                           className={`w-5 h-5 flex-shrink-0 ${
                             file.is_directory ? 'text-bambu-green' : 'text-bambu-gray'
                             file.is_directory ? 'text-bambu-green' : 'text-bambu-gray'
@@ -344,25 +420,60 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
 
 
         {/* Action bar */}
         {/* Action bar */}
         <div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary bg-bambu-dark/50 flex-shrink-0">
         <div className="flex items-center justify-between p-4 border-t border-bambu-dark-tertiary bg-bambu-dark/50 flex-shrink-0">
-          <div className="text-sm text-bambu-gray">
-            {searchQuery
-              ? `${data?.files?.filter(f => f.name.toLowerCase().includes(searchQuery.toLowerCase())).length || 0} of ${data?.files?.length || 0} items`
-              : `${data?.files?.length || 0} items`
-            }
+          <div className="flex items-center gap-4">
+            <div className="text-sm text-bambu-gray">
+              {selectedFiles.size > 0
+                ? `${selectedFiles.size} selected`
+                : searchQuery
+                  ? `${data?.files?.filter(f => f.name.toLowerCase().includes(searchQuery.toLowerCase())).length || 0} of ${data?.files?.length || 0} items`
+                  : `${data?.files?.length || 0} items`
+              }
+            </div>
+            {/* Select All / Deselect All */}
+            {data?.files?.some(f => !f.is_directory) && (
+              <div className="flex items-center gap-2">
+                {selectedFiles.size > 0 ? (
+                  <button
+                    onClick={deselectAllFiles}
+                    className="flex items-center gap-1 text-xs text-bambu-gray hover:text-white transition-colors"
+                  >
+                    <MinusSquare className="w-4 h-4" />
+                    Deselect All
+                  </button>
+                ) : (
+                  <button
+                    onClick={selectAllFiles}
+                    className="flex items-center gap-1 text-xs text-bambu-gray hover:text-white transition-colors"
+                  >
+                    <CheckSquare className="w-4 h-4" />
+                    Select All
+                  </button>
+                )}
+              </div>
+            )}
           </div>
           </div>
           <div className="flex gap-2">
           <div className="flex gap-2">
             <Button
             <Button
               variant="secondary"
               variant="secondary"
-              disabled={!selectedFile}
-              onClick={() => selectedFile && handleDownload(selectedFile)}
+              disabled={selectedFiles.size === 0 || downloadProgress !== null}
+              onClick={handleDownload}
             >
             >
-              <Download className="w-4 h-4" />
-              Download
+              {downloadProgress ? (
+                <>
+                  <Loader2 className="w-4 h-4 animate-spin" />
+                  {downloadProgress.current}/{downloadProgress.total}
+                </>
+              ) : (
+                <>
+                  <Download className="w-4 h-4" />
+                  Download{selectedFiles.size > 1 ? ` (${selectedFiles.size})` : ''}
+                </>
+              )}
             </Button>
             </Button>
             <Button
             <Button
               variant="secondary"
               variant="secondary"
-              disabled={!selectedFile || deleteMutation.isPending}
-              onClick={() => selectedFile && handleDelete(selectedFile)}
+              disabled={selectedFiles.size === 0 || deleteMutation.isPending}
+              onClick={handleDelete}
               className="text-red-400 hover:text-red-300"
               className="text-red-400 hover:text-red-300"
             >
             >
               {deleteMutation.isPending ? (
               {deleteMutation.isPending ? (
@@ -370,24 +481,27 @@ export function FileManagerModal({ printerId, printerName, onClose }: FileManage
               ) : (
               ) : (
                 <Trash2 className="w-4 h-4" />
                 <Trash2 className="w-4 h-4" />
               )}
               )}
-              Delete
+              Delete{selectedFiles.size > 1 ? ` (${selectedFiles.size})` : ''}
             </Button>
             </Button>
           </div>
           </div>
         </div>
         </div>
       </div>
       </div>
 
 
       {/* Delete Confirmation Modal */}
       {/* Delete Confirmation Modal */}
-      {fileToDelete && (
+      {filesToDelete.length > 0 && (
         <ConfirmModal
         <ConfirmModal
-          title="Delete File"
-          message={`Delete "${fileToDelete.split('/').pop()}"? This cannot be undone.`}
+          title={filesToDelete.length > 1 ? `Delete ${filesToDelete.length} Files` : 'Delete File'}
+          message={
+            filesToDelete.length > 1
+              ? `Delete ${filesToDelete.length} selected files? This cannot be undone.`
+              : `Delete "${filesToDelete[0].split('/').pop()}"? This cannot be undone.`
+          }
           confirmText="Delete"
           confirmText="Delete"
           variant="danger"
           variant="danger"
           onConfirm={() => {
           onConfirm={() => {
-            deleteMutation.mutate(fileToDelete);
-            setFileToDelete(null);
+            deleteMutation.mutate(filesToDelete);
           }}
           }}
-          onCancel={() => setFileToDelete(null)}
+          onCancel={() => setFilesToDelete([])}
         />
         />
       )}
       )}
     </div>
     </div>

File diff suppressed because it is too large
+ 0 - 0
static/assets/index-BwLAOG3i.js


+ 1 - 1
static/index.html

@@ -23,7 +23,7 @@
 
 
     <!-- Splash screens for iOS -->
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-C0ILfTrK.js"></script>
+    <script type="module" crossorigin src="/assets/index-BwLAOG3i.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DVKqcow3.css">
     <link rel="stylesheet" crossorigin href="/assets/index-DVKqcow3.css">
   </head>
   </head>
   <body>
   <body>

Some files were not shown because too many files changed in this diff