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:
@@ -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={() => {
|
||||
|
||||
@@ -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`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user