From 130b63b1a51a52c547cc40cfc8352946920c5af3 Mon Sep 17 00:00:00 2001 From: Aman Harwara Date: Mon, 8 Jan 2024 19:59:28 +0530 Subject: [PATCH] feat: Added option to download multiple files as a zip when using the desktop app or a supported browser (#2748) --- .../FileContextMenu/FileMenuOptions.tsx | 26 +++++- .../Controllers/FilesController.ts | 84 +++++++++++++++++++ 2 files changed, 108 insertions(+), 2 deletions(-) diff --git a/packages/web/src/javascripts/Components/FileContextMenu/FileMenuOptions.tsx b/packages/web/src/javascripts/Components/FileContextMenu/FileMenuOptions.tsx index 286f48e68..fa51741a1 100644 --- a/packages/web/src/javascripts/Components/FileContextMenu/FileMenuOptions.tsx +++ b/packages/web/src/javascripts/Components/FileContextMenu/FileMenuOptions.tsx @@ -15,6 +15,7 @@ import AddToVaultMenuOption from '../Vaults/AddToVaultMenuOption' import { iconClass } from '../NotesOptions/ClassNames' import { useApplication } from '../ApplicationProvider' import MenuSection from '../Menu/MenuSection' +import { ToastType, addToast } from '@standardnotes/toast' type Props = { closeMenu: () => void @@ -35,11 +36,12 @@ const FileMenuOptions: FunctionComponent = ({ }) => { const application = useApplication() - const { handleFileAction } = application.filesController + const { shouldUseStreamingAPI, handleFileAction } = application.filesController const { toggleAppPane } = useResponsiveAppPane() const hasProtectedFiles = useMemo(() => selectedFiles.some((file) => file.protected), [selectedFiles]) const hasSelectedMultipleFiles = useMemo(() => selectedFiles.length > 1, [selectedFiles.length]) + const canShowZipDownloadOption = shouldUseStreamingAPI && hasSelectedMultipleFiles const totalFileSize = useMemo( () => selectedFiles.map((file) => file.decryptedSize).reduce((prev, next) => prev + next, 0), @@ -136,8 +138,28 @@ const FileMenuOptions: FunctionComponent = ({ }} > - Download + Download {canShowZipDownloadOption ? 'separately' : ''} + {canShowZipDownloadOption && ( + { + application.filesController.downloadFilesAsZip(selectedFiles).catch((error) => { + if (error instanceof DOMException && error.name === 'AbortError') { + return + } + console.error(error) + addToast({ + type: ToastType.Error, + message: error.message || 'Failed to download files as archive', + }) + }) + closeMenu() + }} + > + + Download as archive + + )} {shouldShowRenameOption && ( { diff --git a/packages/web/src/javascripts/Controllers/FilesController.ts b/packages/web/src/javascripts/Controllers/FilesController.ts index 4be70f7ee..053d88455 100644 --- a/packages/web/src/javascripts/Controllers/FilesController.ts +++ b/packages/web/src/javascripts/Controllers/FilesController.ts @@ -11,6 +11,7 @@ import { ArchiveManager, confirmDialog, IsNativeMobileWeb, + parseAndCreateZippableFileName, VaultDisplayServiceInterface, } from '@standardnotes/ui-services' import { Strings, StringUtils } from '@/Constants/Strings' @@ -44,6 +45,7 @@ import { action, makeObservable, observable, reaction } from 'mobx' import { AbstractViewController } from './Abstract/AbstractViewController' import { NotesController } from './NotesController/NotesController' import { downloadOrShareBlobBasedOnPlatform } from '@/Utils/DownloadOrShareBasedOnPlatform' +import { truncateString } from '@/Components/SuperEditor/Utils' const UnprotectedFileActions = [FileItemActionType.ToggleFileProtection] const NonMutatingFileActions = [FileItemActionType.DownloadFile, FileItemActionType.PreviewFile] @@ -716,4 +718,86 @@ export class FilesController extends AbstractViewController { + if (!this.shouldUseStreamingAPI) { + throw new Error('Device does not support streaming API') + } + + const protectedFiles = files.filter((file) => file.protected) + + if (protectedFiles.length > 0) { + const authorized = await this.protections.authorizeProtectedActionForItems( + protectedFiles, + ChallengeReason.AccessProtectedFile, + ) + if (authorized.length === 0) { + throw new Error('Authorization is required to download protected files') + } + } + + const zipFileHandle = await window.showSaveFilePicker({ + types: [ + { + description: 'ZIP file', + accept: { 'application/zip': ['.zip'] }, + }, + ], + }) + + const toast = addToast({ + type: ToastType.Progress, + title: `Downloading ${files.length} files as archive`, + message: 'Preparing archive...', + }) + + try { + const zip = await import('@zip.js/zip.js') + + const zipStream = await zipFileHandle.createWritable() + + const zipWriter = new zip.ZipWriter(zipStream, { + level: 0, + }) + + const addedFilenames: string[] = [] + + for (const file of files) { + const fileStream = new TransformStream() + + let name = parseAndCreateZippableFileName(file.name) + + if (addedFilenames.includes(name)) { + name = `${Date.now()} ${name}` + } + + zipWriter.add(name, fileStream.readable).catch(console.error) + + addedFilenames.push(name) + + const writer = fileStream.writable.getWriter() + + await this.files + .downloadFile(file, async (bytesChunk, progress) => { + await writer.write(bytesChunk) + updateToast(toast, { + message: `Downloading file "${truncateString(file.name, 20)}"`, + progress: Math.floor(progress.percentComplete), + }) + }) + .catch(console.error) + + await writer.close() + } + + await zipWriter.close() + } finally { + dismissToast(toast) + } + + addToast({ + type: ToastType.Success, + message: `Successfully downloaded ${files.length} files as archive`, + }) + } }