Skip to content
This repository has been archived by the owner on Jul 26, 2023. It is now read-only.

Folder download #177

Merged
merged 9 commits into from Dec 17, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
84 changes: 65 additions & 19 deletions components/FileListing.tsx
Expand Up @@ -12,11 +12,17 @@ import dynamic from 'next/dynamic'

import { getExtension, getFileIcon, hasKey } from '../utils/getFileIcon'
import { extensions, preview } from '../utils/getPreviewType'
import { getBaseUrl, downloadMultipleFiles, useProtectedSWRInfinite } from '../utils/tools'
import {
getBaseUrl,
traverseFolder,
downloadMultipleFiles,
useProtectedSWRInfinite,
downloadTreelikeMultipleFiles,
} from '../utils/tools'

import { VideoPreview } from './previews/VideoPreview'
import { AudioPreview } from './previews/AudioPreview'
import Loading from './Loading'
import Loading, { LoadingIcon } from './Loading'
import FourOhFour from './FourOhFour'
import Auth from './Auth'
import TextPreview from './previews/TextPreview'
Expand Down Expand Up @@ -144,12 +150,25 @@ const Checkbox: FunctionComponent<{
)
}

const Downloading: FunctionComponent<{ title: string }> = ({ title }) => {
return (
<span title={title} className="p-2 rounded" role="status">
<LoadingIcon
// Use fontawesome far theme via class `svg-inline--fa` to get style `vertical-align` only
// for consistent icon alignment, as class `align-*` cannot satisfy it
className="animate-spin w-4 h-4 inline-block svg-inline--fa"
/>
</span>
)
}

const FileListing: FunctionComponent<{ query?: ParsedUrlQuery }> = ({ query }) => {
const [imageViewerVisible, setImageViewerVisibility] = useState(false)
const [activeImageIdx, setActiveImageIdx] = useState(0)
const [selected, setSelected] = useState<{ [key: string]: boolean }>({})
const [totalSelected, setTotalSelected] = useState<0 | 1 | 2>(0)
const [totalGenerating, setTotalGenerating] = useState<boolean>(false)
const [folderGenerating, setFolderGenerating] = useState<{ [key: string]: boolean }>({})

const router = useRouter()
const clipboard = useClipboard()
Expand Down Expand Up @@ -285,6 +304,35 @@ const FileListing: FunctionComponent<{ query?: ParsedUrlQuery }> = ({ query }) =
}
}

// Folder recursive download
const handleFolderDownload = (path: string, id: string, name?: string) => () => {
const files = (async function* () {
for await (const { meta: c, path: p, isFolder } of traverseFolder(path)) {
yield {
name: c?.name,
url: c ? c['@microsoft.graph.downloadUrl'] : undefined,
path: p,
isFolder,
}
}
})()

setFolderGenerating({ ...folderGenerating, [id]: true })
const toastId = toast.loading('Downloading folder. Refresh to cancel, this may take some time...')

downloadTreelikeMultipleFiles(files, path, name)
.then(() => {
setFolderGenerating({ ...folderGenerating, [id]: false })
toast.dismiss(toastId)
toast.success('Finished downloading folder.')
})
.catch(() => {
setFolderGenerating({ ...folderGenerating, [id]: false })
toast.dismiss(toastId)
toast.error('Failed to download folder.')
})
}

return (
<div className="dark:bg-gray-900 dark:text-gray-100 bg-white rounded shadow">
<div className="dark:border-gray-700 grid items-center grid-cols-12 px-3 space-x-2 border-b border-gray-200">
Expand All @@ -301,23 +349,7 @@ const FileListing: FunctionComponent<{ query?: ParsedUrlQuery }> = ({ query }) =
title={'Select files'}
/>
{totalGenerating ? (
<span title="Downloading selected files, refresh page to cancel" className="p-2 rounded" role="status">
<svg
// Use fontawesome far theme via class `svg-inline--fa` to get style `vertical-align` only
// for consistent icon alignment, as class `align-*` cannot satisfy it
className="animate-spin w-4 h-4 inline-block svg-inline--fa"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</span>
<Downloading title="Downloading selected files, refresh page to cancel" />
) : (
<button
title="Download selected files"
Expand Down Expand Up @@ -400,6 +432,20 @@ const FileListing: FunctionComponent<{ query?: ParsedUrlQuery }> = ({ query }) =
>
<FontAwesomeIcon icon={['far', 'copy']} />
</span>
{folderGenerating[c.id] ? (
<Downloading title="Downloading folder, refresh page to cancel" />
) : (
<span
title="Download folder"
className="hover:bg-gray-300 dark:hover:bg-gray-600 p-2 rounded cursor-pointer"
onClick={() => {
const p = `${path === '/' ? '' : path}/${encodeURIComponent(c.name)}`
handleFolderDownload(p, c.id, c.name)()
}}
>
<FontAwesomeIcon icon={['far', 'arrow-alt-circle-down']} />
</span>
)}
</div>
) : (
<div className="md:flex dark:text-gray-400 hidden p-1 text-gray-700">
Expand Down
28 changes: 15 additions & 13 deletions components/Loading.tsx
Expand Up @@ -3,22 +3,24 @@ import { FunctionComponent } from 'react'
const Loading: FunctionComponent<{ loadingText: string }> = ({ loadingText }) => {
return (
<div className="dark:text-white flex items-center justify-center py-32 space-x-1 rounded">
<svg
className="animate-spin w-5 h-5 mr-3 -ml-1"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<LoadingIcon className="animate-spin w-5 h-5 mr-3 -ml-1" />
<div>{loadingText}</div>
</div>
)
}

// As there is no CSS-in-JS styling system, pass class list to override styles
export const LoadingIcon: FunctionComponent<{ className?: string }> = ({ className }) => {
return (
<svg className={className} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)
}

export default Loading
112 changes: 109 additions & 3 deletions utils/tools.ts
Expand Up @@ -142,17 +142,123 @@ export const downloadMultipleFiles = async (files: { name: string; url: string }
)
})

// Create zip file and prepare for download
const b = await dir.generateAsync({ type: 'blob' })
// Create zip file and download it
const b = await zip.generateAsync({ type: 'blob' })
downloadBlob(b, folder ? folder + '.zip' : 'download.zip')
}

// Blob download helper
const downloadBlob = (b: Blob, name: string) => {
// Prepare for download
const el = document.createElement('a')
el.style.display = 'none'
document.body.appendChild(el)

// Download zip file
const bUrl = window.URL.createObjectURL(b)
el.href = bUrl
el.download = folder ? folder + '.zip' : 'download.zip'
el.download = name
el.click()
window.URL.revokeObjectURL(bUrl)
el.remove()
}

/**
* One-shot concurrent BFS file traversing for the folder.
* Due to react hook limit, we cannot reuse SWR utils for recursive actions.
* We will directly fetch API and arrange responses instead.
* In folder tree, we visit folders with same level concurrently.
* Every time we visit a folder, we fetch and return meta of all its children.
* @param path Folder to be traversed
* @returns Array of items representing folders and files of traversed folder in BFS order and excluding root folder.
* Due to BFS, folder items are ALWAYS in front of its children items.
*/
export async function* traverseFolder(path: string): AsyncGenerator<
{
path: string
meta: any
isFolder: boolean
},
void,
undefined
> {
const hashedToken = getStoredToken(path)
let folderPaths = [path]

while (folderPaths.length > 0) {
const itemLists = await Promise.all(
folderPaths.map(fp =>
(async fp => {
const data = await fetcher(`/api?path=${fp}`, hashedToken ?? undefined)
if (data && data.folder) {
return data.folder.value.map((c: any) => {
const p = `${fp === '/' ? '' : fp}/${encodeURIComponent(c.name)}`
return { path: p, meta: c, isFolder: Boolean(c.folder) }
})
} else {
throw new Error('Path is not folder')
}
})(fp)
)
)

const items = itemLists.flat() as { path: string; meta: any; isFolder: boolean }[]
yield* items
folderPaths = items.filter(i => i.isFolder).map(i => i.path)
}
}

/**
* Download hierarchical tree-like files after compressing them into a zip
* @param files Files to be downloaded. Array of file and folder items excluding root folder.
* Folder items MUST be in front of its children items in the array.
* Use async generator because generation of the array may be slow.
* When waiting for its generation, we can meanwhile download bodies of already got items.
* Only folder items can have url undefined.
* @param basePath Root dir path of files to be downloaded
* @param folder Optional folder name to hold files, otherwise flatten files in the zip
*/
export const downloadTreelikeMultipleFiles = async (
files: AsyncGenerator<{
name: string
url?: string
path: string
isFolder: boolean
}>,
basePath: string,
folder?: string
) => {
const zip = new JSZip()
const root = folder ? zip.folder(folder)! : zip
const map = [{ path: basePath, dir: root }]

// Add selected file blobs to zip according to its path
for await (const { name, url, path, isFolder } of files) {
// Search parent dir in map
const i = map
.slice()
.reverse()
.findIndex(
({ path: parent }) =>
path.substring(0, parent.length) === parent && path.substring(parent.length + 1).indexOf('/') === -1
)
if (i === -1) {
throw new Error('File array does not satisfy requirement')
}

// Add file or folder to zip
const dir = map[map.length - 1 - i].dir
if (isFolder) {
map.push({ path, dir: dir.folder(name)! })
} else {
dir.file(
name,
fetch(url!).then(r => r.blob())
)
}
}

// Create zip file and download it
const b = await zip.generateAsync({ type: 'blob' })
downloadBlob(b, folder ? folder + '.zip' : 'download.zip')
}