Feature/restore backup inform about missing sources (#523)

* Validate backup before starting restore process

In case sources were missing, the user was never informed and thus, did not know which sources were required for all manga to work

* Reset file input after restoring

Otherwise, the same backup file can't be restored until the page gets refreshed/re-opened
This commit is contained in:
schroda
2023-12-28 16:08:13 +01:00
committed by GitHub
parent a1dca02fea
commit 120e97e882
2 changed files with 156 additions and 29 deletions

View File

@@ -258,6 +258,7 @@
"popular": "Popular", "popular": "Popular",
"reset": "Reset", "reset": "Reset",
"reset_to_default": "Reset to Default", "reset_to_default": "Reset to Default",
"restore": "Restore",
"resume": "Resume", "resume": "Resume",
"select": "Select", "select": "Select",
"select_all": "Select all", "select_all": "Select all",
@@ -617,6 +618,44 @@
} }
}, },
"backup": { "backup": {
"action": {
"create": {
"label": {
"description": "Back up library as a Tachiyomi backup",
"title": "Create backup"
}
},
"restore": {
"button": "Restore",
"error": {
"label": {
"failure": "Could not restore backup",
"legacy_backup_unsupported": "legacy backups are not supported!"
}
},
"label": {
"description": "You can also drag and drop the backup file here to restore it",
"in_progress": "Restoring backup…",
"success": "Backup restored.",
"title": "Restore Backup"
}
},
"validate": {
"dialog": {
"content": {
"label": {
"missing_sources": "The following sources are not installed:"
}
},
"title": "Backup validation"
},
"error": {
"label": {
"failure": "Could not validate backup"
}
}
}
},
"automated": { "automated": {
"cleanup": { "cleanup": {
"label": { "label": {
@@ -635,16 +674,6 @@
} }
} }
}, },
"label": {
"backup_restore_failed": "Could not restore backup",
"create_backup": "Create backup",
"create_backup_info": "Back up library as a Tachiyomi backup",
"legacy_backup_unsupported": "legacy backups are not supported!",
"restore_backup": "Restore Backup",
"restore_backup_info": "You can also drag and drop the backup file here to restore it",
"restored_backup": "Backup restored.",
"restoring_backup": "Restoring backup…"
},
"title": "Backup" "title": "Backup"
}, },
"clear_cache": { "clear_cache": {

View File

@@ -15,11 +15,18 @@ import { ListItemButton } from '@mui/material';
import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemIcon from '@mui/material/ListItemIcon';
import ListSubheader from '@mui/material/ListSubheader'; import ListSubheader from '@mui/material/ListSubheader';
import { t as translate } from 'i18next'; 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 { requestManager } from '@/lib/requests/RequestManager.ts';
import { makeToast } from '@/components/util/Toast'; import { makeToast } from '@/components/util/Toast';
import { ListItemLink } from '@/components/util/ListItemLink'; import { ListItemLink } from '@/components/util/ListItemLink';
import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext'; import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext';
import { BackupRestoreState } from '@/lib/graphql/generated/graphql.ts'; import { BackupRestoreState, ValidateBackupQuery } from '@/lib/graphql/generated/graphql.ts';
import { Progress } from '@/components/util/Progress.tsx'; import { Progress } from '@/components/util/Progress.tsx';
import { TextSetting } from '@/components/settings/TextSetting.tsx'; import { TextSetting } from '@/components/settings/TextSetting.tsx';
import { NumberSetting } from '@/components/settings/NumberSetting.tsx'; import { NumberSetting } from '@/components/settings/NumberSetting.tsx';
@@ -69,6 +76,10 @@ export function Backup() {
pollInterval: 1000, pollInterval: 1000,
}); });
const [currentBackupFile, setCurrentBackupFile] = useState<File | null>(null);
const [isInvalidBackupDialogOpen, setIsInvalidBackupDialogOpen] = useState(false);
const [missingSources, setMissingSources] = useState<ValidateBackupQuery['validateBackup']['missingSources']>([]);
const [, setTriggerReRender] = useState(0); const [, setTriggerReRender] = useState(0);
const restoreProgress = (() => { const restoreProgress = (() => {
@@ -98,11 +109,11 @@ export function Backup() {
const isRestoreFinished = isSuccess || isFailure; const isRestoreFinished = isSuccess || isFailure;
if (isRestoreFinished) { if (isRestoreFinished) {
if (isSuccess) { if (isSuccess) {
makeToast(t('settings.backup.label.restored_backup'), 'success'); makeToast(t('settings.backup.action.restore.label.success'), 'success');
} }
if (isFailure) { if (isFailure) {
makeToast(t('settings.backup.label.backup_restore_failed'), 'error'); makeToast(t('settings.backup.action.restore.error.label.failure'), 'error');
} }
backupRestoreId = undefined; backupRestoreId = undefined;
@@ -110,21 +121,66 @@ export function Backup() {
} }
}, [data?.restoreStatus?.state]); }, [data?.restoreStatus?.state]);
const submitBackup = async (file: File) => { const resetBackupState = () => {
if (file.name.toLowerCase().match(/proto\.gz$|tachibk$/g)) { setCurrentBackupFile(null);
makeToast(t('settings.backup.label.restoring_backup'), 'info');
try { const input = document.getElementById('backup-file') as HTMLInputElement;
const response = await requestManager.restoreBackupFile(file).response; if (input) {
backupRestoreId = response.data?.restoreBackup.id; input.value = '';
setTriggerReRender(Date.now()); }
} catch (e) { };
makeToast(t('settings.backup.label.backup_restore_failed'), 'error');
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;
} }
} else if (file.name.toLowerCase().endsWith('json')) {
makeToast(t('settings.backup.label.legacy_backup_unsupported'), 'error'); return true;
} else { } 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'); makeToast(t('global.error.label.invalid_file_type'), 'error');
return;
}
setCurrentBackupFile(file);
const isBackupValid = await validateBackup(file);
if (isBackupValid) {
await restoreBackup(file);
} }
}; };
@@ -139,6 +195,11 @@ export function Backup() {
e.preventDefault(); e.preventDefault();
}; };
const closeInvalidBackupDialog = () => {
setIsInvalidBackupDialogOpen(false);
resetBackupState();
};
useEffect(() => { useEffect(() => {
document.addEventListener('drop', dropHandler); document.addEventListener('drop', dropHandler);
document.addEventListener('dragover', dragOverHandler); document.addEventListener('dragover', dragOverHandler);
@@ -163,8 +224,8 @@ export function Backup() {
<List sx={{ padding: 0 }}> <List sx={{ padding: 0 }}>
<ListItemLink to={requestManager.getExportBackupUrl()}> <ListItemLink to={requestManager.getExportBackupUrl()}>
<ListItemText <ListItemText
primary={t('settings.backup.label.create_backup')} primary={t('settings.backup.action.create.label.title')}
secondary={t('settings.backup.label.create_backup_info')} secondary={t('settings.backup.action.create.label.description')}
/> />
</ListItemLink> </ListItemLink>
<ListItemButton <ListItemButton
@@ -172,8 +233,8 @@ export function Backup() {
disabled={!!backupRestoreId} disabled={!!backupRestoreId}
> >
<ListItemText <ListItemText
primary={t('settings.backup.label.restore_backup')} primary={t('settings.backup.action.restore.label.title')}
secondary={t('settings.backup.label.restore_backup_info')} secondary={t('settings.backup.action.restore.label.description')}
/> />
{backupRestoreId ? ( {backupRestoreId ? (
<ListItemIcon> <ListItemIcon>
@@ -236,6 +297,43 @@ export function Backup() {
</List> </List>
</List> </List>
<input type="file" id="backup-file" style={{ display: 'none' }} /> <input type="file" id="backup-file" style={{ display: 'none' }} />
<Dialog open={isInvalidBackupDialogOpen}>
<DialogTitle>{t('settings.backup.action.validate.dialog.title')}</DialogTitle>
<DialogContent dividers>
<List
sx={{ listStyleType: 'initial', listStylePosition: 'inside' }}
subheader={t('settings.backup.action.validate.dialog.content.label.missing_sources')}
>
{missingSources.map(({ id, name }) => (
<ListItem sx={{ display: 'list-item' }} key={id}>
{`${name} (${id})`}
</ListItem>
))}
</List>
</DialogContent>
<DialogActions>
<Button onClick={closeInvalidBackupDialog}>{t('global.button.cancel')}</Button>
<Button
onClick={closeInvalidBackupDialog}
component={Link}
to="/extensions"
autoFocus={!!missingSources.length}
variant={missingSources.length ? 'contained' : 'text'}
>
{t('extension.action.label.install')}
</Button>
<Button
onClick={() => {
closeInvalidBackupDialog();
restoreBackup(currentBackupFile!);
}}
autoFocus={!missingSources.length}
variant={!missingSources.length ? 'contained' : 'text'}
>
{t('global.button.restore')}
</Button>
</DialogActions>
</Dialog>
</> </>
); );
} }