feat: Added option to download multiple files as a zip when using the desktop app or a supported browser (#2748)

This commit is contained in:
Aman Harwara
2024-01-08 19:59:28 +05:30
committed by GitHub
parent ee895ad44d
commit 130b63b1a5
2 changed files with 108 additions and 2 deletions

View File

@@ -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<Props> = ({
}) => {
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<Props> = ({
}}
>
<Icon type="download" className={`mr-2 text-neutral ${MenuItemIconSize}`} />
Download
Download {canShowZipDownloadOption ? 'separately' : ''}
</MenuItem>
{canShowZipDownloadOption && (
<MenuItem
onClick={() => {
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()
}}
>
<Icon type="download" className={`mr-2 text-neutral ${MenuItemIconSize}`} />
Download as archive
</MenuItem>
)}
{shouldShowRenameOption && (
<MenuItem
onClick={() => {

View File

@@ -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<FilesControllerEvent
),
)
}
downloadFilesAsZip = async (files: FileItem[]) => {
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`,
})
}
}