mirror of
https://github.com/cloudreve/frontend.git
synced 2025-12-25 19:52:48 +00:00
Feat: download the whole directory (#123)
* Feat: download directory * Feat: download directory * Chore: remove dev env * Fix duplication handle bugs and add some tips * Fix bugs and add friendlier tips * Fix: directory download is only avaliable in secure contexts * Feat: verify permission and more prompts * Fix: Must be handling a user gesture to show a file picker * Feat: log autoscroll * i18n: download directory * Style: replace directory download icon * Fix: log autoscroll * Feat: cancel download Co-authored-by: HFO4 <912394456@qq.com>
This commit is contained in:
parent
eb322ae208
commit
eaafa4b671
|
|
@ -14,8 +14,10 @@
|
|||
"@types/react": "^16.9.35",
|
||||
"@types/react-dom": "^16.9.8",
|
||||
"@types/streamsaver": "^2.0.1",
|
||||
"@types/wicg-file-system-access": "^2020.9.5",
|
||||
"@typescript-eslint/eslint-plugin": "^2.33.0",
|
||||
"@typescript-eslint/parser": "^2.33.0",
|
||||
"ahooks": "^3.5.2",
|
||||
"artplayer": "^4.3.4",
|
||||
"axios": "^0.21.1",
|
||||
"babel-eslint": "10.0.3",
|
||||
|
|
|
|||
|
|
@ -161,7 +161,17 @@
|
|||
"serverBatchDownloadDescription": "Archive by the server and sent to the client for download on-the-fly.",
|
||||
"selectArchiveMethod": "Select archive method",
|
||||
"batchDownloadStarted": "Batch download has started, please do not close this tab",
|
||||
"batchDownloadError": "Failed to archive: {{msg}}"
|
||||
"batchDownloadError": "Failed to archive: {{msg}}",
|
||||
"userDenied": "User denied.",
|
||||
"directoryDownloadReplace": "Overwrite",
|
||||
"directoryDownloadReplaceDescription": "{{num}} objects including {{duplicates}} will be overwritten.",
|
||||
"directoryDownloadSkip": "Skip",
|
||||
"directoryDownloadSkipDescription": "{{num}} objects including {{duplicates}} will be skipped.",
|
||||
"selectDirectoryDuplicationMethod": "How to handle duplicate files?",
|
||||
"directoryDownloadStarted": "Download started, please do not close this tab.",
|
||||
"directoryDownloadFinished": "Download finished, no failed objects.",
|
||||
"directoryDownloadFinishedWithError": "Download finished, {{failed}} object failed.",
|
||||
"directoryDownloadPermissionError": "Permission denied, please allow read and write local files."
|
||||
},
|
||||
"modals": {
|
||||
"processing": "Processing...",
|
||||
|
|
@ -220,7 +230,16 @@
|
|||
"allowPreview": "Enable preview",
|
||||
"allowPreviewDescription": "Whether to allow preview of file content from the share link",
|
||||
"shareLink": "Share link",
|
||||
"sendLink": "Send the link"
|
||||
"sendLink": "Send the link",
|
||||
"directoryDownloadReplaceNotifiction": "Overwrite {{name}}",
|
||||
"directoryDownloadSkipNotifiction": "Skipped {{name}}",
|
||||
"directoryDownloadTitle": "Download",
|
||||
"directoryDownloadStarted": "Start downloading {{name}}",
|
||||
"directoryDownloadFinished": "Download finished",
|
||||
"directoryDownloadError": "Error: {{msg}}",
|
||||
"directoryDownloadErrorNotification": "Error occurs while download {{name}}: {{msg}}",
|
||||
"directoryDownloadAutoscroll": "Auto scroll",
|
||||
"directoryDownloadCancelled": "Download cancelled"
|
||||
},
|
||||
"uploader": {
|
||||
"fileNotMatchError": "The selected file does not match the original file.",
|
||||
|
|
|
|||
|
|
@ -162,7 +162,16 @@
|
|||
"selectArchiveMethod": "选择打包下载方式",
|
||||
"batchDownloadStarted": "打包下载已开始,请不要关闭此标签页",
|
||||
"batchDownloadError": "打包遇到错误:{{msg}}",
|
||||
"userDenied": "用户拒绝"
|
||||
"userDenied": "用户拒绝",
|
||||
"directoryDownloadReplace": "替换对象",
|
||||
"directoryDownloadReplaceDescription": "将会替换 {{duplicates}} 等共 {{num}} 个对象。",
|
||||
"directoryDownloadSkip": "跳过对象",
|
||||
"directoryDownloadSkipDescription": "将会跳过 {{duplicates}} 等共 {{num}} 个对象。",
|
||||
"selectDirectoryDuplicationMethod": "重复对象处理方式",
|
||||
"directoryDownloadStarted": "下载已开始,请不要关闭此标签页",
|
||||
"directoryDownloadFinished": "下载完成,无失败对象",
|
||||
"directoryDownloadFinishedWithError": "下载完成, 失败 {{failed}} 个对象",
|
||||
"directoryDownloadPermissionError": "无权限操作,请允许读写本地文件"
|
||||
},
|
||||
"modals": {
|
||||
"processing": "处理中...",
|
||||
|
|
@ -221,7 +230,16 @@
|
|||
"allowPreview": "允许预览",
|
||||
"allowPreviewDescription": "是否允许在分享页面预览文件内容",
|
||||
"shareLink": "分享链接",
|
||||
"sendLink": "发送链接"
|
||||
"sendLink": "发送链接",
|
||||
"directoryDownloadReplaceNotifiction": "已覆盖 {{name}}",
|
||||
"directoryDownloadSkipNotifiction": "已跳过 {{name}}",
|
||||
"directoryDownloadTitle": "下载",
|
||||
"directoryDownloadStarted": "开始下载 {{name}}",
|
||||
"directoryDownloadFinished": "下载完成",
|
||||
"directoryDownloadError": "遇到错误:{{msg}}",
|
||||
"directoryDownloadErrorNotification": "下载 {{name}} 遇到错误:{{msg}}",
|
||||
"directoryDownloadAutoscroll": "自动滚动",
|
||||
"directoryDownloadCancelled": "已取消下载"
|
||||
},
|
||||
"uploader": {
|
||||
"fileNotMatchError": "所选择文件与原始文件不符",
|
||||
|
|
@ -259,7 +277,7 @@
|
|||
"hideCompletedTooltip": "列表中不显示已完成、失败、被取消的任务",
|
||||
"hideCompleted": "隐藏已完成任务",
|
||||
"addTimeAscTooltip": "最先添加的任务排在最前",
|
||||
"addTimeAsc":"最先添加靠前",
|
||||
"addTimeAsc": "最先添加靠前",
|
||||
"addTimeDescTooltip": "最后添加的任务排在最前",
|
||||
"addTimeDesc": "最后添加靠前",
|
||||
"showInstantSpeedTooltip": "单个任务上传速度展示为瞬时速度",
|
||||
|
|
@ -292,7 +310,7 @@
|
|||
},
|
||||
"share": {
|
||||
"expireInXDays": "{{num}} 天后到期",
|
||||
"expireInXHours":"{{num}} 小时后到期",
|
||||
"expireInXHours": "{{num}} 小时后到期",
|
||||
"createdBy": "此分享由 <0>{{nick}}</0> 创建",
|
||||
"sharedBy": "<0>{{nick}}</0> 向您分享了 {{num}} 个文件",
|
||||
"statistics": "{{views}} 次浏览 • {{downloads}} 次下载 • {{time}}",
|
||||
|
|
@ -305,9 +323,9 @@
|
|||
"createdAtDesc": "创建日期由晚到早",
|
||||
"createdAtAsc": "创建日期由早到晚",
|
||||
"downloadsDesc": "下载次数由大到小",
|
||||
"downloadsAsc":"下载次数由小到大",
|
||||
"viewsDesc":"浏览次数由大到小",
|
||||
"viewsAsc":"浏览次数由小到大",
|
||||
"downloadsAsc": "下载次数由小到大",
|
||||
"viewsDesc": "浏览次数由大到小",
|
||||
"viewsAsc": "浏览次数由小到大",
|
||||
"noRecords": "没有分享记录.",
|
||||
"sourceNotFound": "[原始对象不存在]",
|
||||
"expired": "已失效",
|
||||
|
|
@ -442,4 +460,4 @@
|
|||
"viewNumber": "浏览次数",
|
||||
"language": "语言"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,7 +18,12 @@ import MoveIcon from "@material-ui/icons/Input";
|
|||
import LinkIcon from "@material-ui/icons/InsertLink";
|
||||
import OpenIcon from "@material-ui/icons/OpenInNew";
|
||||
import ShareIcon from "@material-ui/icons/Share";
|
||||
import { FolderUpload, MagnetOn, FilePlus } from "mdi-material-ui";
|
||||
import {
|
||||
FolderDownload,
|
||||
FolderUpload,
|
||||
MagnetOn,
|
||||
FilePlus,
|
||||
} from "mdi-material-ui";
|
||||
import PropTypes from "prop-types";
|
||||
import React, { Component } from "react";
|
||||
import { connect } from "react-redux";
|
||||
|
|
@ -32,6 +37,7 @@ import {
|
|||
openPreview,
|
||||
setSelectedTarget,
|
||||
startBatchDownload,
|
||||
startDirectoryDownload,
|
||||
startDownload,
|
||||
toggleObjectInfoSidebar,
|
||||
} from "../../redux/explorer/action";
|
||||
|
|
@ -174,6 +180,9 @@ const mapDispatchToProps = (dispatch) => {
|
|||
batchGetSource: () => {
|
||||
dispatch(batchGetSource());
|
||||
},
|
||||
startDirectoryDownload: (share) => {
|
||||
dispatch(startDirectoryDownload(share));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -196,6 +205,10 @@ class ContextMenuCompoment extends Component {
|
|||
this.props.startBatchDownload(this.props.share);
|
||||
};
|
||||
|
||||
openDirectoryDownload = () => {
|
||||
this.props.startDirectoryDownload(this.props.share);
|
||||
};
|
||||
|
||||
openDownload = () => {
|
||||
this.props.startDownload(this.props.share, this.props.selected[0]);
|
||||
};
|
||||
|
|
@ -460,6 +473,24 @@ class ContextMenuCompoment extends Component {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{(this.props.isMultiple || this.props.withFolder) &&
|
||||
window.showDirectoryPicker &&
|
||||
window.isSecureContext && (
|
||||
<MenuItem
|
||||
dense
|
||||
onClick={() =>
|
||||
this.openDirectoryDownload()
|
||||
}
|
||||
>
|
||||
<StyledListItemIcon>
|
||||
<FolderDownload />
|
||||
</StyledListItemIcon>
|
||||
<Typography variant="inherit">
|
||||
{t("fileManager.download")}
|
||||
</Typography>
|
||||
</MenuItem>
|
||||
)}
|
||||
|
||||
{(this.props.isMultiple ||
|
||||
this.props.withFolder) && (
|
||||
<MenuItem
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
} from "@material-ui/core";
|
||||
import Loading from "../Modals/Loading";
|
||||
import CopyDialog from "../Modals/Copy";
|
||||
import DirectoryDownloadDialog from "../Modals/DirectoryDownload";
|
||||
import CreatShare from "../Modals/CreateShare";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import DecompressDialog from "../Modals/Decompress";
|
||||
|
|
@ -1005,6 +1006,12 @@ class ModalsCompoment extends Component {
|
|||
selected={this.props.selected}
|
||||
modalsLoading={this.props.modalsLoading}
|
||||
/>
|
||||
<DirectoryDownloadDialog
|
||||
open={this.props.modalsStatus.directoryDownloading}
|
||||
onClose={this.onClose}
|
||||
done={this.props.modalsStatus.directoryDownloadDone}
|
||||
log={this.props.modalsStatus.directoryDownloadLog}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,107 @@
|
|||
import React, { useCallback, useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Button,
|
||||
CircularProgress,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogContentText,
|
||||
DialogTitle,
|
||||
makeStyles,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
Checkbox,
|
||||
} from "@material-ui/core";
|
||||
import { useDispatch } from "react-redux";
|
||||
import TextField from "@material-ui/core/TextField";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useInterval } from "ahooks";
|
||||
import { cancelDirectoryDownload } from "../../redux/explorer/action";
|
||||
|
||||
const useStyles = makeStyles((theme) => ({
|
||||
contentFix: {
|
||||
padding: "10px 24px 0px 24px",
|
||||
backgroundColor: theme.palette.background.default,
|
||||
},
|
||||
buttonProgress: {
|
||||
color: theme.palette.secondary.light,
|
||||
position: "absolute",
|
||||
top: "50%",
|
||||
left: "50%",
|
||||
marginTop: -12,
|
||||
marginLeft: -12,
|
||||
},
|
||||
}));
|
||||
|
||||
export default function DirectoryDownloadDialog(props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const classes = useStyles();
|
||||
|
||||
const logRef = useRef();
|
||||
const autoScroll = useRef(true);
|
||||
|
||||
useInterval(() => {
|
||||
if (autoScroll.current && !props.done && logRef.current) {
|
||||
logRef.current.scrollIntoView({ behavior: "smooth", block: "end" });
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={props.open}
|
||||
// open
|
||||
onClose={props.onClose}
|
||||
aria-labelledby="form-dialog-title"
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle id="form-dialog-title">
|
||||
{t("modals.directoryDownloadTitle")}
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent className={classes.contentFix}>
|
||||
<TextField
|
||||
value={props.log}
|
||||
ref={logRef}
|
||||
multiline
|
||||
fullWidth
|
||||
autoFocus
|
||||
id="standard-basic"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={autoScroll.current}
|
||||
onChange={() =>
|
||||
(autoScroll.current = !autoScroll.current)
|
||||
}
|
||||
/>
|
||||
}
|
||||
label={t("modals.directoryDownloadAutoscroll")}
|
||||
/>
|
||||
<Button
|
||||
onClick={props.done ? props.onClose : cancelDirectoryDownload}
|
||||
>
|
||||
{t("cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<div className={classes.wrapper}>
|
||||
<Button
|
||||
color="primary"
|
||||
disabled={!props.done}
|
||||
onClick={props.onClose}
|
||||
>
|
||||
{t("ok", { ns: "common" })}
|
||||
{!props.done && (
|
||||
<CircularProgress
|
||||
size={24}
|
||||
className={classes.buttonProgress}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,7 +19,12 @@ import SezrchBar from "./SearchBar";
|
|||
import StorageBar from "./StorageBar";
|
||||
import UserAvatar from "./UserAvatar";
|
||||
import UserInfo from "./UserInfo";
|
||||
import { AccountArrowRight, AccountPlus, LogoutVariant } from "mdi-material-ui";
|
||||
import {
|
||||
FolderDownload,
|
||||
AccountArrowRight,
|
||||
AccountPlus,
|
||||
LogoutVariant,
|
||||
} from "mdi-material-ui";
|
||||
import { withRouter } from "react-router-dom";
|
||||
import {
|
||||
AppBar,
|
||||
|
|
@ -63,7 +68,11 @@ import {
|
|||
showImgPreivew,
|
||||
toggleSnackbar,
|
||||
} from "../../redux/explorer";
|
||||
import { startBatchDownload, startDownload } from "../../redux/explorer/action";
|
||||
import {
|
||||
startBatchDownload,
|
||||
startDirectoryDownload,
|
||||
startDownload,
|
||||
} from "../../redux/explorer/action";
|
||||
import { withTranslation } from "react-i18next";
|
||||
|
||||
vhCheck();
|
||||
|
|
@ -145,6 +154,9 @@ const mapDispatchToProps = (dispatch) => {
|
|||
startBatchDownload: (share) => {
|
||||
dispatch(startBatchDownload(share));
|
||||
},
|
||||
startDirectoryDownload: (share) => {
|
||||
dispatch(startDirectoryDownload(share));
|
||||
},
|
||||
startDownload: (share, file) => {
|
||||
dispatch(startDownload(share, file));
|
||||
},
|
||||
|
|
@ -334,6 +346,10 @@ class NavbarCompoment extends Component {
|
|||
this.props.startDownload(this.props.shareInfo, this.props.selected[0]);
|
||||
};
|
||||
|
||||
openDirectoryDownload = (e) => {
|
||||
this.props.startDirectoryDownload(this.props.shareInfo);
|
||||
};
|
||||
|
||||
archiveDownload = (e) => {
|
||||
this.props.startBatchDownload(this.props.shareInfo);
|
||||
};
|
||||
|
|
@ -688,6 +704,35 @@ class NavbarCompoment extends Component {
|
|||
</Tooltip>
|
||||
</Grow>
|
||||
)}
|
||||
{(this.props.isMultiple ||
|
||||
this.props.withFolder) &&
|
||||
window.showDirectoryPicker &&
|
||||
window.isSecureContext && (
|
||||
<Grow
|
||||
in={
|
||||
(this.props.isMultiple ||
|
||||
this.props
|
||||
.withFolder) &&
|
||||
window.showDirectoryPicker &&
|
||||
window.isSecureContext
|
||||
}
|
||||
>
|
||||
<Tooltip
|
||||
title={t(
|
||||
"fileManager.download"
|
||||
)}
|
||||
>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={() =>
|
||||
this.openDirectoryDownload()
|
||||
}
|
||||
>
|
||||
<FolderDownload />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Grow>
|
||||
)}
|
||||
{(this.props.isMultiple ||
|
||||
this.props.withFolder) && (
|
||||
<Grow
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { push } from "connected-react-router";
|
|||
import {
|
||||
changeContextMenu,
|
||||
closeAllModals,
|
||||
openDirectoryDownloadDialog,
|
||||
openGetSourceDialog,
|
||||
openLoadingDialog,
|
||||
showAudioPreview,
|
||||
|
|
@ -25,6 +26,11 @@ import {
|
|||
} from "./index";
|
||||
import { getDownloadURL } from "../../services/file";
|
||||
import i18next from "../../i18n";
|
||||
import {
|
||||
getFileSystemDirectoryPaths,
|
||||
saveFileToFileSystemDirectory,
|
||||
verifyFileSystemRWPermission,
|
||||
} from "../../utils/filesystem";
|
||||
|
||||
export interface ActionSetFileList extends AnyAction {
|
||||
type: "SET_FILE_LIST";
|
||||
|
|
@ -444,6 +450,306 @@ export const startBatchDownload = (
|
|||
};
|
||||
};
|
||||
|
||||
let directoryDownloadAbortController: AbortController;
|
||||
export const cancelDirectoryDownload = () =>
|
||||
directoryDownloadAbortController.abort();
|
||||
|
||||
export const startDirectoryDownload = (
|
||||
share: any
|
||||
): ThunkAction<any, any, any, any> => {
|
||||
return async (dispatch, getState): Promise<void> => {
|
||||
directoryDownloadAbortController = new AbortController();
|
||||
if (!window.showDirectoryPicker || !window.isSecureContext) {
|
||||
return;
|
||||
}
|
||||
let handle: FileSystemDirectoryHandle;
|
||||
// we should show directory picker at first
|
||||
// https://web.dev/file-system-access/#:~:text=handle%3B%0A%7D-,Gotchas,-Sometimes%20processing%20the
|
||||
try {
|
||||
// can't use suggestedName for showDirectoryPicker (only available showSaveFilePicker)
|
||||
handle = await window.showDirectoryPicker({
|
||||
startIn: "downloads",
|
||||
mode: "readwrite",
|
||||
});
|
||||
// we should obtain the readwrite permission for the directory at first
|
||||
if (!(await verifyFileSystemRWPermission(handle))) {
|
||||
throw new Error(
|
||||
i18next.t("fileManager.directoryDownloadPermissionError")
|
||||
);
|
||||
}
|
||||
dispatch(closeAllModals());
|
||||
} catch (e) {
|
||||
dispatch(
|
||||
toggleSnackbar(
|
||||
"top",
|
||||
"right",
|
||||
i18next.t("modals.directoryDownloadError", {
|
||||
msg: e && e.message,
|
||||
}),
|
||||
"error"
|
||||
)
|
||||
);
|
||||
dispatch(closeAllModals());
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(changeContextMenu("file", false));
|
||||
const {
|
||||
explorer: { selected },
|
||||
navigator: { path },
|
||||
} = getState();
|
||||
|
||||
// list files to download
|
||||
dispatch(openLoadingDialog(i18next.t("modals.listingFiles")));
|
||||
|
||||
let queue: CloudreveFile[] = [];
|
||||
try {
|
||||
queue = await walk(selected, share);
|
||||
} catch (e) {
|
||||
dispatch(
|
||||
toggleSnackbar(
|
||||
"top",
|
||||
"right",
|
||||
i18next.t("modals.listingFileError", {
|
||||
message: e.message,
|
||||
}),
|
||||
"warning"
|
||||
)
|
||||
);
|
||||
dispatch(closeAllModals());
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(closeAllModals());
|
||||
|
||||
let failed = 0;
|
||||
let option: any;
|
||||
// preparation for downloading
|
||||
// get the files in the directory to compare with queue files
|
||||
// parent: ""
|
||||
const fsPaths = await getFileSystemDirectoryPaths(handle, "");
|
||||
|
||||
// path: / or /abc (no sep suffix)
|
||||
// file.path: /abc/d (no sep suffix)
|
||||
// fsPaths: ["abc/d/e.bin",]
|
||||
const duplicates = queue
|
||||
.map((file) =>
|
||||
trimPrefix(
|
||||
`${file.path}/${file.name}`,
|
||||
path === "/" ? "/" : path + "/"
|
||||
)
|
||||
)
|
||||
.filter((path) => fsPaths.includes(path));
|
||||
|
||||
// we should ask users for the duplication handle method
|
||||
if (duplicates.length > 0) {
|
||||
try {
|
||||
option = await dispatch(
|
||||
askForOption(
|
||||
[
|
||||
{
|
||||
key: "replace",
|
||||
name: i18next.t(
|
||||
"fileManager.directoryDownloadReplace"
|
||||
),
|
||||
description: i18next.t(
|
||||
"fileManager.directoryDownloadReplaceDescription",
|
||||
{
|
||||
// display the first three duplications
|
||||
duplicates: duplicates
|
||||
.slice(
|
||||
0,
|
||||
duplicates.length >= 3
|
||||
? 3
|
||||
: duplicates.length
|
||||
)
|
||||
.join(", "),
|
||||
num: duplicates.length,
|
||||
}
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "skip",
|
||||
name: i18next.t(
|
||||
"fileManager.directoryDownloadSkip"
|
||||
),
|
||||
description: i18next.t(
|
||||
"fileManager.directoryDownloadSkipDescription",
|
||||
{
|
||||
duplicates: duplicates
|
||||
.slice(
|
||||
0,
|
||||
duplicates.length >= 3
|
||||
? 3
|
||||
: duplicates.length
|
||||
)
|
||||
.join(", "),
|
||||
num: duplicates.length,
|
||||
}
|
||||
),
|
||||
},
|
||||
],
|
||||
i18next.t(
|
||||
"fileManager.selectDirectoryDuplicationMethod"
|
||||
)
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
dispatch(closeAllModals());
|
||||
|
||||
// start the download
|
||||
dispatch(
|
||||
toggleSnackbar(
|
||||
"top",
|
||||
"center",
|
||||
i18next.t("fileManager.directoryDownloadStarted"),
|
||||
"info"
|
||||
)
|
||||
);
|
||||
|
||||
const updateLog = (log, done) => {
|
||||
dispatch(openDirectoryDownloadDialog(true, log, done));
|
||||
};
|
||||
let log = "";
|
||||
|
||||
while (queue.length > 0) {
|
||||
const next = queue.pop();
|
||||
if (next && next.type === "file") {
|
||||
// donload url
|
||||
const previewPath = getPreviewPath(next);
|
||||
const url =
|
||||
getBaseURL() +
|
||||
(pathHelper.isSharePage(location.pathname)
|
||||
? "/share/preview/" +
|
||||
share.key +
|
||||
(previewPath !== "" ? "?path=" + previewPath : "")
|
||||
: "/file/preview/" + next.id);
|
||||
|
||||
// path to save this file
|
||||
// path: / or /abc (no sep suffix)
|
||||
// next.path: /abc/d (no sep suffix)
|
||||
// name: d/e.bin
|
||||
const name = trimPrefix(
|
||||
pathJoin([next.path, next.name]),
|
||||
path === "/" ? "/" : path + "/"
|
||||
);
|
||||
// TODO: improve the display of log
|
||||
// can we turn the upload queue component to the transition queue?
|
||||
// then we can easily cancel or retry the download
|
||||
// and the batch download queue can show as well.
|
||||
log =
|
||||
(log === "" ? "" : log + "\n\n") +
|
||||
i18next.t("modals.directoryDownloadStarted", { name });
|
||||
updateLog(log, false);
|
||||
try {
|
||||
if (duplicates.includes(name)) {
|
||||
if (option.key === "skip") {
|
||||
log +=
|
||||
"\n" +
|
||||
i18next.t(
|
||||
"modals.directoryDownloadSkipNotifiction",
|
||||
{
|
||||
name,
|
||||
}
|
||||
);
|
||||
updateLog(log, false);
|
||||
continue;
|
||||
} else {
|
||||
log +=
|
||||
"\n" +
|
||||
i18next.t(
|
||||
"modals.directoryDownloadReplaceNotifiction",
|
||||
{
|
||||
name,
|
||||
}
|
||||
);
|
||||
updateLog(log, false);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: need concurrent task queue?
|
||||
const res = await fetch(url, {
|
||||
cache: "no-cache",
|
||||
signal: directoryDownloadAbortController.signal,
|
||||
});
|
||||
await saveFileToFileSystemDirectory(
|
||||
handle,
|
||||
await res.blob(),
|
||||
name
|
||||
);
|
||||
log += "\n" + i18next.t("modals.directoryDownloadFinished");
|
||||
updateLog(log, false);
|
||||
} catch (e) {
|
||||
if (e.name === "AbortError") {
|
||||
dispatch(
|
||||
toggleSnackbar(
|
||||
"top",
|
||||
"right",
|
||||
i18next.t("modals.directoryDownloadCancelled"),
|
||||
"warning"
|
||||
)
|
||||
);
|
||||
log +=
|
||||
"\n\n" +
|
||||
i18next.t("modals.directoryDownloadCancelled");
|
||||
updateLog(log, true);
|
||||
return;
|
||||
}
|
||||
|
||||
failed++;
|
||||
dispatch(
|
||||
toggleSnackbar(
|
||||
"top",
|
||||
"right",
|
||||
i18next.t(
|
||||
"modals.directoryDownloadErrorNotification",
|
||||
{
|
||||
name,
|
||||
msg: e && e.message,
|
||||
}
|
||||
),
|
||||
"warning"
|
||||
)
|
||||
);
|
||||
log +=
|
||||
"\n" +
|
||||
i18next.t("modals.directoryDownloadError", {
|
||||
msg: e.message,
|
||||
});
|
||||
updateLog(log, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
log +=
|
||||
"\n" +
|
||||
(failed === 0
|
||||
? i18next.t("fileManager.directoryDownloadFinished")
|
||||
: i18next.t("fileManager.directoryDownloadFinishedWithError", {
|
||||
failed,
|
||||
}));
|
||||
updateLog(log, true);
|
||||
|
||||
dispatch(
|
||||
toggleSnackbar(
|
||||
"top",
|
||||
"center",
|
||||
failed === 0
|
||||
? i18next.t("fileManager.directoryDownloadFinished")
|
||||
: i18next.t(
|
||||
"fileManager.directoryDownloadFinishedWithError",
|
||||
{
|
||||
failed,
|
||||
}
|
||||
),
|
||||
"success"
|
||||
)
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export const getViewerURL = (
|
||||
viewer: string,
|
||||
file: any,
|
||||
|
|
@ -543,13 +849,8 @@ export const selectFile = (file: any, event: any, fileIndex: any) => {
|
|||
}
|
||||
const isMacbook = isMac();
|
||||
const { explorer } = getState();
|
||||
const {
|
||||
selected,
|
||||
lastSelect,
|
||||
dirList,
|
||||
fileList,
|
||||
shiftSelectedIds,
|
||||
} = explorer;
|
||||
const { selected, lastSelect, dirList, fileList, shiftSelectedIds } =
|
||||
explorer;
|
||||
if (shiftKey && !ctrlKey && !metaKey && selected.length !== 0) {
|
||||
// shift 多选
|
||||
// 取消原有选择
|
||||
|
|
|
|||
|
|
@ -281,3 +281,12 @@ export const setSiteConfig = (config) => {
|
|||
config: config,
|
||||
};
|
||||
};
|
||||
|
||||
export const openDirectoryDownloadDialog = (downloading, log, done) => {
|
||||
return {
|
||||
type: "OPEN_DIRECTORY_DOWNLOAD_DIALOG",
|
||||
downloading,
|
||||
log,
|
||||
done,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -35,6 +35,9 @@ export interface ViewUpdateState {
|
|||
decompress: boolean;
|
||||
loading: boolean;
|
||||
loadingText: string;
|
||||
directoryDownloading: boolean;
|
||||
directoryDownloadLog: string;
|
||||
directoryDownloadDone: boolean;
|
||||
option?: {
|
||||
options: {
|
||||
open: boolean;
|
||||
|
|
@ -96,6 +99,9 @@ export const initState: ViewUpdateState = {
|
|||
decompress: false,
|
||||
loading: false,
|
||||
loadingText: "",
|
||||
directoryDownloading: false,
|
||||
directoryDownloadLog: "",
|
||||
directoryDownloadDone: false,
|
||||
},
|
||||
snackbar: {
|
||||
toggle: false,
|
||||
|
|
@ -246,6 +252,15 @@ const viewUpdate = (state: ViewUpdateState = initState, action: AnyAction) => {
|
|||
}),
|
||||
contextOpen: false,
|
||||
});
|
||||
case "OPEN_DIRECTORY_DOWNLOAD_DIALOG":
|
||||
return Object.assign({}, state, {
|
||||
modals: Object.assign({}, state.modals, {
|
||||
directoryDownloading: action.downloading,
|
||||
directoryDownloadLog: action.log,
|
||||
directoryDownloadDone: action.done,
|
||||
}),
|
||||
contextOpen: false,
|
||||
});
|
||||
case "CLOSE_CONTEXT_MENU":
|
||||
return Object.assign({}, state, {
|
||||
contextOpen: false,
|
||||
|
|
@ -269,6 +284,9 @@ const viewUpdate = (state: ViewUpdateState = initState, action: AnyAction) => {
|
|||
compress: false,
|
||||
decompress: false,
|
||||
option: undefined,
|
||||
directoryDownloading: false,
|
||||
directoryDownloadLog: "",
|
||||
directoryDownloadDone: false,
|
||||
}),
|
||||
});
|
||||
case "TOGGLE_SNACKBAR":
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
// get the paths of files (no directories) in the directory
|
||||
// parent: "" or "/"
|
||||
export const getFileSystemDirectoryPaths = async (
|
||||
handle: FileSystemDirectoryHandle,
|
||||
parent = ""
|
||||
): Promise<string[]> => {
|
||||
const paths: Array<string> = [];
|
||||
|
||||
for await (const [path, fileSystemHandle] of handle.entries()) {
|
||||
if (fileSystemHandle instanceof window.FileSystemFileHandle) {
|
||||
paths.push(`${parent}${path}`);
|
||||
} else {
|
||||
paths.push(
|
||||
...(await getFileSystemDirectoryPaths(
|
||||
fileSystemHandle,
|
||||
`${parent}${path}/`
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
};
|
||||
|
||||
// create the dst directory if it doesn't exist
|
||||
// return the dst directory handle
|
||||
// paths: "/dir1/dir2" => ["dir1","dir2"]
|
||||
export const createFileSystemDirectory = async (
|
||||
handle: FileSystemDirectoryHandle,
|
||||
paths: string[]
|
||||
) => {
|
||||
let cur = handle;
|
||||
while (paths.length > 0) {
|
||||
const path = paths.shift();
|
||||
if (!path) {
|
||||
break;
|
||||
}
|
||||
cur = await cur.getDirectoryHandle(path, { create: true });
|
||||
}
|
||||
return cur;
|
||||
};
|
||||
|
||||
// save file into the dst directory
|
||||
// create the dst file if it doesn't exist by default
|
||||
// path: a/b/c.jpg
|
||||
export const saveFileToFileSystemDirectory = async (
|
||||
handle: FileSystemDirectoryHandle,
|
||||
stream: FileSystemWriteChunkType,
|
||||
path: string,
|
||||
create = true
|
||||
) => {
|
||||
const paths = path.split("/");
|
||||
const fileName = paths.pop();
|
||||
if (!fileName) return;
|
||||
|
||||
const dir = await createFileSystemDirectory(handle, paths);
|
||||
const file = await dir.getFileHandle(fileName, { create });
|
||||
const writable = await file.createWritable();
|
||||
await writable.write(stream);
|
||||
await writable.close();
|
||||
};
|
||||
|
||||
// verify or request the permission of the readwrite permission
|
||||
export async function verifyFileSystemRWPermission(
|
||||
fileHandle: FileSystemDirectoryHandle
|
||||
) {
|
||||
const opts = { mode: "readwrite" as FileSystemPermissionMode };
|
||||
|
||||
// Check if we already have permission, if so, return true.
|
||||
if ((await fileHandle.queryPermission(opts)) === "granted") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Request permission to the file, if the user grants permission, return true.
|
||||
if ((await fileHandle.requestPermission(opts)) === "granted") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// The user did not grant permission, return false.
|
||||
return false;
|
||||
}
|
||||
49
yarn.lock
49
yarn.lock
|
|
@ -1775,6 +1775,11 @@
|
|||
jest-diff "^25.2.1"
|
||||
pretty-format "^25.2.1"
|
||||
|
||||
"@types/js-cookie@^2.x.x":
|
||||
version "2.2.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/js-cookie/-/js-cookie-2.2.7.tgz#226a9e31680835a6188e887f3988e60c04d3f6a3"
|
||||
integrity sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==
|
||||
|
||||
"@types/json-schema@^7.0.3", "@types/json-schema@^7.0.5":
|
||||
version "7.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.9.tgz#97edc9037ea0c38585320b28964dde3b39e4660d"
|
||||
|
|
@ -1875,6 +1880,11 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/streamsaver/-/streamsaver-2.0.1.tgz#fa5e5b891d1b282be3078c232a30ee004b8e0be0"
|
||||
integrity sha512-I49NtT8w6syBI3Zg3ixCyygTHoTVMY0z2TMRcTgccdIsVd2MwlKk7ITLHLsJtgchUHcOd7QEARG9h0ifcA6l2Q==
|
||||
|
||||
"@types/wicg-file-system-access@^2020.9.5":
|
||||
version "2020.9.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.5.tgz#4a0c8f3d1ed101525f329e86c978f7735404474f"
|
||||
integrity sha512-UYK244awtmcUYQfs7FR8710MJcefL2WvkyHMjA8yJzxd1mo0Gfn88sRZ1Bls7hiUhA2w7ne1gpJ9T5g3G0wOyA==
|
||||
|
||||
"@types/yargs-parser@*":
|
||||
version "20.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-20.2.1.tgz#3b9ce2489919d9e4fea439b76916abc34b2df129"
|
||||
|
|
@ -2172,6 +2182,25 @@ adjust-sourcemap-loader@2.0.0:
|
|||
object-path "0.11.4"
|
||||
regex-parser "2.2.10"
|
||||
|
||||
ahooks-v3-count@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/ahooks-v3-count/-/ahooks-v3-count-1.0.0.tgz#ddeb392e009ad6e748905b3cbf63a9fd8262ca80"
|
||||
integrity sha512-V7uUvAwnimu6eh/PED4mCDjE7tokeZQLKlxg9lCTMPhN+NjsSbtdacByVlR1oluXQzD3MOw55wylDmQo4+S9ZQ==
|
||||
|
||||
ahooks@^3.5.2:
|
||||
version "3.5.2"
|
||||
resolved "https://registry.yarnpkg.com/ahooks/-/ahooks-3.5.2.tgz#283e4b2796d3e00f0a280f0f09e2517feaa434e3"
|
||||
integrity sha512-0OrV3/9s339comBg/F+d9Q7lhZERZK3K5f1J8ebK3z076Kc4KkeGc1MLalFRG+95nVA0wUCZ71oJMs66fIU2Uw==
|
||||
dependencies:
|
||||
"@types/js-cookie" "^2.x.x"
|
||||
ahooks-v3-count "^1.0.0"
|
||||
dayjs "^1.9.1"
|
||||
intersection-observer "^0.12.0"
|
||||
js-cookie "^2.x.x"
|
||||
lodash "^4.17.21"
|
||||
resize-observer-polyfill "^1.5.1"
|
||||
screenfull "^5.0.0"
|
||||
|
||||
ajv-errors@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ajv-errors/-/ajv-errors-1.0.1.tgz#f35986aceb91afadec4102fbd85014950cefa64d"
|
||||
|
|
@ -3980,6 +4009,11 @@ dayjs@^1.10.4:
|
|||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.8.tgz#267df4bc6276fcb33c04a6735287e3f429abec41"
|
||||
integrity sha512-wbNwDfBHHur9UOzNUjeKUOJ0fCb0a52Wx0xInmQ7Y8FstyajiV1NmK1e00cxsr9YrE9r7yAChE0VvpuY5Rnlow==
|
||||
|
||||
dayjs@^1.9.1:
|
||||
version "1.11.3"
|
||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.3.tgz#4754eb694a624057b9ad2224b67b15d552589258"
|
||||
integrity sha512-xxwlswWOlGhzgQ4TKzASQkUhqERI3egRNqgV4ScR8wlANA/A9tZ7miXa44vTTKEq5l7vWoL5G57bG3zA+Kow0A==
|
||||
|
||||
debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9:
|
||||
version "2.6.9"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
|
||||
|
|
@ -6021,6 +6055,11 @@ internmap@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95"
|
||||
integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==
|
||||
|
||||
intersection-observer@^0.12.0:
|
||||
version "0.12.2"
|
||||
resolved "https://registry.yarnpkg.com/intersection-observer/-/intersection-observer-0.12.2.tgz#4a45349cc0cd91916682b1f44c28d7ec737dc375"
|
||||
integrity sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==
|
||||
|
||||
invariant@^2.2.2, invariant@^2.2.4:
|
||||
version "2.2.4"
|
||||
resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6"
|
||||
|
|
@ -6846,6 +6885,11 @@ jest@24.9.0:
|
|||
import-local "^2.0.0"
|
||||
jest-cli "^24.9.0"
|
||||
|
||||
js-cookie@^2.x.x:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-2.2.1.tgz#69e106dc5d5806894562902aa5baec3744e9b2b8"
|
||||
integrity sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==
|
||||
|
||||
"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||
|
|
@ -10374,6 +10418,11 @@ schema-utils@^2.0.0, schema-utils@^2.0.1, schema-utils@^2.2.0:
|
|||
ajv "^6.12.4"
|
||||
ajv-keywords "^3.5.2"
|
||||
|
||||
screenfull@^5.0.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-5.2.0.tgz#6533d524d30621fc1283b9692146f3f13a93d1ba"
|
||||
integrity sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==
|
||||
|
||||
screenfull@^6.0.1:
|
||||
version "6.0.1"
|
||||
resolved "https://registry.yarnpkg.com/screenfull/-/screenfull-6.0.1.tgz#3b71e6f06b72d817a8d3be73c45ebe71fa8da1ce"
|
||||
|
|
|
|||
Loading…
Reference in New Issue