Files
suwayomi-material-you-webui/src/modules/backup/screens/Backup.tsx

405 lines
17 KiB
TypeScript
Raw Normal View History

2021-04-10 00:44:13 +04:30
/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
2021-04-10 00:44:13 +04:30
import { useContext, useEffect, useLayoutEffect, useState } from 'react';
2021-09-09 17:51:22 +04:30
import List from '@mui/material/List';
import ListItemText from '@mui/material/ListItemText';
2021-04-10 00:44:13 +04:30
import { fromEvent } from 'file-selector';
import { useTranslation } from 'react-i18next';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListSubheader from '@mui/material/ListSubheader';
import { t as translate } from 'i18next';
import Dialog from '@mui/material/Dialog';
import DialogTitle from '@mui/material/DialogTitle';
import DialogContent from '@mui/material/DialogContent';
import DialogActions from '@mui/material/DialogActions';
import Button from '@mui/material/Button';
import ListItem from '@mui/material/ListItem';
import { Link } from 'react-router-dom';
import Stack from '@mui/material/Stack';
2024-10-05 16:09:34 +02:00
import { requestManager } from '@/lib/requests/requests/RequestManager.ts';
import { makeToast } from '@/lib/ui/Toast.ts';
import { NavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx';
import { BackupRestoreState, ValidateBackupQuery } from '@/lib/graphql/generated/graphql.ts';
2024-10-05 16:09:34 +02:00
import { Progress } from '@/modules/core/components/Progress.tsx';
import { TextSetting } from '@/modules/core/components/settings/text/TextSetting.tsx';
import { NumberSetting } from '@/modules/core/components/settings/NumberSetting.tsx';
import { TimeSetting } from '@/modules/core/components/settings/TimeSetting.tsx';
import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx';
import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
2024-10-05 22:53:22 +02:00
import { ServerSettings } from '@/modules/settings/Settings.types.ts';
type BackupSettingsType = Pick<ServerSettings, 'backupPath' | 'backupTime' | 'backupInterval' | 'backupTTL'>;
const extractBackupSettings = (settings: ServerSettings): BackupSettingsType => ({
backupPath: settings.backupPath,
backupTime: settings.backupTime,
backupInterval: settings.backupInterval,
backupTTL: settings.backupTTL,
});
const getBackupCleanupDisplayValue = (ttl: number): string => {
if (ttl === 0) {
return translate('global.label.never');
}
return translate('settings.backup.automated.cleanup.label.value', { days: ttl, count: ttl });
};
2021-04-10 00:44:13 +04:30
let backupRestoreId: string | undefined;
2023-10-28 00:32:02 +02:00
export function Backup() {
const { t } = useTranslation();
2023-10-28 00:32:02 +02:00
const { setTitle, setAction } = useContext(NavBarContext);
useLayoutEffect(() => {
setTitle(t('settings.backup.title'));
setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]);
2021-04-10 00:44:13 +04:30
2024-04-27 22:28:55 +02:00
const {
data: settingsData,
loading,
error,
refetch,
} = requestManager.useGetServerSettings({ notifyOnNetworkStatusChange: true });
const [mutateSettings] = requestManager.useUpdateServerSettings();
const { data } = requestManager.useGetBackupRestoreStatus(backupRestoreId ?? '', {
skip: !backupRestoreId,
pollInterval: 1000,
});
const [currentBackupFile, setCurrentBackupFile] = useState<File | null>(null);
const [isInvalidBackupDialogOpen, setIsInvalidBackupDialogOpen] = useState(false);
const [validationResult, setValidationResult] = useState<ValidateBackupQuery['validateBackup']>();
const [, setTriggerReRender] = useState(0);
const restoreProgress = (() => {
if (!data?.restoreStatus) {
return 0;
}
const progress = 100 * (data.restoreStatus.mangaProgress / data.restoreStatus.totalManga);
return Number.isNaN(progress) ? 0 : progress;
})();
const updateSetting = <Setting extends keyof BackupSettingsType>(
setting: Setting,
value: BackupSettingsType[Setting],
) => {
mutateSettings({ variables: { input: { settings: { [setting]: value } } } }).catch(() =>
makeToast(t('global.error.label.failed_to_save_changes'), 'error'),
);
};
useEffect(() => {
if (!data?.restoreStatus) {
return;
}
const isSuccess = data.restoreStatus.state === BackupRestoreState.Success;
const isFailure = data.restoreStatus.state === BackupRestoreState.Failure;
const isRestoreFinished = isSuccess || isFailure;
if (isRestoreFinished) {
if (isSuccess) {
makeToast(t('settings.backup.action.restore.label.success'), 'success');
}
if (isFailure) {
makeToast(t('settings.backup.action.restore.error.label.failure'), 'error');
}
backupRestoreId = undefined;
setTriggerReRender(Date.now());
}
}, [data?.restoreStatus?.state]);
const resetBackupState = () => {
setCurrentBackupFile(null);
const input = document.getElementById('backup-file') as HTMLInputElement;
if (input) {
input.value = '';
}
};
const validateBackup = async (file: File) => {
try {
const {
data: { validateBackup: validateBackupData },
} = await requestManager.validateBackupFile(file, { fetchPolicy: 'network-only' }).response;
if (validateBackupData.missingSources.length || validateBackupData.missingTrackers.length) {
setValidationResult(validateBackupData);
setIsInvalidBackupDialogOpen(true);
return false;
}
return true;
} catch (e) {
makeToast(t('settings.backup.action.validate.error.label.failure'), 'error');
resetBackupState();
}
return false;
};
const restoreBackup = async (file: File) => {
try {
makeToast(t('settings.backup.action.restore.label.in_progress'), 'info');
const response = await requestManager.restoreBackupFile(file).response;
backupRestoreId = response.data?.restoreBackup.id;
setTriggerReRender(Date.now());
} catch (e) {
makeToast(t('settings.backup.action.restore.error.label.failure'), 'error');
} finally {
resetBackupState();
}
};
const submitBackup = async (file: File) => {
if (file.name.toLowerCase().endsWith('json')) {
makeToast(t('settings.backup.action.restore.error.label.legacy_backup_unsupported'), 'error');
return;
}
const isValidFilename = file.name.toLowerCase().match(/proto\.gz$|tachibk$/g);
if (!isValidFilename) {
makeToast(t('global.error.label.invalid_file_type'), 'error');
return;
}
setCurrentBackupFile(file);
const isBackupValid = await validateBackup(file);
if (isBackupValid) {
await restoreBackup(file);
2021-08-19 20:42:28 +04:30
}
2021-04-10 00:44:13 +04:30
};
const dropHandler = async (e: Event) => {
e.preventDefault();
const files = await fromEvent(e);
submitBackup(files[0] as File);
};
const dragOverHandler = (e: Event) => {
e.preventDefault();
};
const closeInvalidBackupDialog = () => {
setIsInvalidBackupDialogOpen(false);
resetBackupState();
};
2021-04-10 00:44:13 +04:30
useEffect(() => {
document.addEventListener('drop', dropHandler);
document.addEventListener('dragover', dragOverHandler);
const handleFileSelection = async (event: Event) => {
const files = await fromEvent(event);
2021-04-10 00:44:13 +04:30
submitBackup(files[0] as File);
};
const input = document.getElementById('backup-file');
input?.addEventListener('change', handleFileSelection);
2021-04-10 00:44:13 +04:30
return () => {
document.removeEventListener('drop', dropHandler);
document.removeEventListener('dragover', dragOverHandler);
input?.removeEventListener('change', handleFileSelection);
2021-04-10 00:44:13 +04:30
};
}, []);
2024-04-27 16:24:55 +02:00
if (loading) {
return <LoadingPlaceholder />;
}
2024-04-27 22:28:55 +02:00
if (error) {
return (
2024-04-29 15:29:33 +02:00
<EmptyViewAbsoluteCentered
2024-04-27 22:28:55 +02:00
message={t('global.error.label.failed_to_load_data')}
messageExtra={error.message}
retry={() => refetch().catch(defaultPromiseErrorHandler('Backup::refetch'))}
/>
);
}
const backupSettings = extractBackupSettings(settingsData!.settings);
2021-04-10 00:44:13 +04:30
return (
2021-08-19 20:42:28 +04:30
<>
2021-11-15 18:55:50 +03:30
<List sx={{ padding: 0 }}>
<ListItemButton component="a" href={requestManager.getExportBackupUrl()} download>
<ListItemText
primary={t('settings.backup.action.create.label.title')}
secondary={t('settings.backup.action.create.label.description')}
/>
</ListItemButton>
<ListItemButton
onClick={() => document.getElementById('backup-file')?.click()}
disabled={!!backupRestoreId}
>
2021-08-19 20:42:28 +04:30
<ListItemText
primary={t('settings.backup.action.restore.label.title')}
secondary={t('settings.backup.action.restore.label.description')}
2021-08-19 20:42:28 +04:30
/>
{backupRestoreId ? (
<ListItemIcon>
<Progress progress={restoreProgress} />
</ListItemIcon>
) : null}
</ListItemButton>
<List
subheader={
<ListSubheader component="div" id="backup-settings">
Automated backup
</ListSubheader>
}
>
<TextSetting
settingName={t('settings.backup.automated.location.label.title')}
dialogDescription={t('settings.backup.automated.location.label.description')}
value={backupSettings.backupPath}
settingDescription={
backupSettings.backupPath.length ? backupSettings.backupPath : t('global.label.default')
}
handleChange={(path) => updateSetting('backupPath', path)}
/>
<TimeSetting
settingName={t('settings.backup.automated.label.time')}
value={backupSettings.backupTime}
defaultValue="00:00"
handleChange={(time: string) => updateSetting('backupTime', time)}
/>
<NumberSetting
settingTitle={t('settings.backup.automated.label.interval')}
settingValue={t('global.date.value.label.day', {
days: backupSettings.backupInterval,
count: backupSettings.backupInterval,
})}
value={backupSettings.backupInterval}
defaultValue={1}
minValue={1}
maxValue={31}
stepSize={1}
2024-07-01 19:51:27 +02:00
valueUnit={t('global.date.label.day_one')}
showSlider
handleUpdate={(interval: number) => updateSetting('backupInterval', interval)}
/>
<NumberSetting
settingTitle={t('settings.backup.automated.cleanup.label.title')}
settingValue={getBackupCleanupDisplayValue(backupSettings.backupTTL)}
value={backupSettings.backupTTL}
defaultValue={14}
minValue={0}
maxValue={1000}
stepSize={1}
2024-07-01 19:51:27 +02:00
valueUnit={t('global.date.label.day_one')}
showSlider
handleUpdate={(ttl: number) => updateSetting('backupTTL', ttl)}
/>
</List>
2021-08-19 20:42:28 +04:30
</List>
<input type="file" id="backup-file" style={{ display: 'none' }} />
<Dialog open={isInvalidBackupDialogOpen}>
<DialogTitle>{t('settings.backup.action.validate.dialog.title')}</DialogTitle>
<DialogContent dividers>
{!!validationResult?.missingSources.length && (
<List
sx={{ listStyleType: 'initial', listStylePosition: 'inside' }}
subheader={t('settings.backup.action.validate.dialog.content.label.missing_sources')}
>
{validationResult?.missingSources.map(({ id, name }) => (
<ListItem sx={{ display: 'list-item' }} key={id}>
{`${name} (${id})`}
</ListItem>
))}
</List>
)}
{!!validationResult?.missingTrackers.length && (
<List
sx={{ listStyleType: 'initial', listStylePosition: 'inside' }}
subheader={t('settings.backup.action.validate.dialog.content.label.missing_trackers')}
>
{validationResult?.missingTrackers.map(({ name }) => (
<ListItem sx={{ display: 'list-item' }} key={name}>
{`${name}`}
</ListItem>
))}
</List>
)}
</DialogContent>
<DialogActions>
2024-09-03 17:15:47 +02:00
<Stack
direction="row"
sx={{
justifyContent: 'space-between',
width: '100%',
}}
>
{!!validationResult?.missingSources.length && (
<Button
onClick={closeInvalidBackupDialog}
component={Link}
to="/browse"
autoFocus={!!validationResult?.missingSources.length}
variant={validationResult?.missingSources.length ? 'contained' : 'text'}
>
{t('extension.action.label.install')}
</Button>
)}
{!!validationResult?.missingTrackers.length && (
<Button
onClick={closeInvalidBackupDialog}
component={Link}
to="/settings/trackingSettings"
autoFocus={!!validationResult?.missingTrackers.length}
variant={validationResult?.missingTrackers.length ? 'contained' : 'text'}
>
{t('global.button.log_in')}
</Button>
)}
<Stack direction="row">
<Button onClick={closeInvalidBackupDialog}>{t('global.button.cancel')}</Button>
<Button
onClick={() => {
closeInvalidBackupDialog();
restoreBackup(currentBackupFile!);
}}
autoFocus={
!validationResult?.missingSources.length &&
!validationResult?.missingTrackers.length
}
variant={
!validationResult?.missingSources.length &&
!validationResult?.missingTrackers.length
? 'contained'
: 'text'
}
>
{t('global.button.restore')}
</Button>
</Stack>
</Stack>
</DialogActions>
</Dialog>
2021-08-19 20:42:28 +04:30
</>
2021-04-10 00:44:13 +04:30
);
}