feat: local backup (#5054)
* feat: local backup * refactor(backup): make local backup helpers synchronous and clean up redundant checks - Converted local backup helpers to synchronous functions to remove unused async warnings and align command signatures. - Updated list/delete/export commands to call the sync feature functions directly without awaits while preserving behavior. - Simplified destination directory creation to always ensure parent folders exist without redundant checks, satisfying Clippy.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import RestoreIcon from "@mui/icons-material/Restore";
|
||||
import {
|
||||
Box,
|
||||
@@ -14,22 +15,20 @@ import {
|
||||
TablePagination,
|
||||
} from "@mui/material";
|
||||
import { Typography } from "@mui/material";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { useLockFn } from "ahooks";
|
||||
import { Dayjs } from "dayjs";
|
||||
import { SVGProps, memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
deleteWebdavBackup,
|
||||
restoreWebDavBackup,
|
||||
restartApp,
|
||||
} from "@/services/cmds";
|
||||
import { restartApp } from "@/services/cmds";
|
||||
import { showNotice } from "@/services/noticeService";
|
||||
|
||||
export type BackupFile = IWebDavFile & {
|
||||
export type BackupFile = {
|
||||
platform: string;
|
||||
backup_time: Dayjs;
|
||||
allow_apply: boolean;
|
||||
filename: string;
|
||||
};
|
||||
|
||||
export const DEFAULT_ROWS_PER_PAGE = 5;
|
||||
@@ -43,6 +42,9 @@ interface BackupTableViewerProps {
|
||||
) => void;
|
||||
total: number;
|
||||
onRefresh: () => Promise<void>;
|
||||
onDelete: (filename: string) => Promise<void>;
|
||||
onRestore: (filename: string) => Promise<void>;
|
||||
onExport?: (filename: string, destination: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const BackupTableViewer = memo(
|
||||
@@ -52,21 +54,43 @@ export const BackupTableViewer = memo(
|
||||
onPageChange,
|
||||
total,
|
||||
onRefresh,
|
||||
onDelete,
|
||||
onRestore,
|
||||
onExport,
|
||||
}: BackupTableViewerProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleDelete = useLockFn(async (filename: string) => {
|
||||
await deleteWebdavBackup(filename);
|
||||
await onDelete(filename);
|
||||
await onRefresh();
|
||||
});
|
||||
|
||||
const handleRestore = useLockFn(async (filename: string) => {
|
||||
await restoreWebDavBackup(filename).then(() => {
|
||||
await onRestore(filename).then(() => {
|
||||
showNotice("success", t("Restore Success, App will restart in 1s"));
|
||||
});
|
||||
await restartApp();
|
||||
});
|
||||
|
||||
const handleExport = useLockFn(async (filename: string) => {
|
||||
if (!onExport) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const savePath = await save({
|
||||
defaultPath: filename,
|
||||
});
|
||||
if (!savePath || Array.isArray(savePath)) {
|
||||
return;
|
||||
}
|
||||
await onExport(filename, savePath);
|
||||
showNotice("success", t("Local Backup Exported"));
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showNotice("error", t("Local Backup Export Failed"));
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
@@ -102,6 +126,27 @@ export const BackupTableViewer = memo(
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
{onExport && (
|
||||
<>
|
||||
<IconButton
|
||||
color="primary"
|
||||
aria-label={t("Export")}
|
||||
size="small"
|
||||
title={t("Export Backup")}
|
||||
onClick={async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
await handleExport(file.filename);
|
||||
}}
|
||||
>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
<Divider
|
||||
orientation="vertical"
|
||||
flexItem
|
||||
sx={{ mx: 1, height: 24 }}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<IconButton
|
||||
color="secondary"
|
||||
aria-label={t("Delete")}
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
import { Box, Divider, Paper } from "@mui/material";
|
||||
import { Box, Button, Divider, Paper, Tab, Tabs } from "@mui/material";
|
||||
import dayjs from "dayjs";
|
||||
import customParseFormat from "dayjs/plugin/customParseFormat";
|
||||
import type { Ref } from "react";
|
||||
import { useCallback, useImperativeHandle, useMemo, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { BaseDialog, BaseLoadingOverlay, DialogRef } from "@/components/base";
|
||||
import { listWebDavBackup } from "@/services/cmds";
|
||||
import {
|
||||
deleteLocalBackup,
|
||||
deleteWebdavBackup,
|
||||
listLocalBackup,
|
||||
listWebDavBackup,
|
||||
exportLocalBackup,
|
||||
restoreLocalBackup,
|
||||
restoreWebDavBackup,
|
||||
} from "@/services/cmds";
|
||||
|
||||
import { BackupConfigViewer } from "./backup-config-viewer";
|
||||
import {
|
||||
@@ -14,19 +30,28 @@ import {
|
||||
BackupTableViewer,
|
||||
DEFAULT_ROWS_PER_PAGE,
|
||||
} from "./backup-table-viewer";
|
||||
import { LocalBackupActions } from "./local-backup-actions";
|
||||
dayjs.extend(customParseFormat);
|
||||
|
||||
const DATE_FORMAT = "YYYY-MM-DD_HH-mm-ss";
|
||||
const FILENAME_PATTERN = /\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}/;
|
||||
type BackupSource = "local" | "webdav";
|
||||
|
||||
export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const [dialogPaper, setDialogPaper] = useState<HTMLElement | null>(null);
|
||||
const [closeButtonPosition, setCloseButtonPosition] = useState<{
|
||||
top: number;
|
||||
left: number;
|
||||
} | null>(null);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [backupFiles, setBackupFiles] = useState<BackupFile[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const [source, setSource] = useState<BackupSource>("local");
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
open: () => {
|
||||
@@ -43,7 +68,51 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
[],
|
||||
);
|
||||
|
||||
const fetchAndSetBackupFiles = async () => {
|
||||
const handleChangeSource = useCallback(
|
||||
(_event: React.SyntheticEvent, newSource: string) => {
|
||||
setSource(newSource as BackupSource);
|
||||
setPage(0);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const buildBackupFile = useCallback((filename: string) => {
|
||||
const platform = filename.split("-")[0];
|
||||
const fileBackupTimeStr = filename.match(FILENAME_PATTERN);
|
||||
|
||||
if (fileBackupTimeStr === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const backupTime = dayjs(fileBackupTimeStr[0], DATE_FORMAT);
|
||||
const allowApply = true;
|
||||
return {
|
||||
filename,
|
||||
platform,
|
||||
backup_time: backupTime,
|
||||
allow_apply: allowApply,
|
||||
} as BackupFile;
|
||||
}, []);
|
||||
|
||||
const getAllBackupFiles = useCallback(async (): Promise<BackupFile[]> => {
|
||||
if (source === "local") {
|
||||
const files = await listLocalBackup();
|
||||
return files
|
||||
.map((file) => buildBackupFile(file.filename))
|
||||
.filter((item): item is BackupFile => item !== null)
|
||||
.sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1));
|
||||
}
|
||||
|
||||
const files = await listWebDavBackup();
|
||||
return files
|
||||
.map((file) => {
|
||||
return buildBackupFile(file.filename);
|
||||
})
|
||||
.filter((item): item is BackupFile => item !== null)
|
||||
.sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1));
|
||||
}, [buildBackupFile, source]);
|
||||
|
||||
const fetchAndSetBackupFiles = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const files = await getAllBackupFiles();
|
||||
@@ -57,31 +126,109 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [getAllBackupFiles]);
|
||||
|
||||
const getAllBackupFiles = async () => {
|
||||
const files = await listWebDavBackup();
|
||||
return files
|
||||
.map((file) => {
|
||||
const platform = file.filename.split("-")[0];
|
||||
const fileBackupTimeStr = file.filename.match(FILENAME_PATTERN)!;
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchAndSetBackupFiles();
|
||||
const paper = contentRef.current?.closest(".MuiPaper-root");
|
||||
setDialogPaper((paper as HTMLElement) ?? null);
|
||||
} else {
|
||||
setDialogPaper(null);
|
||||
}
|
||||
}, [open, fetchAndSetBackupFiles]);
|
||||
|
||||
if (fileBackupTimeStr === null) {
|
||||
return null;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!open || dialogPaper) {
|
||||
return;
|
||||
}
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const paper = contentRef.current?.closest(".MuiPaper-root");
|
||||
setDialogPaper((paper as HTMLElement) ?? null);
|
||||
});
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [open, dialogPaper]);
|
||||
|
||||
const backupTime = dayjs(fileBackupTimeStr[0], DATE_FORMAT);
|
||||
const allowApply = true;
|
||||
return {
|
||||
...file,
|
||||
platform,
|
||||
backup_time: backupTime,
|
||||
allow_apply: allowApply,
|
||||
} as BackupFile;
|
||||
})
|
||||
.filter((item) => item !== null)
|
||||
.sort((a, b) => (a.backup_time.isAfter(b.backup_time) ? -1 : 1));
|
||||
};
|
||||
useEffect(() => {
|
||||
if (!dialogPaper) {
|
||||
setCloseButtonPosition(null);
|
||||
return;
|
||||
}
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatePosition = () => {
|
||||
const rect = dialogPaper.getBoundingClientRect();
|
||||
setCloseButtonPosition({
|
||||
top: rect.bottom - 16,
|
||||
left: rect.right - 24,
|
||||
});
|
||||
};
|
||||
|
||||
updatePosition();
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
if (typeof ResizeObserver !== "undefined") {
|
||||
resizeObserver = new ResizeObserver(() => {
|
||||
updatePosition();
|
||||
});
|
||||
resizeObserver.observe(dialogPaper);
|
||||
}
|
||||
|
||||
const scrollTargets: EventTarget[] = [];
|
||||
const addScrollListener = (target: EventTarget | null) => {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
target.addEventListener("scroll", updatePosition, true);
|
||||
scrollTargets.push(target);
|
||||
};
|
||||
|
||||
addScrollListener(window);
|
||||
addScrollListener(dialogPaper);
|
||||
const dialogContent = dialogPaper.querySelector(".MuiDialogContent-root");
|
||||
addScrollListener(dialogContent);
|
||||
|
||||
window.addEventListener("resize", updatePosition);
|
||||
|
||||
return () => {
|
||||
resizeObserver?.disconnect();
|
||||
scrollTargets.forEach((target) => {
|
||||
target.removeEventListener("scroll", updatePosition, true);
|
||||
});
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
};
|
||||
}, [dialogPaper]);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
async (filename: string) => {
|
||||
if (source === "local") {
|
||||
await deleteLocalBackup(filename);
|
||||
} else {
|
||||
await deleteWebdavBackup(filename);
|
||||
}
|
||||
},
|
||||
[source],
|
||||
);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
async (filename: string) => {
|
||||
if (source === "local") {
|
||||
await restoreLocalBackup(filename);
|
||||
} else {
|
||||
await restoreWebDavBackup(filename);
|
||||
}
|
||||
},
|
||||
[source],
|
||||
);
|
||||
|
||||
const handleExport = useCallback(
|
||||
async (filename: string, destination: string) => {
|
||||
await exportLocalBackup(filename, destination);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const dataSource = useMemo<BackupFile[]>(
|
||||
() =>
|
||||
@@ -96,33 +243,105 @@ export function BackupViewer({ ref }: { ref?: Ref<DialogRef> }) {
|
||||
<BaseDialog
|
||||
open={open}
|
||||
title={t("Backup Setting")}
|
||||
// contentSx={{ width: 600, maxHeight: 800 }}
|
||||
okBtn={t("")}
|
||||
cancelBtn={t("Close")}
|
||||
contentSx={{
|
||||
minWidth: { xs: 320, sm: 620 },
|
||||
maxWidth: "unset",
|
||||
minHeight: 460,
|
||||
}}
|
||||
onClose={() => setOpen(false)}
|
||||
onCancel={() => setOpen(false)}
|
||||
disableOk
|
||||
disableFooter
|
||||
>
|
||||
<Box>
|
||||
<Box
|
||||
ref={contentRef}
|
||||
sx={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<BaseLoadingOverlay isLoading={isLoading} />
|
||||
<Paper elevation={2} sx={{ padding: 2 }}>
|
||||
<BackupConfigViewer
|
||||
setLoading={setIsLoading}
|
||||
onBackupSuccess={fetchAndSetBackupFiles}
|
||||
onSaveSuccess={fetchAndSetBackupFiles}
|
||||
onRefresh={fetchAndSetBackupFiles}
|
||||
onInit={fetchAndSetBackupFiles}
|
||||
/>
|
||||
<Paper
|
||||
elevation={2}
|
||||
sx={{
|
||||
padding: 2,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
flexGrow: 1,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<Tabs
|
||||
value={source}
|
||||
onChange={handleChangeSource}
|
||||
aria-label={t("Select Backup Target")}
|
||||
sx={{ mb: 2 }}
|
||||
>
|
||||
<Tab value="local" label={t("Local Backup")} />
|
||||
<Tab value="webdav" label={t("WebDAV Backup")} />
|
||||
</Tabs>
|
||||
{source === "local" ? (
|
||||
<LocalBackupActions
|
||||
setLoading={setIsLoading}
|
||||
onBackupSuccess={fetchAndSetBackupFiles}
|
||||
onRefresh={fetchAndSetBackupFiles}
|
||||
/>
|
||||
) : (
|
||||
<BackupConfigViewer
|
||||
setLoading={setIsLoading}
|
||||
onBackupSuccess={fetchAndSetBackupFiles}
|
||||
onSaveSuccess={fetchAndSetBackupFiles}
|
||||
onRefresh={fetchAndSetBackupFiles}
|
||||
onInit={fetchAndSetBackupFiles}
|
||||
/>
|
||||
)}
|
||||
<Divider sx={{ marginY: 2 }} />
|
||||
<BackupTableViewer
|
||||
datasource={dataSource}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
total={total}
|
||||
onRefresh={fetchAndSetBackupFiles}
|
||||
/>
|
||||
<Box
|
||||
sx={{
|
||||
flexGrow: 1,
|
||||
overflow: "auto",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<BackupTableViewer
|
||||
datasource={dataSource}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
total={total}
|
||||
onRefresh={fetchAndSetBackupFiles}
|
||||
onDelete={handleDelete}
|
||||
onRestore={handleRestore}
|
||||
onExport={source === "local" ? handleExport : undefined}
|
||||
/>
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
{dialogPaper &&
|
||||
closeButtonPosition &&
|
||||
createPortal(
|
||||
<Box
|
||||
sx={{
|
||||
position: "fixed",
|
||||
top: closeButtonPosition.top,
|
||||
left: closeButtonPosition.left,
|
||||
transform: "translate(-100%, -100%)",
|
||||
pointerEvents: "none",
|
||||
zIndex: (theme) => theme.zIndex.modal + 1,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={() => setOpen(false)}
|
||||
sx={{
|
||||
pointerEvents: "auto",
|
||||
boxShadow: (theme) => theme.shadows[3],
|
||||
backgroundColor: (theme) => theme.palette.background.paper,
|
||||
}}
|
||||
>
|
||||
{t("Close")}
|
||||
</Button>
|
||||
</Box>,
|
||||
dialogPaper,
|
||||
)}
|
||||
</BaseDialog>
|
||||
);
|
||||
}
|
||||
|
||||
78
src/components/setting/mods/local-backup-actions.tsx
Normal file
78
src/components/setting/mods/local-backup-actions.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Button, Grid, Stack, Typography } from "@mui/material";
|
||||
import { useLockFn } from "ahooks";
|
||||
import { memo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { createLocalBackup } from "@/services/cmds";
|
||||
import { showNotice } from "@/services/noticeService";
|
||||
|
||||
interface LocalBackupActionsProps {
|
||||
onBackupSuccess: () => Promise<void>;
|
||||
onRefresh: () => Promise<void>;
|
||||
setLoading: (loading: boolean) => void;
|
||||
}
|
||||
|
||||
export const LocalBackupActions = memo(
|
||||
({ onBackupSuccess, onRefresh, setLoading }: LocalBackupActionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleBackup = useLockFn(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await createLocalBackup();
|
||||
showNotice("success", t("Local Backup Created"));
|
||||
await onBackupSuccess();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showNotice("error", t("Local Backup Failed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleRefresh = useLockFn(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await onRefresh();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, sm: 9 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t("Local Backup Info")}
|
||||
</Typography>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 3 }}>
|
||||
<Stack
|
||||
direction="column"
|
||||
alignItems="stretch"
|
||||
spacing={1.5}
|
||||
sx={{ height: "100%" }}
|
||||
>
|
||||
<Button
|
||||
variant="contained"
|
||||
color="success"
|
||||
onClick={handleBackup}
|
||||
type="button"
|
||||
size="large"
|
||||
>
|
||||
{t("Backup")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
onClick={handleRefresh}
|
||||
type="button"
|
||||
size="large"
|
||||
>
|
||||
{t("Refresh")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user