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 { iconClass } from '../NotesOptions/ClassNames'
|
||||||
import { useApplication } from '../ApplicationProvider'
|
import { useApplication } from '../ApplicationProvider'
|
||||||
import MenuSection from '../Menu/MenuSection'
|
import MenuSection from '../Menu/MenuSection'
|
||||||
|
import { ToastType, addToast } from '@standardnotes/toast'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
closeMenu: () => void
|
closeMenu: () => void
|
||||||
@@ -35,11 +36,12 @@ const FileMenuOptions: FunctionComponent<Props> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const application = useApplication()
|
const application = useApplication()
|
||||||
|
|
||||||
const { handleFileAction } = application.filesController
|
const { shouldUseStreamingAPI, handleFileAction } = application.filesController
|
||||||
const { toggleAppPane } = useResponsiveAppPane()
|
const { toggleAppPane } = useResponsiveAppPane()
|
||||||
|
|
||||||
const hasProtectedFiles = useMemo(() => selectedFiles.some((file) => file.protected), [selectedFiles])
|
const hasProtectedFiles = useMemo(() => selectedFiles.some((file) => file.protected), [selectedFiles])
|
||||||
const hasSelectedMultipleFiles = useMemo(() => selectedFiles.length > 1, [selectedFiles.length])
|
const hasSelectedMultipleFiles = useMemo(() => selectedFiles.length > 1, [selectedFiles.length])
|
||||||
|
const canShowZipDownloadOption = shouldUseStreamingAPI && hasSelectedMultipleFiles
|
||||||
|
|
||||||
const totalFileSize = useMemo(
|
const totalFileSize = useMemo(
|
||||||
() => selectedFiles.map((file) => file.decryptedSize).reduce((prev, next) => prev + next, 0),
|
() => 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}`} />
|
<Icon type="download" className={`mr-2 text-neutral ${MenuItemIconSize}`} />
|
||||||
Download
|
Download {canShowZipDownloadOption ? 'separately' : ''}
|
||||||
</MenuItem>
|
</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 && (
|
{shouldShowRenameOption && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ArchiveManager,
|
ArchiveManager,
|
||||||
confirmDialog,
|
confirmDialog,
|
||||||
IsNativeMobileWeb,
|
IsNativeMobileWeb,
|
||||||
|
parseAndCreateZippableFileName,
|
||||||
VaultDisplayServiceInterface,
|
VaultDisplayServiceInterface,
|
||||||
} from '@standardnotes/ui-services'
|
} from '@standardnotes/ui-services'
|
||||||
import { Strings, StringUtils } from '@/Constants/Strings'
|
import { Strings, StringUtils } from '@/Constants/Strings'
|
||||||
@@ -44,6 +45,7 @@ import { action, makeObservable, observable, reaction } from 'mobx'
|
|||||||
import { AbstractViewController } from './Abstract/AbstractViewController'
|
import { AbstractViewController } from './Abstract/AbstractViewController'
|
||||||
import { NotesController } from './NotesController/NotesController'
|
import { NotesController } from './NotesController/NotesController'
|
||||||
import { downloadOrShareBlobBasedOnPlatform } from '@/Utils/DownloadOrShareBasedOnPlatform'
|
import { downloadOrShareBlobBasedOnPlatform } from '@/Utils/DownloadOrShareBasedOnPlatform'
|
||||||
|
import { truncateString } from '@/Components/SuperEditor/Utils'
|
||||||
|
|
||||||
const UnprotectedFileActions = [FileItemActionType.ToggleFileProtection]
|
const UnprotectedFileActions = [FileItemActionType.ToggleFileProtection]
|
||||||
const NonMutatingFileActions = [FileItemActionType.DownloadFile, FileItemActionType.PreviewFile]
|
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