/* * 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 { useContext, useEffect, 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'; 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 { requestManager } from '@/lib/requests/RequestManager.ts'; import { makeToast } from '@/components/util/Toast'; import { ListItemLink } from '@/components/util/ListItemLink'; import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext'; import { BackupRestoreState, ValidateBackupQuery } from '@/lib/graphql/generated/graphql.ts'; import { Progress } from '@/components/util/Progress.tsx'; import { TextSetting } from '@/components/settings/text/TextSetting.tsx'; import { NumberSetting } from '@/components/settings/NumberSetting.tsx'; import { TimeSetting } from '@/components/settings/TimeSetting.tsx'; import { ServerSettings } from '@/typings.ts'; type BackupSettingsType = Pick; const extractBackupSettings = (settings: ServerSettings): BackupSettingsType => ({ backupPath: settings.backupPath, backupTime: settings.backupTime, backupInterval: settings.backupInterval, backupTTL: settings.backupTTL, }); const getBackupCleanupDisplayValue = (ttl?: number) => { if (ttl === undefined) { return undefined; } 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 } = useContext(NavBarContext); useEffect(() => { setTitle(t('settings.backup.title')); setAction(null); return () => { setTitle(''); setAction(null); }; }, [t]); useSetDefaultBackTo('settings'); const { data: settingsData } = requestManager.useGetServerSettings(); const [mutateSettings] = requestManager.useUpdateServerSettings(); const backupSettings = settingsData ? extractBackupSettings(settingsData.settings) : undefined; const { data } = requestManager.useGetBackupRestoreStatus(backupRestoreId ?? '', { skip: !backupRestoreId, pollInterval: 1000, }); const [currentBackupFile, setCurrentBackupFile] = useState(null); const [isInvalidBackupDialogOpen, setIsInvalidBackupDialogOpen] = useState(false); const [missingSources, setMissingSources] = useState([]); 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: Setting, value: BackupSettingsType[Setting], ) => { mutateSettings({ variables: { input: { settings: { [setting]: value } } } }); }; 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) { setMissingSources([...validateBackupData.missingSources]); 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); } }; 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(); }; useEffect(() => { document.addEventListener('drop', dropHandler); document.addEventListener('dragover', dragOverHandler); const handleFileSelection = async (event: Event) => { const files = await fromEvent(event); submitBackup(files[0] as File); }; const input = document.getElementById('backup-file'); input?.addEventListener('change', handleFileSelection); return () => { document.removeEventListener('drop', dropHandler); document.removeEventListener('dragover', dragOverHandler); input?.removeEventListener('change', handleFileSelection); }; }, []); return ( <> document.getElementById('backup-file')?.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')} {missingSources.map(({ id, name }) => ( {`${name} (${id})`} ))} ); }