Skip to content

Commit 130b63b

Browse files
authored
feat: Added option to download multiple files as a zip when using the desktop app or a supported browser (#2748)
1 parent ee895ad commit 130b63b

File tree

2 files changed

+108
-2
lines changed

2 files changed

+108
-2
lines changed

packages/web/src/javascripts/Components/FileContextMenu/FileMenuOptions.tsx

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import AddToVaultMenuOption from '../Vaults/AddToVaultMenuOption'
1515
import { iconClass } from '../NotesOptions/ClassNames'
1616
import { useApplication } from '../ApplicationProvider'
1717
import MenuSection from '../Menu/MenuSection'
18+
import { ToastType, addToast } from '@standardnotes/toast'
1819

1920
type Props = {
2021
closeMenu: () => void
@@ -35,11 +36,12 @@ const FileMenuOptions: FunctionComponent<Props> = ({
3536
}) => {
3637
const application = useApplication()
3738

38-
const { handleFileAction } = application.filesController
39+
const { shouldUseStreamingAPI, handleFileAction } = application.filesController
3940
const { toggleAppPane } = useResponsiveAppPane()
4041

4142
const hasProtectedFiles = useMemo(() => selectedFiles.some((file) => file.protected), [selectedFiles])
4243
const hasSelectedMultipleFiles = useMemo(() => selectedFiles.length > 1, [selectedFiles.length])
44+
const canShowZipDownloadOption = shouldUseStreamingAPI && hasSelectedMultipleFiles
4345

4446
const totalFileSize = useMemo(
4547
() => selectedFiles.map((file) => file.decryptedSize).reduce((prev, next) => prev + next, 0),
@@ -136,8 +138,28 @@ const FileMenuOptions: FunctionComponent<Props> = ({
136138
}}
137139
>
138140
<Icon type="download" className={`mr-2 text-neutral ${MenuItemIconSize}`} />
139-
Download
141+
Download {canShowZipDownloadOption ? 'separately' : ''}
140142
</MenuItem>
143+
{canShowZipDownloadOption && (
144+
<MenuItem
145+
onClick={() => {
146+
application.filesController.downloadFilesAsZip(selectedFiles).catch((error) => {
147+
if (error instanceof DOMException && error.name === 'AbortError') {
148+
return
149+
}
150+
console.error(error)
151+
addToast({
152+
type: ToastType.Error,
153+
message: error.message || 'Failed to download files as archive',
154+
})
155+
})
156+
closeMenu()
157+
}}
158+
>
159+
<Icon type="download" className={`mr-2 text-neutral ${MenuItemIconSize}`} />
160+
Download as archive
161+
</MenuItem>
162+
)}
141163
{shouldShowRenameOption && (
142164
<MenuItem
143165
onClick={() => {

packages/web/src/javascripts/Controllers/FilesController.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
ArchiveManager,
1212
confirmDialog,
1313
IsNativeMobileWeb,
14+
parseAndCreateZippableFileName,
1415
VaultDisplayServiceInterface,
1516
} from '@standardnotes/ui-services'
1617
import { Strings, StringUtils } from '@/Constants/Strings'
@@ -44,6 +45,7 @@ import { action, makeObservable, observable, reaction } from 'mobx'
4445
import { AbstractViewController } from './Abstract/AbstractViewController'
4546
import { NotesController } from './NotesController/NotesController'
4647
import { downloadOrShareBlobBasedOnPlatform } from '@/Utils/DownloadOrShareBasedOnPlatform'
48+
import { truncateString } from '@/Components/SuperEditor/Utils'
4749

4850
const UnprotectedFileActions = [FileItemActionType.ToggleFileProtection]
4951
const NonMutatingFileActions = [FileItemActionType.DownloadFile, FileItemActionType.PreviewFile]
@@ -716,4 +718,86 @@ export class FilesController extends AbstractViewController<FilesControllerEvent
716718
),
717719
)
718720
}
721+
722+
downloadFilesAsZip = async (files: FileItem[]) => {
723+
if (!this.shouldUseStreamingAPI) {
724+
throw new Error('Device does not support streaming API')
725+
}
726+
727+
const protectedFiles = files.filter((file) => file.protected)
728+
729+
if (protectedFiles.length > 0) {
730+
const authorized = await this.protections.authorizeProtectedActionForItems(
731+
protectedFiles,
732+
ChallengeReason.AccessProtectedFile,
733+
)
734+
if (authorized.length === 0) {
735+
throw new Error('Authorization is required to download protected files')
736+
}
737+
}
738+
739+
const zipFileHandle = await window.showSaveFilePicker({
740+
types: [
741+
{
742+
description: 'ZIP file',
743+
accept: { 'application/zip': ['.zip'] },
744+
},
745+
],
746+
})
747+
748+
const toast = addToast({
749+
type: ToastType.Progress,
750+
title: `Downloading ${files.length} files as archive`,
751+
message: 'Preparing archive...',
752+
})
753+
754+
try {
755+
const zip = await import('@zip.js/zip.js')
756+
757+
const zipStream = await zipFileHandle.createWritable()
758+
759+
const zipWriter = new zip.ZipWriter(zipStream, {
760+
level: 0,
761+
})
762+
763+
const addedFilenames: string[] = []
764+
765+
for (const file of files) {
766+
const fileStream = new TransformStream()
767+
768+
let name = parseAndCreateZippableFileName(file.name)
769+
770+
if (addedFilenames.includes(name)) {
771+
name = `${Date.now()} ${name}`
772+
}
773+
774+
zipWriter.add(name, fileStream.readable).catch(console.error)
775+
776+
addedFilenames.push(name)
777+
778+
const writer = fileStream.writable.getWriter()
779+
780+
await this.files
781+
.downloadFile(file, async (bytesChunk, progress) => {
782+
await writer.write(bytesChunk)
783+
updateToast(toast, {
784+
message: `Downloading file "${truncateString(file.name, 20)}"`,
785+
progress: Math.floor(progress.percentComplete),
786+
})
787+
})
788+
.catch(console.error)
789+
790+
await writer.close()
791+
}
792+
793+
await zipWriter.close()
794+
} finally {
795+
dismissToast(toast)
796+
}
797+
798+
addToast({
799+
type: ToastType.Success,
800+
message: `Successfully downloaded ${files.length} files as archive`,
801+
})
802+
}
719803
}

0 commit comments

Comments
 (0)