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:
topjohncian 2022-07-14 19:20:11 +08:00 committed by GitHub
parent eb322ae208
commit eaafa4b671
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 707 additions and 20 deletions

View File

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

View File

@ -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.",

View 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": "语言"
}
}
}

View File

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

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

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

View File

@ -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 多选
// 取消原有选择

View File

@ -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,
};
};

View File

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

81
src/utils/filesystem.ts Normal file
View File

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

View File

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