Merge pull request #177 from myl7/tree-list

Folder download
This commit is contained in:
Spencer Woo 2021-12-17 14:02:00 +08:00 committed by GitHub
commit 476f24164d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 189 additions and 35 deletions

View file

@ -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'
@ -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()
@ -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">
@ -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"
@ -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">

View file

@ -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

View file

@ -142,8 +142,14 @@ 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)
@ -151,8 +157,108 @@ export const downloadMultipleFiles = async (files: { name: string; url: string }
// 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')
}