/* * 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/. */ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import List from '@mui/material/List'; import ListItemText from '@mui/material/ListItemText'; 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'; import { useEventListener, useMergedRef, useWindowEvent } from '@mantine/hooks'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { makeToast } from '@/modules/core/utils/Toast.ts'; import { BackupRestoreState, ValidateBackupQuery } from '@/lib/graphql/generated/graphql.ts'; import { Progress } from '@/modules/core/components/feedback/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/feedback/LoadingPlaceholder.tsx'; import { EmptyViewAbsoluteCentered } from '@/modules/core/components/feedback/EmptyViewAbsoluteCentered.tsx'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { ServerSettings } from '@/modules/settings/Settings.types.ts'; import { AppRoutes } from '@/modules/core/AppRoute.constants.ts'; import { getErrorMessage } from '@/lib/HelperFunctions.ts'; import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; type BackupSettingsType = Pick; 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 }); }; let backupRestoreId: string | undefined; export function Backup() { const { t } = useTranslation(); const { setTitle, setAction } = useNavBarContext(); useLayoutEffect(() => { setTitle(t('settings.backup.title')); setAction(null); return () => { setTitle(''); setAction(null); }; }, [t]); 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(null); const [isInvalidBackupDialogOpen, setIsInvalidBackupDialogOpen] = useState(false); const [validationResult, setValidationResult] = useState(); const [, setTriggerReRender] = useState(0); const inputRef = useRef(null); 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: Setting, value: BackupSettingsType[Setting], ) => { mutateSettings({ variables: { input: { settings: { [setting]: value } } } }).catch((e) => makeToast(t('global.error.label.failed_to_save_changes'), 'error', getErrorMessage(e)), ); }; 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', getErrorMessage(e)); 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', getErrorMessage(e)); } 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); } }; const closeInvalidBackupDialog = () => { setIsInvalidBackupDialogOpen(false); resetBackupState(); }; useWindowEvent('drop', async (e) => { e.preventDefault(); const files = await fromEvent(e); submitBackup(files[0] as File); }); useWindowEvent('dragover', (e) => { e.preventDefault(); }); const inputEventListenerRef = useEventListener('change', async (event) => { const files = await fromEvent(event); submitBackup(files[0] as File); }); const mergedInputRef = useMergedRef(inputRef, inputEventListenerRef); if (loading) { return ; } if (error) { return ( refetch().catch(defaultPromiseErrorHandler('Backup::refetch'))} /> ); } const backupSettings = extractBackupSettings(settingsData!.settings); return ( <> inputRef.current?.click()} disabled={!!backupRestoreId}> {backupRestoreId ? ( ) : null} Automated backup } > updateSetting('backupPath', path)} /> updateSetting('backupTime', time)} /> updateSetting('backupInterval', interval)} /> updateSetting('backupTTL', ttl)} /> {t('settings.backup.action.validate.dialog.title')} {!!validationResult?.missingSources.length && ( {validationResult?.missingSources.map(({ id, name }) => ( {`${name} (${id})`} ))} )} {!!validationResult?.missingTrackers.length && ( {validationResult?.missingTrackers.map(({ name }) => ( {`${name}`} ))} )} {!!validationResult?.missingSources.length && ( )} {!!validationResult?.missingTrackers.length && ( )} ); }