Feature/manga migration (#536)

* Add "migration" tab to "Browse" screen

* Add missing useEffect cleanup for navbar title and actions

* Make "SourceGridLayout" reusable

* Add migration tab to "Browse"

* Add migration logic
This commit is contained in:
schroda
2024-01-26 20:50:51 +01:00
committed by GitHub
parent 5224ad1391
commit e0c5e0521d
39 changed files with 1329 additions and 365 deletions

View File

@@ -37,6 +37,7 @@ import { ServerUpdateChecker } from '@/components/settings/ServerUpdateChecker.t
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { ExtensionSettings } from '@/screens/settings/ExtensionSettings.tsx'; import { ExtensionSettings } from '@/screens/settings/ExtensionSettings.tsx';
import { WebUISettings } from '@/screens/settings/WebUISettings.tsx'; import { WebUISettings } from '@/screens/settings/WebUISettings.tsx';
import { Migrate } from '@/screens/Migrate.tsx';
if (process.env.NODE_ENV !== 'production') { if (process.env.NODE_ENV !== 'production') {
// Adds messages only in a dev environment // Adds messages only in a dev environment
@@ -120,6 +121,10 @@ export const App: React.FC = () => (
<Route path="updates" element={<Updates />} /> <Route path="updates" element={<Updates />} />
<Route path="extensions" element={<Extensions />} /> <Route path="extensions" element={<Extensions />} />
<Route path="browse" element={<Browse />} /> <Route path="browse" element={<Browse />} />
<Route path="migrate/source/:sourceId">
<Route index element={<Migrate />} />
<Route path="manga/:mangaId/search" element={<SearchAll />} />
</Route>
</Routes> </Routes>
</Container> </Container>
<Routes> <Routes>

View File

@@ -13,6 +13,7 @@ import { Link } from 'react-router-dom';
import { Avatar, Box, CardContent, Stack, styled, Tooltip } from '@mui/material'; import { Avatar, Box, CardContent, Stack, styled, Tooltip } from '@mui/material';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import PopupState, { bindMenu } from 'material-ui-popup-state'; import PopupState, { bindMenu } from 'material-ui-popup-state';
import { useState } from 'react';
import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts';
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import { SpinnerImage } from '@/components/util/SpinnerImage'; import { SpinnerImage } from '@/components/util/SpinnerImage';
@@ -22,6 +23,7 @@ import { SelectableCollectionReturnType } from '@/components/collection/useSelec
import { MangaOptionButton } from '@/components/manga/MangaOptionButton.tsx'; import { MangaOptionButton } from '@/components/manga/MangaOptionButton.tsx';
import { MangaActionMenuItems, SingleModeProps } from '@/components/manga/MangaActionMenuItems.tsx'; import { MangaActionMenuItems, SingleModeProps } from '@/components/manga/MangaActionMenuItems.tsx';
import { Menu } from '@/components/menu/Menu.tsx'; import { Menu } from '@/components/menu/Menu.tsx';
import { MigrateDialog } from '@/components/MigrateDialog.tsx';
const BottomGradient = styled('div')({ const BottomGradient = styled('div')({
position: 'absolute', position: 'absolute',
@@ -66,18 +68,34 @@ const BadgeContainer = styled('div')({
}, },
}); });
interface IProps { type MangaCardMode = 'default' | 'migrate.search' | 'migrate.select';
export interface MangaCardProps {
manga: TPartialManga; manga: TPartialManga;
gridLayout?: GridLayout; gridLayout?: GridLayout;
inLibraryIndicator?: boolean; inLibraryIndicator?: boolean;
selected?: boolean | null; selected?: boolean | null;
handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection']; handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection'];
mode?: MangaCardMode;
} }
export const MangaCard = (props: IProps) => { const getMangaLinkTo = (mode: MangaCardMode, mangaId: number, sourceId: string, mangaTitle: string): string => {
switch (mode) {
case 'default':
return `/manga/${mangaId}/`;
case 'migrate.search':
return `/migrate/source/${sourceId}/manga/${mangaId}/search?query=${mangaTitle}`;
case 'migrate.select':
return '';
default:
throw new Error(`getMangaLinkTo: unexpected MangaCardMode "${mode}"`);
}
};
export const MangaCard = (props: MangaCardProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { manga, gridLayout, inLibraryIndicator, selected, handleSelection } = props; const { manga, gridLayout, inLibraryIndicator, selected, handleSelection, mode = 'default' } = props;
const { const {
id, id,
title, title,
@@ -93,182 +111,322 @@ export const MangaCard = (props: IProps) => {
options: { showContinueReadingButton, showUnreadBadge, showDownloadBadge }, options: { showContinueReadingButton, showUnreadBadge, showDownloadBadge },
} = useLibraryOptionsContext(); } = useLibraryOptionsContext();
const mangaLinkTo = `/manga/${id}/`; const mangaLinkTo = getMangaLinkTo(mode, manga.id, manga.source?.id, manga.title);
const nextChapterIndexToRead = (latestReadChapter?.sourceOrder ?? 0) + 1; const nextChapterIndexToRead = (latestReadChapter?.sourceOrder ?? 0) + 1;
const isLatestChapterRead = chapters?.totalCount === latestReadChapter?.sourceOrder; const isLatestChapterRead = chapters?.totalCount === latestReadChapter?.sourceOrder;
const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false);
if (gridLayout !== GridLayout.List) { if (gridLayout !== GridLayout.List) {
return ( return (
<>
{isMigrateDialogOpen && (
<MigrateDialog mangaIdToMigrateTo={manga.id} onClose={() => setIsMigrateDialogOpen(false)} />
)}
<PopupState variant="popover" popupId="manga-card-action-menu">
{(popupState) => (
<>
<Link
onClick={(e) => {
const isMigrateSelectMode = mode === 'migrate.select';
const isSelectionMode = selected !== null;
const handleClick = isMigrateSelectMode || isSelectionMode;
if (!handleClick) {
return;
}
e.preventDefault();
if (isMigrateSelectMode) {
setIsMigrateDialogOpen(true);
return;
}
handleSelection?.(id, !selected);
}}
to={mangaLinkTo}
style={{ textDecoration: 'none' }}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
margin: '2px',
outline: selected ? '4px solid' : undefined,
borderRadius: selected ? '1px' : undefined,
outlineColor: (theme) => theme.palette.primary.main,
backgroundColor: (theme) => (selected ? theme.palette.primary.main : undefined),
'@media (hover: hover) and (pointer: fine)': {
'&:hover .manga-option-button': {
visibility: 'visible',
pointerEvents: 'all',
},
},
}}
>
<Card
sx={{
// force standard aspect ratio of manga covers
aspectRatio: '225/350',
display: 'flex',
}}
>
<CardActionArea
sx={{
position: 'relative',
height: '100%',
}}
>
<Stack
alignItems="start"
justifyContent="space-between"
direction="row"
sx={{
position: 'absolute',
top: 5,
left: 5,
right: 5,
}}
>
<BadgeContainer>
{inLibraryIndicator && inLibrary && (
<Typography
sx={{ backgroundColor: 'primary.dark', zIndex: '1' }}
>
{t('manga.button.in_library')}
</Typography>
)}
{showUnreadBadge && (unread ?? 0) > 0 && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>
{unread}
</Typography>
)}
{showDownloadBadge && (downloadCount ?? 0) > 0 && (
<Typography
sx={{
backgroundColor: 'success.dark',
}}
>
{downloadCount}
</Typography>
)}
</BadgeContainer>
<MangaOptionButton
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
/>
</Stack>
<SpinnerImage
alt={title}
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
imgStyle={
inLibraryIndicator && inLibrary
? {
height: '100%',
width: '100%',
objectFit: 'cover',
filter: 'brightness(0.4)',
}
: {
height: '100%',
width: '100%',
objectFit: 'cover',
}
}
spinnerStyle={{
display: 'grid',
placeItems: 'center',
}}
/>
<>
{gridLayout !== GridLayout.Comfortable && (
<>
<BottomGradient />
<BottomGradientDoubledDown />
</>
)}
<Stack
direction="row"
justifyContent={
gridLayout !== GridLayout.Comfortable ? 'space-between' : 'end'
}
alignItems="end"
sx={{
position: 'absolute',
bottom: 0,
width: '100%',
margin: '0.5em 0',
padding: '0 0.5em',
gap: '0.5em',
}}
>
{gridLayout !== GridLayout.Comfortable && (
<Tooltip title={title} placement="top">
<GridMangaTitle
sx={{
color: 'white',
textShadow: '0px 0px 3px #000000',
}}
>
{title}
</GridMangaTitle>
</Tooltip>
)}
<ContinueReadingButton
showContinueReadingButton={showContinueReadingButton}
isLatestChapterRead={isLatestChapterRead}
nextChapterIndexToRead={nextChapterIndexToRead}
mangaLinkTo={mangaLinkTo}
/>
</Stack>
</>
</CardActionArea>
</Card>
{gridLayout === GridLayout.Comfortable && (
<Tooltip title={title} placement="top">
<GridMangaTitle
sx={{
position: 'relative',
width: '100%',
bottom: 0,
margin: '0.5em 0',
padding: '0 0.5em',
color: 'text.primary',
height: '3rem',
}}
>
{title}
</GridMangaTitle>
</Tooltip>
)}
</Box>
</Link>
{!!handleSelection && popupState.isOpen && (
<Menu {...bindMenu(popupState)}>
{(onClose, setHideMenu) => (
<MangaActionMenuItems
manga={manga as SingleModeProps['manga']}
handleSelection={handleSelection}
onClose={onClose}
setHideMenu={setHideMenu}
/>
)}
</Menu>
)}
</>
)}
</PopupState>
</>
);
}
return (
<>
{isMigrateDialogOpen && (
<MigrateDialog mangaIdToMigrateTo={manga.id} onClose={() => setIsMigrateDialogOpen(false)} />
)}
<PopupState variant="popover" popupId="manga-card-action-menu"> <PopupState variant="popover" popupId="manga-card-action-menu">
{(popupState) => ( {(popupState) => (
<> <>
<Link <Card>
onClick={(e) => { <CardActionArea
if (selected === null) { component={Link}
return; to={mangaLinkTo}
} onClick={(e) => {
if (selected === null) {
return;
}
e.preventDefault(); e.preventDefault();
handleSelection?.(id, !selected); handleSelection?.(id, !selected);
}}
to={mangaLinkTo}
style={{ textDecoration: 'none' }}
>
<Box
sx={{
display: 'flex',
flexDirection: 'column',
margin: '2px',
outline: selected ? '4px solid' : undefined,
borderRadius: selected ? '1px' : undefined,
outlineColor: (theme) => theme.palette.primary.main,
backgroundColor: (theme) => (selected ? theme.palette.primary.main : undefined),
'@media (hover: hover) and (pointer: fine)': {
'&:hover .manga-option-button': {
visibility: 'visible',
pointerEvents: 'all',
},
},
}} }}
> >
<Card <CardContent
sx={{ sx={{
// force standard aspect ratio of manga covers
aspectRatio: '225/350',
display: 'flex', display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: 2,
position: 'relative',
}} }}
> >
<CardActionArea <Avatar
variant="rounded"
sx={
inLibraryIndicator && inLibrary
? {
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 2,
imageRendering: 'pixelated',
filter: 'brightness(0.4)',
}
: {
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 2,
imageRendering: 'pixelated',
}
}
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
/>
<Box
sx={{ sx={{
position: 'relative', display: 'flex',
height: '100%', flexDirection: 'row',
flexGrow: 1,
width: 'min-content',
}} }}
> >
<Stack <Tooltip title={title} placement="top">
alignItems="start" <MangaTitle variant="h5">{title}</MangaTitle>
justifyContent="space-between" </Tooltip>
direction="row" </Box>
sx={{ <Stack direction="row" alignItems="center" gap="5px">
position: 'absolute', <BadgeContainer>
top: 5, {inLibraryIndicator && inLibrary && (
left: 5, <Typography sx={{ backgroundColor: 'primary.dark' }}>
right: 5, {t('manga.button.in_library')}
}} </Typography>
>
<BadgeContainer>
{inLibraryIndicator && inLibrary && (
<Typography sx={{ backgroundColor: 'primary.dark', zIndex: '1' }}>
{t('manga.button.in_library')}
</Typography>
)}
{showUnreadBadge && (unread ?? 0) > 0 && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>
{unread}
</Typography>
)}
{showDownloadBadge && (downloadCount ?? 0) > 0 && (
<Typography
sx={{
backgroundColor: 'success.dark',
}}
>
{downloadCount}
</Typography>
)}
</BadgeContainer>
<MangaOptionButton
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
/>
</Stack>
<SpinnerImage
alt={title}
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
imgStyle={
inLibraryIndicator && inLibrary
? {
height: '100%',
width: '100%',
objectFit: 'cover',
filter: 'brightness(0.4)',
}
: {
height: '100%',
width: '100%',
objectFit: 'cover',
}
}
spinnerStyle={{
display: 'grid',
placeItems: 'center',
}}
/>
<>
{gridLayout !== GridLayout.Comfortable && (
<>
<BottomGradient />
<BottomGradientDoubledDown />
</>
)} )}
<Stack {showUnreadBadge && unread! > 0 && (
direction="row" <Typography sx={{ backgroundColor: 'primary.dark' }}>
justifyContent={ {unread}
gridLayout !== GridLayout.Comfortable ? 'space-between' : 'end' </Typography>
} )}
alignItems="end" {showDownloadBadge && downloadCount! > 0 && (
sx={{ <Typography
position: 'absolute', sx={{
bottom: 0, backgroundColor: 'success.dark',
width: '100%', }}
margin: '0.5em 0', >
padding: '0 0.5em', {downloadCount}
gap: '0.5em', </Typography>
}} )}
> </BadgeContainer>
{gridLayout !== GridLayout.Comfortable && ( <ContinueReadingButton
<Tooltip title={title} placement="top"> showContinueReadingButton={showContinueReadingButton}
<GridMangaTitle isLatestChapterRead={isLatestChapterRead}
sx={{ nextChapterIndexToRead={nextChapterIndexToRead}
color: 'white', mangaLinkTo={mangaLinkTo}
textShadow: '0px 0px 3px #000000', />
}} <MangaOptionButton
> popupState={popupState}
{title} id={id}
</GridMangaTitle> selected={selected}
</Tooltip> handleSelection={handleSelection}
)} asCheckbox
<ContinueReadingButton />
showContinueReadingButton={showContinueReadingButton} </Stack>
isLatestChapterRead={isLatestChapterRead} </CardContent>
nextChapterIndexToRead={nextChapterIndexToRead} </CardActionArea>
mangaLinkTo={mangaLinkTo} </Card>
/>
</Stack>
</>
</CardActionArea>
</Card>
{gridLayout === GridLayout.Comfortable && (
<Tooltip title={title} placement="top">
<GridMangaTitle
sx={{
position: 'relative',
width: '100%',
bottom: 0,
margin: '0.5em 0',
padding: '0 0.5em',
color: 'text.primary',
height: '3rem',
}}
>
{title}
</GridMangaTitle>
</Tooltip>
)}
</Box>
</Link>
{!!handleSelection && popupState.isOpen && ( {!!handleSelection && popupState.isOpen && (
<Menu {...bindMenu(popupState)}> <Menu {...bindMenu(popupState)}>
{(onClose, setHideMenu) => ( {(onClose, setHideMenu) => (
@@ -284,120 +442,6 @@ export const MangaCard = (props: IProps) => {
</> </>
)} )}
</PopupState> </PopupState>
); </>
}
return (
<PopupState variant="popover" popupId="manga-card-action-menu">
{(popupState) => (
<>
<Card>
<CardActionArea
component={Link}
to={mangaLinkTo}
onClick={(e) => {
if (selected === null) {
return;
}
e.preventDefault();
handleSelection?.(id, !selected);
}}
>
<CardContent
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: 2,
position: 'relative',
}}
>
<Avatar
variant="rounded"
sx={
inLibraryIndicator && inLibrary
? {
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 2,
imageRendering: 'pixelated',
filter: 'brightness(0.4)',
}
: {
width: 56,
height: 56,
flex: '0 0 auto',
marginRight: 2,
imageRendering: 'pixelated',
}
}
src={requestManager.getValidImgUrlFor(thumbnailUrl)}
/>
<Box
sx={{
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
width: 'min-content',
}}
>
<Tooltip title={title} placement="top">
<MangaTitle variant="h5">{title}</MangaTitle>
</Tooltip>
</Box>
<Stack direction="row" alignItems="center" gap="5px">
<BadgeContainer>
{inLibraryIndicator && inLibrary && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>
{t('manga.button.in_library')}
</Typography>
)}
{showUnreadBadge && unread! > 0 && (
<Typography sx={{ backgroundColor: 'primary.dark' }}>{unread}</Typography>
)}
{showDownloadBadge && downloadCount! > 0 && (
<Typography
sx={{
backgroundColor: 'success.dark',
}}
>
{downloadCount}
</Typography>
)}
</BadgeContainer>
<ContinueReadingButton
showContinueReadingButton={showContinueReadingButton}
isLatestChapterRead={isLatestChapterRead}
nextChapterIndexToRead={nextChapterIndexToRead}
mangaLinkTo={mangaLinkTo}
/>
<MangaOptionButton
popupState={popupState}
id={id}
selected={selected}
handleSelection={handleSelection}
asCheckbox
/>
</Stack>
</CardContent>
</CardActionArea>
</Card>
{!!handleSelection && popupState.isOpen && (
<Menu {...bindMenu(popupState)}>
{(onClose, setHideMenu) => (
<MangaActionMenuItems
manga={manga as SingleModeProps['manga']}
handleSelection={handleSelection}
onClose={onClose}
setHideMenu={setHideMenu}
/>
)}
</Menu>
)}
</>
)}
</PopupState>
); );
}; };

View File

@@ -13,7 +13,7 @@ import { GridItemProps, GridStateSnapshot, VirtuosoGrid } from 'react-virtuoso';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { EmptyView } from '@/components/util/EmptyView'; import { EmptyView } from '@/components/util/EmptyView';
import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder'; import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder';
import { MangaCard } from '@/components/MangaCard'; import { MangaCard, MangaCardProps } from '@/components/MangaCard';
import { GridLayout } from '@/components/context/LibraryOptionsContext'; import { GridLayout } from '@/components/context/LibraryOptionsContext';
import { useLocalStorage } from '@/util/useLocalStorage'; import { useLocalStorage } from '@/util/useLocalStorage';
import { TManga, TPartialManga } from '@/typings.ts'; import { TManga, TPartialManga } from '@/typings.ts';
@@ -49,6 +49,7 @@ const createMangaCard = (
isSelectModeActive: boolean = false, isSelectModeActive: boolean = false,
selectedMangaIds?: TManga['id'][], selectedMangaIds?: TManga['id'][],
handleSelection?: DefaultGridProps['handleSelection'], handleSelection?: DefaultGridProps['handleSelection'],
mode?: MangaCardProps['mode'],
) => ( ) => (
<MangaCard <MangaCard
key={manga.id} key={manga.id}
@@ -57,10 +58,11 @@ const createMangaCard = (
inLibraryIndicator={inLibraryIndicator} inLibraryIndicator={inLibraryIndicator}
selected={isSelectModeActive ? selectedMangaIds?.includes(manga.id) : null} selected={isSelectModeActive ? selectedMangaIds?.includes(manga.id) : null}
handleSelection={handleSelection} handleSelection={handleSelection}
mode={mode}
/> />
); );
type DefaultGridProps = { type DefaultGridProps = Pick<MangaCardProps, 'mode'> & {
isLoading: boolean; isLoading: boolean;
mangas: TPartialManga[]; mangas: TPartialManga[];
inLibraryIndicator?: boolean; inLibraryIndicator?: boolean;
@@ -80,6 +82,7 @@ const HorizontalGrid = ({
isSelectModeActive, isSelectModeActive,
selectedMangaIds, selectedMangaIds,
handleSelection, handleSelection,
mode,
}: DefaultGridProps) => ( }: DefaultGridProps) => (
<Grid <Grid
container container
@@ -105,6 +108,7 @@ const HorizontalGrid = ({
isSelectModeActive, isSelectModeActive,
selectedMangaIds, selectedMangaIds,
handleSelection, handleSelection,
mode,
)} )}
</GridItemContainer> </GridItemContainer>
)) ))
@@ -123,6 +127,7 @@ const VerticalGrid = ({
isSelectModeActive, isSelectModeActive,
selectedMangaIds, selectedMangaIds,
handleSelection, handleSelection,
mode,
}: DefaultGridProps & { }: DefaultGridProps & {
hasNextPage: boolean; hasNextPage: boolean;
loadMore: () => void; loadMore: () => void;
@@ -171,6 +176,7 @@ const VerticalGrid = ({
isSelectModeActive, isSelectModeActive,
selectedMangaIds, selectedMangaIds,
handleSelection, handleSelection,
mode,
) )
} }
/> />
@@ -212,6 +218,7 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
isSelectModeActive, isSelectModeActive,
selectedMangaIds, selectedMangaIds,
handleSelection, handleSelection,
mode,
} = props; } = props;
const [dimensions, setDimensions] = useState(document.documentElement.offsetWidth); const [dimensions, setDimensions] = useState(document.documentElement.offsetWidth);
@@ -287,6 +294,7 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
isSelectModeActive={isSelectModeActive} isSelectModeActive={isSelectModeActive}
selectedMangaIds={selectedMangaIds} selectedMangaIds={selectedMangaIds}
handleSelection={handleSelection} handleSelection={handleSelection}
mode={mode}
/> />
) : ( ) : (
<VerticalGrid <VerticalGrid
@@ -300,6 +308,7 @@ export const MangaGrid: React.FC<IMangaGridProps> = (props) => {
isSelectModeActive={isSelectModeActive} isSelectModeActive={isSelectModeActive}
selectedMangaIds={selectedMangaIds} selectedMangaIds={selectedMangaIds}
handleSelection={handleSelection} handleSelection={handleSelection}
mode={mode}
/> />
)} )}
</div> </div>

View File

@@ -0,0 +1,97 @@
/*
* 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 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 { useTranslation } from 'react-i18next';
import { Stack } from '@mui/material';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useState } from 'react';
import FormGroup from '@mui/material/FormGroup';
import { CheckboxInput } from '@/components/atoms/CheckboxInput.tsx';
import { Mangas, MigrateMode } from '@/lib/data/Mangas.ts';
import { makeToast } from '@/components/util/Toast.tsx';
export const MigrateDialog = ({ mangaIdToMigrateTo, onClose }: { mangaIdToMigrateTo: number; onClose: () => void }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const { mangaId: mangaIdAsString } = useParams<{ mangaId: string }>();
const mangaId = Number(mangaIdAsString);
const [includeChapters, setIncludeChapters] = useState(true);
const [includeCategories, setIncludeCategories] = useState(true);
const [isMigrationInProcess, setIsMigrationInProcess] = useState(false);
const migrate = async (mode: MigrateMode) => {
if (mangaId == null) {
throw new Error(`MigrateDialog::migrate: unexpected mangaId "${mangaId}"`);
}
makeToast(t('migrate.label.info'), 'info');
setIsMigrationInProcess(true);
try {
await Mangas.migrate(mangaId, mangaIdToMigrateTo, {
mode,
migrateChapters: includeChapters,
migrateCategories: includeCategories,
});
navigate(`/manga/${mangaIdToMigrateTo}`);
} catch (e) {
setIsMigrationInProcess(false);
}
};
return (
<Dialog open fullWidth onClose={onClose}>
<DialogTitle>{t('migrate.dialog.title')}</DialogTitle>
<DialogContent dividers>
<FormGroup>
<CheckboxInput
disabled={isMigrationInProcess}
label={t('chapter.title')}
checked={includeChapters}
onChange={(_, checked) => setIncludeChapters(checked)}
/>
<CheckboxInput
disabled={isMigrationInProcess}
label={t('category.title.category_one')}
checked={includeCategories}
onChange={(_, checked) => setIncludeCategories(checked)}
/>
</FormGroup>
</DialogContent>
<DialogActions>
<Stack sx={{ width: '100%' }} direction="row" justifyContent="space-between">
<Button disabled={isMigrationInProcess} component={Link} to={`/manga/${mangaIdToMigrateTo}`}>
{t('migrate.dialog.action.button.show_entry')}
</Button>
<Stack direction="row">
<Button disabled={isMigrationInProcess} onClick={onClose}>
{t('global.button.cancel')}
</Button>
<Button disabled={isMigrationInProcess} onClick={() => migrate('copy')}>
{t('global.button.copy')}
</Button>
<Button disabled={isMigrationInProcess} onClick={() => migrate('migrate')}>
{t('global.button.migrate')}
</Button>
</Stack>
</Stack>
</DialogActions>
</Dialog>
);
};

View File

@@ -0,0 +1,60 @@
/*
* 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 Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import { Box, CardActionArea, Chip } from '@mui/material';
import Avatar from '@mui/material/Avatar';
import Typography from '@mui/material/Typography';
import { Link } from 'react-router-dom';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { GetMigratableSourcesQuery } from '@/lib/graphql/generated/graphql.ts';
import { translateExtensionLanguage } from '@/screens/util/Extensions.ts';
export type TMigratableSource = NonNullable<GetMigratableSourcesQuery['mangas']['nodes'][number]['source']> & {
mangaCount: number;
};
// TODO - cleanup source/extension components
export const MigrationCard = ({ id, name, lang, iconUrl, mangaCount }: TMigratableSource) => (
<Card>
<CardActionArea component={Link} to={`/migrate/source/${id}/`}>
<CardContent
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: 2,
}}
>
<Box sx={{ display: 'flex' }}>
<Avatar
variant="rounded"
sx={{
width: 56,
height: 56,
flex: '0 0 auto',
mr: 2,
}}
alt={name}
src={requestManager.getValidImgUrlFor(iconUrl)}
/>
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant="h5" component="h2">
{name}
</Typography>
<Typography variant="caption" display="block">
{translateExtensionLanguage(lang)}
</Typography>
</Box>
</Box>
<Chip sx={{ borderRadius: '5px' }} size="small" label={mangaCount} />
</CardContent>
</CardActionArea>
</Card>
);

View File

@@ -15,6 +15,8 @@ import { useTranslation } from 'react-i18next';
import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder'; import FavoriteBorderIcon from '@mui/icons-material/FavoriteBorder';
import Label from '@mui/icons-material/Label'; import Label from '@mui/icons-material/Label';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import SyncAltIcon from '@mui/icons-material/SyncAlt';
import { Link, useNavigate } from 'react-router-dom';
import { TManga } from '@/typings.ts'; import { TManga } from '@/typings.ts';
import { actionToTranslationKey, MangaAction, MangaDownloadInfo, Mangas, MangaUnreadInfo } from '@/lib/data/Mangas.ts'; import { actionToTranslationKey, MangaAction, MangaDownloadInfo, Mangas, MangaUnreadInfo } from '@/lib/data/Mangas.ts';
import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts'; import { SelectableCollectionReturnType } from '@/components/collection/useSelectableCollection.ts';
@@ -28,7 +30,7 @@ const ACTION_DISABLES_SELECTION_MODE: MangaAction[] = ['remove_from_library'] as
type BaseProps = { onClose: (selectionModeState: boolean) => void; setHideMenu: (hide: boolean) => void }; type BaseProps = { onClose: (selectionModeState: boolean) => void; setHideMenu: (hide: boolean) => void };
export type SingleModeProps = { export type SingleModeProps = {
manga: Pick<TManga, 'id'> & MangaDownloadInfo & MangaUnreadInfo; manga: Pick<TManga, 'id' | 'title' | 'source'> & MangaDownloadInfo & MangaUnreadInfo;
handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection']; handleSelection?: SelectableCollectionReturnType<TManga['id']>['handleSelection'];
}; };
@@ -49,6 +51,8 @@ export const MangaActionMenuItems = ({
}: Props) => { }: Props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate();
const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false); const [isCategorySelectOpen, setIsCategorySelectOpen] = useState(false);
const isSingleMode = !!manga; const isSingleMode = !!manga;
@@ -129,6 +133,23 @@ export const MangaActionMenuItems = ({
title={getMenuItemTitle('mark_as_unread', readMangas.length)} title={getMenuItemTitle('mark_as_unread', readMangas.length)}
/> />
)} )}
{isSingleMode && (
<Link
to={`/migrate/source/${manga?.source?.id}/manga/${manga?.id}/search?query=${manga?.title}`}
state={{ mangaTitle: manga?.title }}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<MenuItem
onClick={() =>
navigate(
`/migrate/source/${manga?.source?.id}/manga/${manga?.id}/search?query=${manga?.title}`,
)
}
Icon={SyncAltIcon}
title={getMenuItemTitle('migrate', selectedMangas.length)}
/>
</Link>
)}
<MenuItem <MenuItem
onClick={() => { onClick={() => {
setIsCategorySelectOpen(true); setIsCategorySelectOpen(true);

View File

@@ -21,6 +21,8 @@ import {
} from '@mui/material'; } from '@mui/material';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import SyncAltIcon from '@mui/icons-material/SyncAlt';
import { CategorySelect } from '@/components/navbar/action/CategorySelect'; import { CategorySelect } from '@/components/navbar/action/CategorySelect';
import { TManga } from '@/typings.ts'; import { TManga } from '@/typings.ts';
@@ -32,6 +34,7 @@ interface IProps {
export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => { export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();
const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm')); const isLargeScreen = useMediaQuery(theme.breakpoints.up('sm'));
@@ -57,6 +60,17 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
<Refresh /> <Refresh />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title={t('global.button.migrate')}>
<Link
to={`/migrate/source/${manga.source?.id}/manga/${manga.id}/search?query=${manga.title}`}
state={{ mangaTitle: manga.title }}
style={{ textDecoration: 'none', color: 'inherit' }}
>
<IconButton disabled={refreshing}>
<SyncAltIcon />
</IconButton>
</Link>
</Tooltip>
{manga.inLibrary && ( {manga.inLibrary && (
<Tooltip title={t('manga.label.edit_categories')}> <Tooltip title={t('manga.label.edit_categories')}>
<IconButton <IconButton

View File

@@ -158,7 +158,7 @@ export const CategoriesInclusionSetting = (props: CategoriesInclusionSettingProp
<> <>
<ListItemButton onClick={() => setIsDialogOpen(true)}> <ListItemButton onClick={() => setIsDialogOpen(true)}>
<ListItemText <ListItemText
primary={t('category.title.categories')} primary={t('category.title.category_other')}
secondary={ secondary={
<> <>
<span> <span>
@@ -179,7 +179,7 @@ export const CategoriesInclusionSetting = (props: CategoriesInclusionSettingProp
<Dialog open={isDialogOpen} onClose={closeDialog}> <Dialog open={isDialogOpen} onClose={closeDialog}>
<DialogContent> <DialogContent>
<DialogTitle sx={{ paddingLeft: 0 }}>{t('category.title.categories')}</DialogTitle> <DialogTitle sx={{ paddingLeft: 0 }}>{t('category.title.category_other')}</DialogTitle>
{dialogText && <DialogContentText sx={{ paddingBottom: '10px' }}>{dialogText}</DialogContentText>} {dialogText && <DialogContentText sx={{ paddingBottom: '10px' }}>{dialogText}</DialogContentText>}
<CheckboxContainer> <CheckboxContainer>
{dialogCategories.map((category) => ( {dialogCategories.map((category) => (

View File

@@ -10,17 +10,18 @@ import { IconButton, Menu, MenuItem, FormControlLabel, Radio, Tooltip } from '@m
import React from 'react'; import React from 'react';
import ViewModuleIcon from '@mui/icons-material/ViewModule'; import ViewModuleIcon from '@mui/icons-material/ViewModule';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import { GridLayout } from '@/components/context/LibraryOptionsContext';
// TODO: clean up this to use a FormControl, and remove dependency on name o radio button // TODO: clean up this to use a FormControl, and remove dependency on name o radio button
export function SourceGridLayout() { export function GridLayouts({
gridLayout,
onChange,
}: {
gridLayout: GridLayout;
onChange: (gridLayout: GridLayout) => void;
}) {
const { t } = useTranslation(); const { t } = useTranslation();
const {
options: { SourcegridLayout },
setOptions,
} = useLibraryOptionsContext();
const [anchorEl, setAnchorEl] = React.useState(null); const [anchorEl, setAnchorEl] = React.useState(null);
const open = Boolean(anchorEl); const open = Boolean(anchorEl);
const handleClick = (event: any) => { const handleClick = (event: any) => {
@@ -30,10 +31,8 @@ export function SourceGridLayout() {
setAnchorEl(null); setAnchorEl(null);
}; };
function setGridContextOptions(e: React.ChangeEvent<HTMLInputElement>, checked: boolean) { function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
if (checked) { onChange(parseInt(e.target.name, 10));
setOptions((prev: any) => ({ ...prev, SourcegridLayout: parseInt(e.target.name, 10) }));
}
} }
return ( return (
@@ -64,8 +63,8 @@ export function SourceGridLayout() {
control={ control={
<Radio <Radio
name={GridLayout.Compact.toString()} name={GridLayout.Compact.toString()}
checked={SourcegridLayout === GridLayout.Compact || SourcegridLayout === undefined} checked={gridLayout === GridLayout.Compact}
onChange={setGridContextOptions} onChange={handleChange}
/> />
} }
/> />
@@ -76,8 +75,8 @@ export function SourceGridLayout() {
control={ control={
<Radio <Radio
name={GridLayout.Comfortable.toString()} name={GridLayout.Comfortable.toString()}
checked={SourcegridLayout === GridLayout.Comfortable} checked={gridLayout === GridLayout.Comfortable}
onChange={setGridContextOptions} onChange={handleChange}
/> />
} }
/> />
@@ -88,8 +87,8 @@ export function SourceGridLayout() {
control={ control={
<Radio <Radio
name={GridLayout.List.toString()} name={GridLayout.List.toString()}
checked={SourcegridLayout === GridLayout.List} checked={gridLayout === GridLayout.List}
onChange={setGridContextOptions} onChange={handleChange}
/> />
} }
/> />

View File

@@ -0,0 +1,23 @@
/*
* 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 { GridLayout, useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import { GridLayouts } from '@/components/source/GridLayouts.tsx';
export function SourceGridLayout() {
const {
options: { SourcegridLayout },
setOptions,
} = useLibraryOptionsContext();
function setGridContextOptions(gridLayout: GridLayout) {
setOptions((prev: any) => ({ ...prev, SourcegridLayout: gridLayout }));
}
return <GridLayouts gridLayout={SourcegridLayout} onChange={setGridContextOptions} />;
}

View File

@@ -22,10 +22,6 @@
"category_name": "Category Name", "category_name": "Category Name",
"use_as_default_category": "Default category when adding new manga to the library" "use_as_default_category": "Default category when adding new manga to the library"
}, },
"title": {
"categories": "Categories",
"set_categories": "Set categories"
},
"settings": { "settings": {
"inclusion": { "inclusion": {
"label": { "label": {
@@ -33,6 +29,11 @@
"include": "Include: {{includedCategoriesText}}" "include": "Include: {{includedCategoriesText}}"
} }
} }
},
"title": {
"category_one": "Category",
"category_other": "Categories",
"set_categories": "Set categories"
} }
}, },
"chapter": { "chapter": {
@@ -161,15 +162,15 @@
}, },
"settings": { "settings": {
"auto_download": { "auto_download": {
"label": {
"ignore_with_unread_chapters": "Ignore automatic chapter downloads for entries with unread chapters",
"new_chapters": "Download new chapters"
},
"categories": { "categories": {
"label": { "label": {
"include_in_download": "Entries in excluded categories will not be downloaded even if they are also in included categories" "include_in_download": "Entries in excluded categories will not be downloaded even if they are also in included categories"
} }
}, },
"label": {
"ignore_with_unread_chapters": "Ignore automatic chapter downloads for entries with unread chapters",
"new_chapters": "Download new chapters"
},
"title": "Auto-download" "title": "Auto-download"
}, },
"delete_chapters": { "delete_chapters": {
@@ -289,10 +290,12 @@
"browse": "Browse", "browse": "Browse",
"cancel": "Cancel", "cancel": "Cancel",
"clear": "Clear", "clear": "Clear",
"copy": "Copy",
"deselect": "Deselect", "deselect": "Deselect",
"edit": "Edit", "edit": "Edit",
"filter": "Filter", "filter": "Filter",
"latest": "Latest", "latest": "Latest",
"migrate": "Migrate",
"ok": "Ok", "ok": "Ok",
"open_site": "Open Site", "open_site": "Open Site",
"options": "Options", "options": "Options",
@@ -549,6 +552,12 @@
"success_other": "Removed {{count}} manga from the library" "success_other": "Removed {{count}} manga from the library"
} }
} }
},
"migrate": {
"label": {
"error": "Could not migrate manga",
"success": "Successfully migrated manga"
}
} }
}, },
"button": { "button": {
@@ -573,6 +582,23 @@
"title_one": "Manga", "title_one": "Manga",
"title_other": "Manga" "title_other": "Manga"
}, },
"migrate": {
"dialog": {
"action": {
"button": {
"show_entry": "Show entry"
}
},
"title": "Select data to include"
},
"label": {
"info": "Migrating manga…"
},
"search": {
"title": "$t(migrate.title) \"{{title}}\""
},
"title": "Migrate"
},
"reader": { "reader": {
"button": { "button": {
"close_menu": "Close menu", "close_menu": "Close menu",
@@ -605,10 +631,10 @@
"load_next_chapter": "Load next chapter at ending", "load_next_chapter": "Load next chapter at ending",
"offset_first_page": "Offset first page", "offset_first_page": "Offset first page",
"reader_type": "Reader type", "reader_type": "Reader type",
"reader_width": "Reader width",
"show_page_number": "Show page number", "show_page_number": "Show page number",
"skip_dup_chapters": "Skip duplicate chapters", "skip_dup_chapters": "Skip duplicate chapters",
"static_navigation": "Static navigation", "static_navigation": "Static navigation"
"reader_width": "Reader width"
}, },
"reader_type": { "reader_type": {
"label": { "label": {
@@ -926,4 +952,4 @@
}, },
"title": "Updates" "title": "Updates"
} }
} }

View File

@@ -78,6 +78,7 @@ export type ChapterIdInfo = Pick<TChapter, 'id'>;
export type ChapterDownloadInfo = ChapterIdInfo & Pick<TChapter, 'isDownloaded'>; export type ChapterDownloadInfo = ChapterIdInfo & Pick<TChapter, 'isDownloaded'>;
export type ChapterBookmarkInfo = ChapterIdInfo & Pick<TChapter, 'isBookmarked'>; export type ChapterBookmarkInfo = ChapterIdInfo & Pick<TChapter, 'isBookmarked'>;
export type ChapterReadInfo = ChapterIdInfo & Pick<TChapter, 'isRead'>; export type ChapterReadInfo = ChapterIdInfo & Pick<TChapter, 'isRead'>;
export type ChapterNumberInfo = ChapterIdInfo & Pick<TChapter, 'chapterNumber'>;
export class Chapters { export class Chapters {
static getIds(chapters: { id: number }[]): number[] { static getIds(chapters: { id: number }[]): number[] {
@@ -130,6 +131,23 @@ export class Chapters {
return chapters.filter((chapter) => !Chapters.isRead(chapter)); return chapters.filter((chapter) => !Chapters.isRead(chapter));
} }
static getMatchingChapterNumberChapters<Chapter extends ChapterNumberInfo>(
chaptersA: Chapter[],
chaptersB: Chapter[],
): [ChapterA: Chapter, ChapterB: Chapter][] {
return chaptersA
.map((chapterA) => {
const matchingChapter = chaptersB.find((chapterB) => chapterA.chapterNumber === chapterB.chapterNumber);
if (!matchingChapter) {
return null;
}
return [chapterA, matchingChapter];
})
.filter((matchingChapters) => matchingChapters !== null) as [Chapter, Chapter][];
}
static async download(chapterIds: number[]): Promise<void> { static async download(chapterIds: number[]): Promise<void> {
return Chapters.executeAction( return Chapters.executeAction(
'download', 'download',

View File

@@ -12,6 +12,8 @@ import { requestManager } from '@/lib/requests/RequestManager.ts';
import { import {
ChapterConditionInput, ChapterConditionInput,
GetMangasChapterIdsWithStateQuery, GetMangasChapterIdsWithStateQuery,
GetMangaToMigrateQuery,
GetMangaToMigrateToFetchMutation,
UpdateMangaCategoriesPatchInput, UpdateMangaCategoriesPatchInput,
} from '@/lib/graphql/generated/graphql.ts'; } from '@/lib/graphql/generated/graphql.ts';
import { Chapters } from '@/lib/data/Chapters.ts'; import { Chapters } from '@/lib/data/Chapters.ts';
@@ -23,7 +25,8 @@ export type MangaAction =
| 'mark_as_read' | 'mark_as_read'
| 'mark_as_unread' | 'mark_as_unread'
| 'remove_from_library' | 'remove_from_library'
| 'change_categories'; | 'change_categories'
| 'migrate';
export const actionToTranslationKey: { export const actionToTranslationKey: {
[key in MangaAction]: { [key in MangaAction]: {
@@ -83,11 +86,38 @@ export const actionToTranslationKey: {
success: 'manga.action.category.label.success', success: 'manga.action.category.label.success',
error: 'manga.action.category.label.error', error: 'manga.action.category.label.error',
}, },
migrate: {
action: {
single: 'global.button.migrate',
selected: 'global.button.migrate', // not supported
},
success: 'manga.action.migrate.label.success',
error: 'manga.action.migrate.label.error',
},
}; };
export type MangaChapterCountInfo = { chapters: Pick<TManga['chapters'], 'totalCount'> }; export type MangaChapterCountInfo = { chapters: Pick<TManga['chapters'], 'totalCount'> };
export type MangaDownloadInfo = Pick<TManga, 'downloadCount'> & MangaChapterCountInfo; export type MangaDownloadInfo = Pick<TManga, 'downloadCount'> & MangaChapterCountInfo;
export type MangaUnreadInfo = Pick<TManga, 'unreadCount'> & MangaChapterCountInfo; export type MangaUnreadInfo = Pick<TManga, 'unreadCount'> & MangaChapterCountInfo;
export type MigrateMode = 'copy' | 'migrate';
type MarkAsReadOptions = { wasManuallyMarkedAsRead: boolean };
type ChangeCategoriesOptions = { changeCategoriesPatch: UpdateMangaCategoriesPatchInput };
type MigrateOptions = {
mangaIdToMigrateTo: number;
mode: MigrateMode;
migrateChapters?: boolean;
migrateCategories?: boolean;
};
type PerformActionOptions<Action extends MangaAction> = Action extends 'mark_as_read'
? MarkAsReadOptions & PropertiesNever<ChangeCategoriesOptions> & PropertiesNever<MigrateOptions>
: Action extends 'change_categories'
? PropertiesNever<MarkAsReadOptions> & ChangeCategoriesOptions & PropertiesNever<MigrateOptions>
: Action extends 'migrate'
? PropertiesNever<MarkAsReadOptions> & PropertiesNever<ChangeCategoriesOptions> & MigrateOptions
: Partial<MarkAsReadOptions> & Partial<ChangeCategoriesOptions> & Partial<MigrateOptions>;
export class Mangas { export class Mangas {
static getIds(mangas: { id: number }[]): number[] { static getIds(mangas: { id: number }[]): number[] {
return mangas.map((manga) => manga.id); return mangas.map((manga) => manga.id);
@@ -185,6 +215,89 @@ export class Mangas {
); );
} }
private static async migrateChapters(
mangaToMigrate: GetMangaToMigrateQuery['manga'],
mangaToMigrateToInfo: GetMangaToMigrateToFetchMutation,
): Promise<void> {
if (!mangaToMigrate.chapters || !mangaToMigrateToInfo.fetchChapters?.chapters) {
throw new Error('Chapters are missing');
}
const chaptersToMigrate = mangaToMigrate.chapters.nodes;
const chaptersToMigrateTo = mangaToMigrateToInfo.fetchChapters?.chapters;
const migratableChapters = Chapters.getMatchingChapterNumberChapters(chaptersToMigrate, chaptersToMigrateTo);
const readChapters: number[] = [];
const bookmarkedChapters: number[] = [];
migratableChapters.forEach(([chapterToMigrate, chapterToMigrateTo]) => {
const { isRead, isBookmarked } = chapterToMigrate;
if (isRead) {
readChapters.push(chapterToMigrateTo.id);
}
if (isBookmarked) {
bookmarkedChapters.push(chapterToMigrateTo.id);
}
});
await Promise.all([
requestManager.updateChapters(readChapters, { isRead: true }).response,
requestManager.updateChapters(bookmarkedChapters, { isBookmarked: true }).response,
]);
}
private static async migrateCategories(
mangaToMigrate: GetMangaToMigrateQuery['manga'],
mangaToMigrateTo: GetMangaToMigrateToFetchMutation['fetchManga']['manga'],
): Promise<void> {
if (!mangaToMigrate?.categories) {
throw new Error('Categories are missing');
}
requestManager.updateMangasCategories([mangaToMigrateTo.id], {
addToCategories: mangaToMigrate.categories.nodes.map((category) => category.id),
});
}
static async migrate(
mangaId: number,
mangaIdToMigrateTo: number,
{ mode, migrateChapters, migrateCategories }: Omit<MigrateOptions, 'mangaIdToMigrateTo'>,
): Promise<void> {
return Mangas.executeAction('migrate', 1, async () => {
const [{ data: mangaToMigrateData }, { data: mangaToMigrateToData }] = await Promise.all([
requestManager.getMangaToMigrate(mangaId, { migrateChapters, migrateCategories }).response,
requestManager.getMangaToMigrateToFetch(mangaIdToMigrateTo, { migrateChapters, migrateCategories })
.response,
]);
if (!mangaToMigrateData.manga || !mangaToMigrateToData?.fetchManga.manga) {
throw new Error('Mangas::migrate: missing manga data');
}
if (
migrateChapters &&
(!mangaToMigrateData.manga.chapters || !mangaToMigrateToData.fetchChapters?.chapters)
) {
throw new Error('Mangas::migrate: missing chapters data');
}
await Promise.all([
migrateChapters ? Mangas.migrateChapters(mangaToMigrateData.manga, mangaToMigrateToData) : undefined,
migrateCategories
? Mangas.migrateCategories(mangaToMigrateData.manga, mangaToMigrateToData.fetchManga.manga)
: undefined,
!mangaToMigrateToData.fetchManga.manga.inLibrary
? requestManager.updateManga(mangaIdToMigrateTo, { inLibrary: true }).response
: undefined,
mode === 'migrate' ? requestManager.updateManga(mangaId, { inLibrary: false }).response : undefined,
]);
});
}
private static async executeAction( private static async executeAction(
action: MangaAction, action: MangaAction,
itemCount: number, itemCount: number,
@@ -205,11 +318,9 @@ export class Mangas {
{ {
wasManuallyMarkedAsRead, wasManuallyMarkedAsRead,
changeCategoriesPatch, changeCategoriesPatch,
}: Action extends 'mark_as_read' mangaIdToMigrateTo,
? { wasManuallyMarkedAsRead: boolean; changeCategoriesPatch?: never } ...migrateOptions
: Action extends 'change_categories' }: PerformActionOptions<Action>,
? { wasManuallyMarkedAsRead?: never; changeCategoriesPatch: UpdateMangaCategoriesPatchInput }
: { wasManuallyMarkedAsRead?: boolean; changeCategoriesPatch?: UpdateMangaCategoriesPatchInput },
): Promise<void> { ): Promise<void> {
switch (action) { switch (action) {
case 'download': case 'download':
@@ -224,6 +335,9 @@ export class Mangas {
return Mangas.removeFromLibrary(mangaIds); return Mangas.removeFromLibrary(mangaIds);
case 'change_categories': case 'change_categories':
return Mangas.changeCategories(mangaIds, changeCategoriesPatch!); return Mangas.changeCategories(mangaIds, changeCategoriesPatch!);
case 'migrate': {
return Mangas.migrate(mangaIds[0], mangaIdToMigrateTo!, migrateOptions as unknown as MigrateOptions);
}
default: default:
throw new Error(`Mangas::performAction: unknown action "${action}"`); throw new Error(`Mangas::performAction: unknown action "${action}"`);
} }

View File

@@ -508,7 +508,7 @@ export type PageInfoFieldPolicy = {
hasPreviousPage?: FieldPolicy<any> | FieldReadFunction<any>, hasPreviousPage?: FieldPolicy<any> | FieldReadFunction<any>,
startCursor?: FieldPolicy<any> | FieldReadFunction<any> startCursor?: FieldPolicy<any> | FieldReadFunction<any>
}; };
export type PartialSettingsTypeKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | PartialSettingsTypeKeySpecifier)[]; export type PartialSettingsTypeKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'flareSolverrEnabled' | 'flareSolverrSessionName' | 'flareSolverrSessionTtl' | 'flareSolverrTimeout' | 'flareSolverrUrl' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | PartialSettingsTypeKeySpecifier)[];
export type PartialSettingsTypeFieldPolicy = { export type PartialSettingsTypeFieldPolicy = {
autoDownloadAheadLimit?: FieldPolicy<any> | FieldReadFunction<any>, autoDownloadAheadLimit?: FieldPolicy<any> | FieldReadFunction<any>,
autoDownloadNewChapters?: FieldPolicy<any> | FieldReadFunction<any>, autoDownloadNewChapters?: FieldPolicy<any> | FieldReadFunction<any>,
@@ -528,6 +528,11 @@ export type PartialSettingsTypeFieldPolicy = {
excludeNotStarted?: FieldPolicy<any> | FieldReadFunction<any>, excludeNotStarted?: FieldPolicy<any> | FieldReadFunction<any>,
excludeUnreadChapters?: FieldPolicy<any> | FieldReadFunction<any>, excludeUnreadChapters?: FieldPolicy<any> | FieldReadFunction<any>,
extensionRepos?: FieldPolicy<any> | FieldReadFunction<any>, extensionRepos?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrSessionName?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrSessionTtl?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrTimeout?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrUrl?: FieldPolicy<any> | FieldReadFunction<any>,
globalUpdateInterval?: FieldPolicy<any> | FieldReadFunction<any>, globalUpdateInterval?: FieldPolicy<any> | FieldReadFunction<any>,
gqlDebugLogsEnabled?: FieldPolicy<any> | FieldReadFunction<any>, gqlDebugLogsEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
initialOpenInBrowserEnabled?: FieldPolicy<any> | FieldReadFunction<any>, initialOpenInBrowserEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
@@ -631,7 +636,7 @@ export type SetSettingsPayloadFieldPolicy = {
clientMutationId?: FieldPolicy<any> | FieldReadFunction<any>, clientMutationId?: FieldPolicy<any> | FieldReadFunction<any>,
settings?: FieldPolicy<any> | FieldReadFunction<any> settings?: FieldPolicy<any> | FieldReadFunction<any>
}; };
export type SettingsKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | SettingsKeySpecifier)[]; export type SettingsKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'flareSolverrEnabled' | 'flareSolverrSessionName' | 'flareSolverrSessionTtl' | 'flareSolverrTimeout' | 'flareSolverrUrl' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | SettingsKeySpecifier)[];
export type SettingsFieldPolicy = { export type SettingsFieldPolicy = {
autoDownloadAheadLimit?: FieldPolicy<any> | FieldReadFunction<any>, autoDownloadAheadLimit?: FieldPolicy<any> | FieldReadFunction<any>,
autoDownloadNewChapters?: FieldPolicy<any> | FieldReadFunction<any>, autoDownloadNewChapters?: FieldPolicy<any> | FieldReadFunction<any>,
@@ -651,6 +656,11 @@ export type SettingsFieldPolicy = {
excludeNotStarted?: FieldPolicy<any> | FieldReadFunction<any>, excludeNotStarted?: FieldPolicy<any> | FieldReadFunction<any>,
excludeUnreadChapters?: FieldPolicy<any> | FieldReadFunction<any>, excludeUnreadChapters?: FieldPolicy<any> | FieldReadFunction<any>,
extensionRepos?: FieldPolicy<any> | FieldReadFunction<any>, extensionRepos?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrSessionName?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrSessionTtl?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrTimeout?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrUrl?: FieldPolicy<any> | FieldReadFunction<any>,
globalUpdateInterval?: FieldPolicy<any> | FieldReadFunction<any>, globalUpdateInterval?: FieldPolicy<any> | FieldReadFunction<any>,
gqlDebugLogsEnabled?: FieldPolicy<any> | FieldReadFunction<any>, gqlDebugLogsEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
initialOpenInBrowserEnabled?: FieldPolicy<any> | FieldReadFunction<any>, initialOpenInBrowserEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
@@ -668,7 +678,7 @@ export type SettingsFieldPolicy = {
webUIInterface?: FieldPolicy<any> | FieldReadFunction<any>, webUIInterface?: FieldPolicy<any> | FieldReadFunction<any>,
webUIUpdateCheckInterval?: FieldPolicy<any> | FieldReadFunction<any> webUIUpdateCheckInterval?: FieldPolicy<any> | FieldReadFunction<any>
}; };
export type SettingsTypeKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | SettingsTypeKeySpecifier)[]; export type SettingsTypeKeySpecifier = ('autoDownloadAheadLimit' | 'autoDownloadNewChapters' | 'backupInterval' | 'backupPath' | 'backupTTL' | 'backupTime' | 'basicAuthEnabled' | 'basicAuthPassword' | 'basicAuthUsername' | 'debugLogsEnabled' | 'downloadAsCbz' | 'downloadsPath' | 'electronPath' | 'excludeCompleted' | 'excludeEntryWithUnreadChapters' | 'excludeNotStarted' | 'excludeUnreadChapters' | 'extensionRepos' | 'flareSolverrEnabled' | 'flareSolverrSessionName' | 'flareSolverrSessionTtl' | 'flareSolverrTimeout' | 'flareSolverrUrl' | 'globalUpdateInterval' | 'gqlDebugLogsEnabled' | 'initialOpenInBrowserEnabled' | 'ip' | 'localSourcePath' | 'maxSourcesInParallel' | 'port' | 'socksProxyEnabled' | 'socksProxyHost' | 'socksProxyPort' | 'systemTrayEnabled' | 'updateMangas' | 'webUIChannel' | 'webUIFlavor' | 'webUIInterface' | 'webUIUpdateCheckInterval' | SettingsTypeKeySpecifier)[];
export type SettingsTypeFieldPolicy = { export type SettingsTypeFieldPolicy = {
autoDownloadAheadLimit?: FieldPolicy<any> | FieldReadFunction<any>, autoDownloadAheadLimit?: FieldPolicy<any> | FieldReadFunction<any>,
autoDownloadNewChapters?: FieldPolicy<any> | FieldReadFunction<any>, autoDownloadNewChapters?: FieldPolicy<any> | FieldReadFunction<any>,
@@ -688,6 +698,11 @@ export type SettingsTypeFieldPolicy = {
excludeNotStarted?: FieldPolicy<any> | FieldReadFunction<any>, excludeNotStarted?: FieldPolicy<any> | FieldReadFunction<any>,
excludeUnreadChapters?: FieldPolicy<any> | FieldReadFunction<any>, excludeUnreadChapters?: FieldPolicy<any> | FieldReadFunction<any>,
extensionRepos?: FieldPolicy<any> | FieldReadFunction<any>, extensionRepos?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrSessionName?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrSessionTtl?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrTimeout?: FieldPolicy<any> | FieldReadFunction<any>,
flareSolverrUrl?: FieldPolicy<any> | FieldReadFunction<any>,
globalUpdateInterval?: FieldPolicy<any> | FieldReadFunction<any>, globalUpdateInterval?: FieldPolicy<any> | FieldReadFunction<any>,
gqlDebugLogsEnabled?: FieldPolicy<any> | FieldReadFunction<any>, gqlDebugLogsEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
initialOpenInBrowserEnabled?: FieldPolicy<any> | FieldReadFunction<any>, initialOpenInBrowserEnabled?: FieldPolicy<any> | FieldReadFunction<any>,
@@ -785,7 +800,7 @@ export type TrackRecordNodeListFieldPolicy = {
pageInfo?: FieldPolicy<any> | FieldReadFunction<any>, pageInfo?: FieldPolicy<any> | FieldReadFunction<any>,
totalCount?: FieldPolicy<any> | FieldReadFunction<any> totalCount?: FieldPolicy<any> | FieldReadFunction<any>
}; };
export type TrackRecordTypeKeySpecifier = ('displayScore' | 'finishDate' | 'id' | 'lastChapterRead' | 'libraryId' | 'manga' | 'mangaId' | 'remoteId' | 'remoteUrl' | 'score' | 'startDate' | 'status' | 'syncId' | 'title' | 'totalChapters' | 'tracker' | TrackRecordTypeKeySpecifier)[]; export type TrackRecordTypeKeySpecifier = ('displayScore' | 'finishDate' | 'id' | 'lastChapterRead' | 'libraryId' | 'manga' | 'mangaId' | 'remoteId' | 'remoteUrl' | 'score' | 'startDate' | 'status' | 'title' | 'totalChapters' | 'tracker' | 'trackerId' | TrackRecordTypeKeySpecifier)[];
export type TrackRecordTypeFieldPolicy = { export type TrackRecordTypeFieldPolicy = {
displayScore?: FieldPolicy<any> | FieldReadFunction<any>, displayScore?: FieldPolicy<any> | FieldReadFunction<any>,
finishDate?: FieldPolicy<any> | FieldReadFunction<any>, finishDate?: FieldPolicy<any> | FieldReadFunction<any>,
@@ -799,25 +814,31 @@ export type TrackRecordTypeFieldPolicy = {
score?: FieldPolicy<any> | FieldReadFunction<any>, score?: FieldPolicy<any> | FieldReadFunction<any>,
startDate?: FieldPolicy<any> | FieldReadFunction<any>, startDate?: FieldPolicy<any> | FieldReadFunction<any>,
status?: FieldPolicy<any> | FieldReadFunction<any>, status?: FieldPolicy<any> | FieldReadFunction<any>,
syncId?: FieldPolicy<any> | FieldReadFunction<any>,
title?: FieldPolicy<any> | FieldReadFunction<any>,
totalChapters?: FieldPolicy<any> | FieldReadFunction<any>,
tracker?: FieldPolicy<any> | FieldReadFunction<any>
};
export type TrackSearchTypeKeySpecifier = ('coverUrl' | 'mediaId' | 'publishingStatus' | 'publishingType' | 'startDate' | 'summary' | 'syncId' | 'title' | 'totalChapters' | 'tracker' | 'trackingUrl' | TrackSearchTypeKeySpecifier)[];
export type TrackSearchTypeFieldPolicy = {
coverUrl?: FieldPolicy<any> | FieldReadFunction<any>,
mediaId?: FieldPolicy<any> | FieldReadFunction<any>,
publishingStatus?: FieldPolicy<any> | FieldReadFunction<any>,
publishingType?: FieldPolicy<any> | FieldReadFunction<any>,
startDate?: FieldPolicy<any> | FieldReadFunction<any>,
summary?: FieldPolicy<any> | FieldReadFunction<any>,
syncId?: FieldPolicy<any> | FieldReadFunction<any>,
title?: FieldPolicy<any> | FieldReadFunction<any>, title?: FieldPolicy<any> | FieldReadFunction<any>,
totalChapters?: FieldPolicy<any> | FieldReadFunction<any>, totalChapters?: FieldPolicy<any> | FieldReadFunction<any>,
tracker?: FieldPolicy<any> | FieldReadFunction<any>, tracker?: FieldPolicy<any> | FieldReadFunction<any>,
trackerId?: FieldPolicy<any> | FieldReadFunction<any>
};
export type TrackSearchTypeKeySpecifier = ('coverUrl' | 'id' | 'publishingStatus' | 'publishingType' | 'remoteId' | 'startDate' | 'summary' | 'title' | 'totalChapters' | 'tracker' | 'trackerId' | 'trackingUrl' | TrackSearchTypeKeySpecifier)[];
export type TrackSearchTypeFieldPolicy = {
coverUrl?: FieldPolicy<any> | FieldReadFunction<any>,
id?: FieldPolicy<any> | FieldReadFunction<any>,
publishingStatus?: FieldPolicy<any> | FieldReadFunction<any>,
publishingType?: FieldPolicy<any> | FieldReadFunction<any>,
remoteId?: FieldPolicy<any> | FieldReadFunction<any>,
startDate?: FieldPolicy<any> | FieldReadFunction<any>,
summary?: FieldPolicy<any> | FieldReadFunction<any>,
title?: FieldPolicy<any> | FieldReadFunction<any>,
totalChapters?: FieldPolicy<any> | FieldReadFunction<any>,
tracker?: FieldPolicy<any> | FieldReadFunction<any>,
trackerId?: FieldPolicy<any> | FieldReadFunction<any>,
trackingUrl?: FieldPolicy<any> | FieldReadFunction<any> trackingUrl?: FieldPolicy<any> | FieldReadFunction<any>
}; };
export type TrackStatusTypeKeySpecifier = ('name' | 'value' | TrackStatusTypeKeySpecifier)[];
export type TrackStatusTypeFieldPolicy = {
name?: FieldPolicy<any> | FieldReadFunction<any>,
value?: FieldPolicy<any> | FieldReadFunction<any>
};
export type TrackerEdgeKeySpecifier = ('cursor' | 'node' | TrackerEdgeKeySpecifier)[]; export type TrackerEdgeKeySpecifier = ('cursor' | 'node' | TrackerEdgeKeySpecifier)[];
export type TrackerEdgeFieldPolicy = { export type TrackerEdgeFieldPolicy = {
cursor?: FieldPolicy<any> | FieldReadFunction<any>, cursor?: FieldPolicy<any> | FieldReadFunction<any>,
@@ -830,13 +851,15 @@ export type TrackerNodeListFieldPolicy = {
pageInfo?: FieldPolicy<any> | FieldReadFunction<any>, pageInfo?: FieldPolicy<any> | FieldReadFunction<any>,
totalCount?: FieldPolicy<any> | FieldReadFunction<any> totalCount?: FieldPolicy<any> | FieldReadFunction<any>
}; };
export type TrackerTypeKeySpecifier = ('authUrl' | 'icon' | 'id' | 'isLoggedIn' | 'name' | 'trackRecords' | TrackerTypeKeySpecifier)[]; export type TrackerTypeKeySpecifier = ('authUrl' | 'icon' | 'id' | 'isLoggedIn' | 'name' | 'scores' | 'statuses' | 'trackRecords' | TrackerTypeKeySpecifier)[];
export type TrackerTypeFieldPolicy = { export type TrackerTypeFieldPolicy = {
authUrl?: FieldPolicy<any> | FieldReadFunction<any>, authUrl?: FieldPolicy<any> | FieldReadFunction<any>,
icon?: FieldPolicy<any> | FieldReadFunction<any>, icon?: FieldPolicy<any> | FieldReadFunction<any>,
id?: FieldPolicy<any> | FieldReadFunction<any>, id?: FieldPolicy<any> | FieldReadFunction<any>,
isLoggedIn?: FieldPolicy<any> | FieldReadFunction<any>, isLoggedIn?: FieldPolicy<any> | FieldReadFunction<any>,
name?: FieldPolicy<any> | FieldReadFunction<any>, name?: FieldPolicy<any> | FieldReadFunction<any>,
scores?: FieldPolicy<any> | FieldReadFunction<any>,
statuses?: FieldPolicy<any> | FieldReadFunction<any>,
trackRecords?: FieldPolicy<any> | FieldReadFunction<any> trackRecords?: FieldPolicy<any> | FieldReadFunction<any>
}; };
export type TriStateFilterKeySpecifier = ('default' | 'name' | TriStateFilterKeySpecifier)[]; export type TriStateFilterKeySpecifier = ('default' | 'name' | TriStateFilterKeySpecifier)[];
@@ -1351,6 +1374,10 @@ export type StrictTypedTypePolicies = {
keyFields?: false | TrackSearchTypeKeySpecifier | (() => undefined | TrackSearchTypeKeySpecifier), keyFields?: false | TrackSearchTypeKeySpecifier | (() => undefined | TrackSearchTypeKeySpecifier),
fields?: TrackSearchTypeFieldPolicy, fields?: TrackSearchTypeFieldPolicy,
}, },
TrackStatusType?: Omit<TypePolicy, "fields" | "keyFields"> & {
keyFields?: false | TrackStatusTypeKeySpecifier | (() => undefined | TrackStatusTypeKeySpecifier),
fields?: TrackStatusTypeFieldPolicy,
},
TrackerEdge?: Omit<TypePolicy, "fields" | "keyFields"> & { TrackerEdge?: Omit<TypePolicy, "fields" | "keyFields"> & {
keyFields?: false | TrackerEdgeKeySpecifier | (() => undefined | TrackerEdgeKeySpecifier), keyFields?: false | TrackerEdgeKeySpecifier | (() => undefined | TrackerEdgeKeySpecifier),
fields?: TrackerEdgeFieldPolicy, fields?: TrackerEdgeFieldPolicy,

View File

@@ -52,7 +52,8 @@ export type BackupRestoreStatus = {
export type BindTrackInput = { export type BindTrackInput = {
clientMutationId?: InputMaybe<Scalars['String']['input']>; clientMutationId?: InputMaybe<Scalars['String']['input']>;
mangaId: Scalars['Int']['input']; mangaId: Scalars['Int']['input'];
track: TrackSearchTypeInput; remoteId: Scalars['LongString']['input'];
trackerId: Scalars['Int']['input'];
}; };
export type BindTrackPayload = { export type BindTrackPayload = {
@@ -1369,6 +1370,11 @@ export type PartialSettingsType = Settings & {
excludeNotStarted?: Maybe<Scalars['Boolean']['output']>; excludeNotStarted?: Maybe<Scalars['Boolean']['output']>;
excludeUnreadChapters?: Maybe<Scalars['Boolean']['output']>; excludeUnreadChapters?: Maybe<Scalars['Boolean']['output']>;
extensionRepos?: Maybe<Array<Scalars['String']['output']>>; extensionRepos?: Maybe<Array<Scalars['String']['output']>>;
flareSolverrEnabled?: Maybe<Scalars['Boolean']['output']>;
flareSolverrSessionName?: Maybe<Scalars['String']['output']>;
flareSolverrSessionTtl?: Maybe<Scalars['Int']['output']>;
flareSolverrTimeout?: Maybe<Scalars['Int']['output']>;
flareSolverrUrl?: Maybe<Scalars['String']['output']>;
globalUpdateInterval?: Maybe<Scalars['Float']['output']>; globalUpdateInterval?: Maybe<Scalars['Float']['output']>;
gqlDebugLogsEnabled?: Maybe<Scalars['Boolean']['output']>; gqlDebugLogsEnabled?: Maybe<Scalars['Boolean']['output']>;
initialOpenInBrowserEnabled?: Maybe<Scalars['Boolean']['output']>; initialOpenInBrowserEnabled?: Maybe<Scalars['Boolean']['output']>;
@@ -1406,6 +1412,11 @@ export type PartialSettingsTypeInput = {
excludeNotStarted?: InputMaybe<Scalars['Boolean']['input']>; excludeNotStarted?: InputMaybe<Scalars['Boolean']['input']>;
excludeUnreadChapters?: InputMaybe<Scalars['Boolean']['input']>; excludeUnreadChapters?: InputMaybe<Scalars['Boolean']['input']>;
extensionRepos?: InputMaybe<Array<Scalars['String']['input']>>; extensionRepos?: InputMaybe<Array<Scalars['String']['input']>>;
flareSolverrEnabled?: InputMaybe<Scalars['Boolean']['input']>;
flareSolverrSessionName?: InputMaybe<Scalars['String']['input']>;
flareSolverrSessionTtl?: InputMaybe<Scalars['Int']['input']>;
flareSolverrTimeout?: InputMaybe<Scalars['Int']['input']>;
flareSolverrUrl?: InputMaybe<Scalars['String']['input']>;
globalUpdateInterval?: InputMaybe<Scalars['Float']['input']>; globalUpdateInterval?: InputMaybe<Scalars['Float']['input']>;
gqlDebugLogsEnabled?: InputMaybe<Scalars['Boolean']['input']>; gqlDebugLogsEnabled?: InputMaybe<Scalars['Boolean']['input']>;
initialOpenInBrowserEnabled?: InputMaybe<Scalars['Boolean']['input']>; initialOpenInBrowserEnabled?: InputMaybe<Scalars['Boolean']['input']>;
@@ -1746,6 +1757,11 @@ export type Settings = {
excludeNotStarted?: Maybe<Scalars['Boolean']['output']>; excludeNotStarted?: Maybe<Scalars['Boolean']['output']>;
excludeUnreadChapters?: Maybe<Scalars['Boolean']['output']>; excludeUnreadChapters?: Maybe<Scalars['Boolean']['output']>;
extensionRepos?: Maybe<Array<Scalars['String']['output']>>; extensionRepos?: Maybe<Array<Scalars['String']['output']>>;
flareSolverrEnabled?: Maybe<Scalars['Boolean']['output']>;
flareSolverrSessionName?: Maybe<Scalars['String']['output']>;
flareSolverrSessionTtl?: Maybe<Scalars['Int']['output']>;
flareSolverrTimeout?: Maybe<Scalars['Int']['output']>;
flareSolverrUrl?: Maybe<Scalars['String']['output']>;
globalUpdateInterval?: Maybe<Scalars['Float']['output']>; globalUpdateInterval?: Maybe<Scalars['Float']['output']>;
gqlDebugLogsEnabled?: Maybe<Scalars['Boolean']['output']>; gqlDebugLogsEnabled?: Maybe<Scalars['Boolean']['output']>;
initialOpenInBrowserEnabled?: Maybe<Scalars['Boolean']['output']>; initialOpenInBrowserEnabled?: Maybe<Scalars['Boolean']['output']>;
@@ -1784,6 +1800,11 @@ export type SettingsType = Settings & {
excludeNotStarted: Scalars['Boolean']['output']; excludeNotStarted: Scalars['Boolean']['output'];
excludeUnreadChapters: Scalars['Boolean']['output']; excludeUnreadChapters: Scalars['Boolean']['output'];
extensionRepos: Array<Scalars['String']['output']>; extensionRepos: Array<Scalars['String']['output']>;
flareSolverrEnabled: Scalars['Boolean']['output'];
flareSolverrSessionName: Scalars['String']['output'];
flareSolverrSessionTtl: Scalars['Int']['output'];
flareSolverrTimeout: Scalars['Int']['output'];
flareSolverrUrl: Scalars['String']['output'];
globalUpdateInterval: Scalars['Float']['output']; globalUpdateInterval: Scalars['Float']['output'];
gqlDebugLogsEnabled: Scalars['Boolean']['output']; gqlDebugLogsEnabled: Scalars['Boolean']['output'];
initialOpenInBrowserEnabled: Scalars['Boolean']['output']; initialOpenInBrowserEnabled: Scalars['Boolean']['output'];
@@ -1983,9 +2004,9 @@ export type TrackRecordConditionInput = {
score?: InputMaybe<Scalars['Float']['input']>; score?: InputMaybe<Scalars['Float']['input']>;
startDate?: InputMaybe<Scalars['LongString']['input']>; startDate?: InputMaybe<Scalars['LongString']['input']>;
status?: InputMaybe<Scalars['Int']['input']>; status?: InputMaybe<Scalars['Int']['input']>;
syncId?: InputMaybe<Scalars['Int']['input']>;
title?: InputMaybe<Scalars['String']['input']>; title?: InputMaybe<Scalars['String']['input']>;
totalChapters?: InputMaybe<Scalars['Int']['input']>; totalChapters?: InputMaybe<Scalars['Int']['input']>;
trackerId?: InputMaybe<Scalars['Int']['input']>;
}; };
export type TrackRecordEdge = Edge & { export type TrackRecordEdge = Edge & {
@@ -2008,9 +2029,9 @@ export type TrackRecordFilterInput = {
score?: InputMaybe<DoubleFilterInput>; score?: InputMaybe<DoubleFilterInput>;
startDate?: InputMaybe<LongFilterInput>; startDate?: InputMaybe<LongFilterInput>;
status?: InputMaybe<IntFilterInput>; status?: InputMaybe<IntFilterInput>;
syncId?: InputMaybe<IntFilterInput>;
title?: InputMaybe<StringFilterInput>; title?: InputMaybe<StringFilterInput>;
totalChapters?: InputMaybe<IntFilterInput>; totalChapters?: InputMaybe<IntFilterInput>;
trackerId?: InputMaybe<IntFilterInput>;
}; };
export type TrackRecordNodeList = NodeList & { export type TrackRecordNodeList = NodeList & {
@@ -2029,9 +2050,9 @@ export enum TrackRecordOrderBy {
RemoteId = 'REMOTE_ID', RemoteId = 'REMOTE_ID',
Score = 'SCORE', Score = 'SCORE',
StartDate = 'START_DATE', StartDate = 'START_DATE',
SyncId = 'SYNC_ID',
Title = 'TITLE', Title = 'TITLE',
TotalChapters = 'TOTAL_CHAPTERS' TotalChapters = 'TOTAL_CHAPTERS',
TrackerId = 'TRACKER_ID'
} }
export type TrackRecordType = { export type TrackRecordType = {
@@ -2048,38 +2069,32 @@ export type TrackRecordType = {
score: Scalars['Float']['output']; score: Scalars['Float']['output'];
startDate: Scalars['LongString']['output']; startDate: Scalars['LongString']['output'];
status: Scalars['Int']['output']; status: Scalars['Int']['output'];
syncId: Scalars['Int']['output'];
title: Scalars['String']['output']; title: Scalars['String']['output'];
totalChapters: Scalars['Int']['output']; totalChapters: Scalars['Int']['output'];
tracker: TrackerType; tracker: TrackerType;
trackerId: Scalars['Int']['output'];
}; };
export type TrackSearchType = { export type TrackSearchType = {
__typename?: 'TrackSearchType'; __typename?: 'TrackSearchType';
coverUrl: Scalars['String']['output']; coverUrl: Scalars['String']['output'];
mediaId: Scalars['LongString']['output']; id: Scalars['Int']['output'];
publishingStatus: Scalars['String']['output']; publishingStatus: Scalars['String']['output'];
publishingType: Scalars['String']['output']; publishingType: Scalars['String']['output'];
remoteId: Scalars['LongString']['output'];
startDate: Scalars['String']['output']; startDate: Scalars['String']['output'];
summary: Scalars['String']['output']; summary: Scalars['String']['output'];
syncId: Scalars['Int']['output'];
title: Scalars['String']['output']; title: Scalars['String']['output'];
totalChapters: Scalars['Int']['output']; totalChapters: Scalars['Int']['output'];
tracker: TrackerType; tracker: TrackerType;
trackerId: Scalars['Int']['output'];
trackingUrl: Scalars['String']['output']; trackingUrl: Scalars['String']['output'];
}; };
export type TrackSearchTypeInput = { export type TrackStatusType = {
coverUrl: Scalars['String']['input']; __typename?: 'TrackStatusType';
mediaId: Scalars['LongString']['input']; name: Scalars['String']['output'];
publishingStatus: Scalars['String']['input']; value: Scalars['Int']['output'];
publishingType: Scalars['String']['input'];
startDate: Scalars['String']['input'];
summary: Scalars['String']['input'];
syncId: Scalars['Int']['input'];
title: Scalars['String']['input'];
totalChapters: Scalars['Int']['input'];
trackingUrl: Scalars['String']['input'];
}; };
export type TrackerConditionInput = { export type TrackerConditionInput = {
@@ -2116,6 +2131,8 @@ export type TrackerType = {
id: Scalars['Int']['output']; id: Scalars['Int']['output'];
isLoggedIn: Scalars['Boolean']['output']; isLoggedIn: Scalars['Boolean']['output'];
name: Scalars['String']['output']; name: Scalars['String']['output'];
scores: Array<Scalars['String']['output']>;
statuses: Array<TrackStatusType>;
trackRecords: TrackRecordNodeList; trackRecords: TrackRecordNodeList;
}; };
@@ -2756,6 +2773,15 @@ export type GetMangaFetchMutationVariables = Exact<{
export type GetMangaFetchMutation = { __typename?: 'Mutation', fetchManga: { __typename?: 'FetchMangaPayload', clientMutationId?: string | null, manga: { __typename?: 'MangaType', unreadCount: number, downloadCount: number, artist?: string | null, author?: string | null, chaptersLastFetchedAt?: any | null, description?: string | null, genre: Array<string>, id: number, inLibrary: boolean, inLibraryAt: any, initialized: boolean, lastFetchedAt?: any | null, realUrl?: string | null, status: MangaStatus, thumbnailUrl?: string | null, title: string, url: string, lastReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestFetchedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestUploadedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, categories: { __typename?: 'CategoryNodeList', totalCount: number, nodes: Array<{ __typename?: 'CategoryType', default: boolean, id: number, includeInUpdate: IncludeOrExclude, includeInDownload: IncludeOrExclude, name: string, order: number, meta: Array<{ __typename?: 'CategoryMetaType', key: string, value: string }>, mangas: { __typename?: 'MangaNodeList', totalCount: number } }> }, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, meta: Array<{ __typename?: 'MangaMetaType', key: string, value: string }>, source?: { __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } } | null } } }; export type GetMangaFetchMutation = { __typename?: 'Mutation', fetchManga: { __typename?: 'FetchMangaPayload', clientMutationId?: string | null, manga: { __typename?: 'MangaType', unreadCount: number, downloadCount: number, artist?: string | null, author?: string | null, chaptersLastFetchedAt?: any | null, description?: string | null, genre: Array<string>, id: number, inLibrary: boolean, inLibraryAt: any, initialized: boolean, lastFetchedAt?: any | null, realUrl?: string | null, status: MangaStatus, thumbnailUrl?: string | null, title: string, url: string, lastReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestFetchedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestUploadedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, categories: { __typename?: 'CategoryNodeList', totalCount: number, nodes: Array<{ __typename?: 'CategoryType', default: boolean, id: number, includeInUpdate: IncludeOrExclude, includeInDownload: IncludeOrExclude, name: string, order: number, meta: Array<{ __typename?: 'CategoryMetaType', key: string, value: string }>, mangas: { __typename?: 'MangaNodeList', totalCount: number } }> }, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, meta: Array<{ __typename?: 'MangaMetaType', key: string, value: string }>, source?: { __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } } | null } } };
export type GetMangaToMigrateToFetchMutationVariables = Exact<{
id: Scalars['Int']['input'];
migrateChapters: Scalars['Boolean']['input'];
migrateCategories: Scalars['Boolean']['input'];
}>;
export type GetMangaToMigrateToFetchMutation = { __typename?: 'Mutation', fetchManga: { __typename?: 'FetchMangaPayload', clientMutationId?: string | null, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, categories?: { __typename?: 'CategoryNodeList', nodes: Array<{ __typename?: 'CategoryType', id: number }> } } }, fetchChapters?: { __typename?: 'FetchChaptersPayload', chapters: Array<{ __typename?: 'ChapterType', id: number, chapterNumber: number, isRead: boolean, isDownloaded: boolean, isBookmarked: boolean, manga: { __typename?: 'MangaType', id: number } }> } };
export type SetMangaMetadataMutationVariables = Exact<{ export type SetMangaMetadataMutationVariables = Exact<{
input: SetMangaMetaInput; input: SetMangaMetaInput;
}>; }>;
@@ -2983,6 +3009,15 @@ export type GetMangaQueryVariables = Exact<{
export type GetMangaQuery = { __typename?: 'Query', manga: { __typename?: 'MangaType', unreadCount: number, downloadCount: number, artist?: string | null, author?: string | null, chaptersLastFetchedAt?: any | null, description?: string | null, genre: Array<string>, id: number, inLibrary: boolean, inLibraryAt: any, initialized: boolean, lastFetchedAt?: any | null, realUrl?: string | null, status: MangaStatus, thumbnailUrl?: string | null, title: string, url: string, lastReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestFetchedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestUploadedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, categories: { __typename?: 'CategoryNodeList', totalCount: number, nodes: Array<{ __typename?: 'CategoryType', default: boolean, id: number, includeInUpdate: IncludeOrExclude, includeInDownload: IncludeOrExclude, name: string, order: number, meta: Array<{ __typename?: 'CategoryMetaType', key: string, value: string }>, mangas: { __typename?: 'MangaNodeList', totalCount: number } }> }, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, meta: Array<{ __typename?: 'MangaMetaType', key: string, value: string }>, source?: { __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } } | null } }; export type GetMangaQuery = { __typename?: 'Query', manga: { __typename?: 'MangaType', unreadCount: number, downloadCount: number, artist?: string | null, author?: string | null, chaptersLastFetchedAt?: any | null, description?: string | null, genre: Array<string>, id: number, inLibrary: boolean, inLibraryAt: any, initialized: boolean, lastFetchedAt?: any | null, realUrl?: string | null, status: MangaStatus, thumbnailUrl?: string | null, title: string, url: string, lastReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestFetchedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestUploadedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, categories: { __typename?: 'CategoryNodeList', totalCount: number, nodes: Array<{ __typename?: 'CategoryType', default: boolean, id: number, includeInUpdate: IncludeOrExclude, includeInDownload: IncludeOrExclude, name: string, order: number, meta: Array<{ __typename?: 'CategoryMetaType', key: string, value: string }>, mangas: { __typename?: 'MangaNodeList', totalCount: number } }> }, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, meta: Array<{ __typename?: 'MangaMetaType', key: string, value: string }>, source?: { __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } } | null } };
export type GetMangaToMigrateQueryVariables = Exact<{
id: Scalars['Int']['input'];
migrateChapters: Scalars['Boolean']['input'];
migrateCategories: Scalars['Boolean']['input'];
}>;
export type GetMangaToMigrateQuery = { __typename?: 'Query', manga: { __typename?: 'MangaType', id: number, inLibrary: boolean, title: string, chapters?: { __typename?: 'ChapterNodeList', totalCount: number, nodes: Array<{ __typename?: 'ChapterType', id: number, chapterNumber: number, isRead: boolean, isDownloaded: boolean, isBookmarked: boolean, manga: { __typename?: 'MangaType', id: number } }> }, categories?: { __typename?: 'CategoryNodeList', nodes: Array<{ __typename?: 'CategoryType', id: number }> } } };
export type GetMangasQueryVariables = Exact<{ export type GetMangasQueryVariables = Exact<{
after?: InputMaybe<Scalars['Cursor']['input']>; after?: InputMaybe<Scalars['Cursor']['input']>;
before?: InputMaybe<Scalars['Cursor']['input']>; before?: InputMaybe<Scalars['Cursor']['input']>;
@@ -2998,6 +3033,13 @@ export type GetMangasQueryVariables = Exact<{
export type GetMangasQuery = { __typename?: 'Query', mangas: { __typename?: 'MangaNodeList', totalCount: number, nodes: Array<{ __typename?: 'MangaType', unreadCount: number, downloadCount: number, artist?: string | null, author?: string | null, chaptersLastFetchedAt?: any | null, description?: string | null, genre: Array<string>, id: number, inLibrary: boolean, inLibraryAt: any, initialized: boolean, lastFetchedAt?: any | null, realUrl?: string | null, status: MangaStatus, thumbnailUrl?: string | null, title: string, url: string, lastReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestFetchedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestUploadedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, categories: { __typename?: 'CategoryNodeList', totalCount: number, nodes: Array<{ __typename?: 'CategoryType', default: boolean, id: number, includeInUpdate: IncludeOrExclude, includeInDownload: IncludeOrExclude, name: string, order: number, meta: Array<{ __typename?: 'CategoryMetaType', key: string, value: string }>, mangas: { __typename?: 'MangaNodeList', totalCount: number } }> }, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, meta: Array<{ __typename?: 'MangaMetaType', key: string, value: string }>, source?: { __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } } | null }>, pageInfo: { __typename?: 'PageInfo', endCursor?: any | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: any | null } } }; export type GetMangasQuery = { __typename?: 'Query', mangas: { __typename?: 'MangaNodeList', totalCount: number, nodes: Array<{ __typename?: 'MangaType', unreadCount: number, downloadCount: number, artist?: string | null, author?: string | null, chaptersLastFetchedAt?: any | null, description?: string | null, genre: Array<string>, id: number, inLibrary: boolean, inLibraryAt: any, initialized: boolean, lastFetchedAt?: any | null, realUrl?: string | null, status: MangaStatus, thumbnailUrl?: string | null, title: string, url: string, lastReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestReadChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestFetchedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, latestUploadedChapter?: { __typename?: 'ChapterType', chapterNumber: number, fetchedAt: any, id: number, isBookmarked: boolean, isDownloaded: boolean, isRead: boolean, lastPageRead: number, lastReadAt: any, name: string, pageCount: number, realUrl?: string | null, scanlator?: string | null, sourceOrder: number, uploadDate: any, url: string, manga: { __typename?: 'MangaType', id: number, title: string, inLibrary: boolean, thumbnailUrl?: string | null }, meta: Array<{ __typename?: 'ChapterMetaType', key: string, value: string }> } | null, categories: { __typename?: 'CategoryNodeList', totalCount: number, nodes: Array<{ __typename?: 'CategoryType', default: boolean, id: number, includeInUpdate: IncludeOrExclude, includeInDownload: IncludeOrExclude, name: string, order: number, meta: Array<{ __typename?: 'CategoryMetaType', key: string, value: string }>, mangas: { __typename?: 'MangaNodeList', totalCount: number } }> }, chapters: { __typename?: 'ChapterNodeList', totalCount: number }, meta: Array<{ __typename?: 'MangaMetaType', key: string, value: string }>, source?: { __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } } | null }>, pageInfo: { __typename?: 'PageInfo', endCursor?: any | null, hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: any | null } } };
export type GetMigratableSourceMangasQueryVariables = Exact<{
sourceId: Scalars['LongString']['input'];
}>;
export type GetMigratableSourceMangasQuery = { __typename?: 'Query', mangas: { __typename?: 'MangaNodeList', nodes: Array<{ __typename?: 'MangaType', id: number, title: string, thumbnailUrl?: string | null, source?: { __typename?: 'SourceType', id: any } | null, categories: { __typename?: 'CategoryNodeList', nodes: Array<{ __typename?: 'CategoryType', id: number }> } }> } };
export type GetAboutQueryVariables = Exact<{ [key: string]: never; }>; export type GetAboutQueryVariables = Exact<{ [key: string]: never; }>;
@@ -3035,6 +3077,11 @@ export type GetSourcesQueryVariables = Exact<{ [key: string]: never; }>;
export type GetSourcesQuery = { __typename?: 'Query', sources: { __typename?: 'SourceNodeList', nodes: Array<{ __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } }> } }; export type GetSourcesQuery = { __typename?: 'Query', sources: { __typename?: 'SourceNodeList', nodes: Array<{ __typename?: 'SourceType', displayName: string, iconUrl: string, id: any, isConfigurable: boolean, isNsfw: boolean, lang: string, name: string, supportsLatest: boolean, extension: { __typename?: 'ExtensionType', pkgName: string, repo?: string | null } }> } };
export type GetMigratableSourcesQueryVariables = Exact<{ [key: string]: never; }>;
export type GetMigratableSourcesQuery = { __typename?: 'Query', mangas: { __typename?: 'MangaNodeList', nodes: Array<{ __typename?: 'MangaType', sourceId: any, source?: { __typename?: 'SourceType', id: any, name: string, lang: string, iconUrl: string } | null }> } };
export type GetUpdateStatusQueryVariables = Exact<{ [key: string]: never; }>; export type GetUpdateStatusQueryVariables = Exact<{ [key: string]: never; }>;

View File

@@ -48,6 +48,37 @@ export const GET_MANGA_FETCH = gql`
} }
`; `;
// makes the server fetch and return the manga
export const GET_MANGA_TO_MIGRATE_TO_FETCH = gql`
mutation GET_MANGA_TO_MIGRATE_TO_FETCH($id: Int!, $migrateChapters: Boolean!, $migrateCategories: Boolean!) {
fetchManga(input: { id: $id }) {
clientMutationId
manga {
id
title
inLibrary
categories @include(if: $migrateCategories) {
nodes {
id
}
}
}
}
fetchChapters(input: { mangaId: $id }) @include(if: $migrateChapters) {
chapters {
id
manga {
id
}
chapterNumber
isRead
isDownloaded
isBookmarked
}
}
}
`;
export const SET_MANGA_METADATA = gql` export const SET_MANGA_METADATA = gql`
mutation SET_MANGA_METADATA($input: SetMangaMetaInput!) { mutation SET_MANGA_METADATA($input: SetMangaMetaInput!) {
setMangaMeta(input: $input) { setMangaMeta(input: $input) {

View File

@@ -20,6 +20,35 @@ export const GET_MANGA = gql`
} }
`; `;
// returns the current manga from the database
export const GET_MANGA_TO_MIGRATE = gql`
query GET_MANGA_TO_MIGRATE($id: Int!, $migrateChapters: Boolean!, $migrateCategories: Boolean!) {
manga(id: $id) {
id
inLibrary
title
chapters @include(if: $migrateChapters) {
nodes {
id
manga {
id
}
chapterNumber
isRead
isDownloaded
isBookmarked
}
totalCount
}
categories @include(if: $migrateCategories) {
nodes {
id
}
}
}
}
`;
// returns the current manga from the database // returns the current manga from the database
export const GET_MANGAS = gql` export const GET_MANGAS = gql`
${FULL_MANGA_FIELDS} ${FULL_MANGA_FIELDS}
@@ -57,3 +86,23 @@ export const GET_MANGAS = gql`
} }
} }
`; `;
export const GET_MIGRATABLE_SOURCE_MANGAS = gql`
query GET_MIGRATABLE_SOURCE_MANGAS($sourceId: LongString!) {
mangas(condition: { sourceId: $sourceId, inLibrary: true }) {
nodes {
id
title
thumbnailUrl
source {
id
}
categories {
nodes {
id
}
}
}
}
}
`;

View File

@@ -28,3 +28,19 @@ export const GET_SOURCES = gql`
} }
} }
`; `;
export const GET_MIGRATABLE_SOURCES = gql`
query GET_MIGRATABLE_SOURCES {
mangas(condition: { inLibrary: true }) {
nodes {
sourceId
source {
id
name
lang
iconUrl
}
}
}
}
`;

View File

@@ -172,6 +172,14 @@ import {
UpdateMangaCategoriesPatchInput, UpdateMangaCategoriesPatchInput,
GetWebuiUpdateStatusQuery, GetWebuiUpdateStatusQuery,
GetWebuiUpdateStatusQueryVariables, GetWebuiUpdateStatusQueryVariables,
GetMigratableSourcesQuery,
GetMigratableSourcesQueryVariables,
GetMigratableSourceMangasQuery,
GetMigratableSourceMangasQueryVariables,
GetMangaToMigrateQuery,
GetMangaToMigrateQueryVariables,
GetMangaToMigrateToFetchMutation,
GetMangaToMigrateToFetchMutationVariables,
} from '@/lib/graphql/generated/graphql.ts'; } from '@/lib/graphql/generated/graphql.ts';
import { GET_GLOBAL_METADATAS } from '@/lib/graphql/queries/GlobalMetadataQuery.ts'; import { GET_GLOBAL_METADATAS } from '@/lib/graphql/queries/GlobalMetadataQuery.ts';
import { SET_GLOBAL_METADATA } from '@/lib/graphql/mutations/GlobalMetadataMutation.ts'; import { SET_GLOBAL_METADATA } from '@/lib/graphql/mutations/GlobalMetadataMutation.ts';
@@ -187,16 +195,22 @@ import {
INSTALL_EXTERNAL_EXTENSION, INSTALL_EXTERNAL_EXTENSION,
UPDATE_EXTENSION, UPDATE_EXTENSION,
} from '@/lib/graphql/mutations/ExtensionMutation.ts'; } from '@/lib/graphql/mutations/ExtensionMutation.ts';
import { GET_SOURCE, GET_SOURCES } from '@/lib/graphql/queries/SourceQuery.ts'; import { GET_MIGRATABLE_SOURCES, GET_SOURCE, GET_SOURCES } from '@/lib/graphql/queries/SourceQuery.ts';
import { import {
GET_MANGA_FETCH, GET_MANGA_FETCH,
GET_MANGA_TO_MIGRATE_TO_FETCH,
SET_MANGA_METADATA, SET_MANGA_METADATA,
UPDATE_MANGA, UPDATE_MANGA,
UPDATE_MANGA_CATEGORIES, UPDATE_MANGA_CATEGORIES,
UPDATE_MANGAS, UPDATE_MANGAS,
UPDATE_MANGAS_CATEGORIES, UPDATE_MANGAS_CATEGORIES,
} from '@/lib/graphql/mutations/MangaMutation.ts'; } from '@/lib/graphql/mutations/MangaMutation.ts';
import { GET_MANGA, GET_MANGAS } from '@/lib/graphql/queries/MangaQuery.ts'; import {
GET_MANGA,
GET_MANGA_TO_MIGRATE,
GET_MANGAS,
GET_MIGRATABLE_SOURCE_MANGAS,
} from '@/lib/graphql/queries/MangaQuery.ts';
import { GET_CATEGORIES, GET_CATEGORY_MANGAS } from '@/lib/graphql/queries/CategoryQuery.ts'; import { GET_CATEGORIES, GET_CATEGORY_MANGAS } from '@/lib/graphql/queries/CategoryQuery.ts';
import { GET_SOURCE_MANGAS_FETCH, UPDATE_SOURCE_PREFERENCES } from '@/lib/graphql/mutations/SourceMutation.ts'; import { GET_SOURCE_MANGAS_FETCH, UPDATE_SOURCE_PREFERENCES } from '@/lib/graphql/mutations/SourceMutation.ts';
import { import {
@@ -1330,6 +1344,33 @@ export class RequestManager {
return this.doRequest(GQLMethod.USE_QUERY, GET_MANGA, { id: Number(mangaId) }, options); return this.doRequest(GQLMethod.USE_QUERY, GET_MANGA, { id: Number(mangaId) }, options);
} }
public getManga(
mangaId: number | string,
options?: QueryOptions<GetMangaQueryVariables, GetMangaQuery>,
): AbortabaleApolloQueryResponse<GetMangaQuery> {
return this.doRequest(GQLMethod.QUERY, GET_MANGA, { id: Number(mangaId) }, options);
}
public getMangaToMigrate(
mangaId: number | string,
{
migrateChapters = false,
migrateCategories = false,
apolloOptions: options,
}: {
migrateChapters?: boolean;
migrateCategories?: boolean;
apolloOptions?: QueryOptions<GetMangaToMigrateQueryVariables, GetMangaToMigrateQuery>;
} = {},
): AbortabaleApolloQueryResponse<GetMangaToMigrateQuery> {
return this.doRequest(
GQLMethod.QUERY,
GET_MANGA_TO_MIGRATE,
{ id: Number(mangaId), migrateChapters, migrateCategories },
options,
);
}
public getMangaFetch( public getMangaFetch(
mangaId: number | string, mangaId: number | string,
options?: MutationOptions<GetMangaFetchMutation, GetMangaFetchMutationVariables>, options?: MutationOptions<GetMangaFetchMutation, GetMangaFetchMutationVariables>,
@@ -1346,6 +1387,33 @@ export class RequestManager {
); );
} }
public getMangaToMigrateToFetch(
mangaId: number | string,
{
migrateChapters = false,
migrateCategories = false,
apolloOptions: options,
}: {
migrateChapters?: boolean;
migrateCategories?: boolean;
apolloOptions?: MutationOptions<
GetMangaToMigrateToFetchMutation,
GetMangaToMigrateToFetchMutationVariables
>;
} = {},
): AbortableApolloMutationResponse<GetMangaToMigrateToFetchMutation> {
return this.doRequest<GetMangaToMigrateToFetchMutation, GetMangaToMigrateToFetchMutationVariables>(
GQLMethod.MUTATION,
GET_MANGA_TO_MIGRATE_TO_FETCH,
{
id: Number(mangaId),
migrateChapters,
migrateCategories,
},
options,
);
}
public useGetMangas( public useGetMangas(
variables: GetMangasQueryVariables, variables: GetMangasQueryVariables,
options?: QueryHookOptions<GetMangasQuery, GetMangasQueryVariables>, options?: QueryHookOptions<GetMangasQuery, GetMangasQueryVariables>,
@@ -1360,6 +1428,13 @@ export class RequestManager {
return this.doRequest(GQLMethod.QUERY, GET_MANGAS, variables, options); return this.doRequest(GQLMethod.QUERY, GET_MANGAS, variables, options);
} }
public useGetMigratableSourceMangas(
sourceId: string,
options?: QueryHookOptions<GetMigratableSourceMangasQuery, GetMigratableSourceMangasQueryVariables>,
): AbortableApolloUseQueryResponse<GetMigratableSourceMangasQuery, GetMigratableSourceMangasQueryVariables> {
return this.doRequest(GQLMethod.USE_QUERY, GET_MIGRATABLE_SOURCE_MANGAS, { sourceId }, options);
}
public getMangaThumbnailUrl(mangaId: number): string { public getMangaThumbnailUrl(mangaId: number): string {
return this.getValidImgUrlFor(`manga/${mangaId}/thumbnail`); return this.getValidImgUrlFor(`manga/${mangaId}/thumbnail`);
} }
@@ -2182,6 +2257,12 @@ export class RequestManager {
): AbortableApolloUseQueryResponse<GetWebuiUpdateStatusQuery, GetWebuiUpdateStatusQueryVariables> { ): AbortableApolloUseQueryResponse<GetWebuiUpdateStatusQuery, GetWebuiUpdateStatusQueryVariables> {
return this.doRequest(GQLMethod.USE_QUERY, GET_WEBUI_UPDATE_STATUS, undefined, options); return this.doRequest(GQLMethod.USE_QUERY, GET_WEBUI_UPDATE_STATUS, undefined, options);
} }
public useGetMigratableSources(
options?: QueryHookOptions<GetMigratableSourcesQuery, GetMigratableSourcesQueryVariables>,
): AbortableApolloUseQueryResponse<GetMigratableSourcesQuery, GetMigratableSourcesQueryVariables> {
return this.doRequest(GQLMethod.USE_QUERY, GET_MIGRATABLE_SOURCES, undefined, options);
}
} }
export const requestManager = new RequestManager(); export const requestManager = new RequestManager();

View File

@@ -6,7 +6,7 @@
* file, You can obtain one at https://mozilla.org/MPL/2.0/. * file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/ */
import { useState } from 'react'; import { useContext, useEffect, useState } from 'react';
import Tab from '@mui/material/Tab'; import Tab from '@mui/material/Tab';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Sources } from '@/screens/Sources'; import { Sources } from '@/screens/Sources';
@@ -14,17 +14,25 @@ import { Extensions } from '@/screens/Extensions';
import { TabPanel } from '@/components/tabs/TabPanel.tsx'; import { TabPanel } from '@/components/tabs/TabPanel.tsx';
import { TabsWrapper } from '@/components/tabs/TabsWrapper.tsx'; import { TabsWrapper } from '@/components/tabs/TabsWrapper.tsx';
import { TabsMenu } from '@/components/tabs/TabsMenu.tsx'; import { TabsMenu } from '@/components/tabs/TabsMenu.tsx';
import { Migration } from '@/screens/Migration.tsx';
import { NavBarContext } from '@/components/context/NavbarContext.tsx';
export function Browse() { export function Browse() {
const { t } = useTranslation(); const { t } = useTranslation();
const { setTitle } = useContext(NavBarContext);
const [tabNum, setTabNum] = useState<number>(0); const [tabNum, setTabNum] = useState<number>(0);
useEffect(() => {
setTitle(t('global.label.browse'));
}, [t]);
return ( return (
<TabsWrapper> <TabsWrapper>
<TabsMenu value={tabNum} tabsCount={2} onChange={(e, newTab) => setTabNum(newTab)}> <TabsMenu value={tabNum} tabsCount={2} onChange={(e, newTab) => setTabNum(newTab)}>
<Tab sx={{ textTransform: 'none' }} label={t('source.title')} /> <Tab sx={{ textTransform: 'none' }} label={t('source.title')} />
<Tab sx={{ textTransform: 'none' }} label={t('extension.title')} /> <Tab sx={{ textTransform: 'none' }} label={t('extension.title')} />
<Tab sx={{ textTransform: 'none' }} label={t('migrate.title')} />
</TabsMenu> </TabsMenu>
<TabPanel index={0} currentIndex={tabNum}> <TabPanel index={0} currentIndex={tabNum}>
<Sources /> <Sources />
@@ -32,6 +40,9 @@ export function Browse() {
<TabPanel index={1} currentIndex={tabNum}> <TabPanel index={1} currentIndex={tabNum}>
<Extensions /> <Extensions />
</TabPanel> </TabPanel>
<TabPanel index={2} currentIndex={tabNum}>
<Migration />
</TabPanel>
</TabsWrapper> </TabsWrapper>
); );
} }

View File

@@ -142,6 +142,11 @@ export const DownloadQueue: React.FC = () => {
</Tooltip> </Tooltip>
</>, </>,
); );
return () => {
setTitle('');
setAction(null);
};
}, [t, status, isQueueEmpty]); }, [t, status, isQueueEmpty]);
useEffect(() => { useEffect(() => {

View File

@@ -95,6 +95,7 @@ function getExtensionsInfo(extensions: PartialExtension[]): {
export function Extensions() { export function Extensions() {
const { t } = useTranslation(); const { t } = useTranslation();
const { setAction } = useContext(NavBarContext);
const theme = useTheme(); const theme = useTheme();
const isMobileWidth = useMediaQuery(theme.breakpoints.down('sm')); const isMobileWidth = useMediaQuery(theme.breakpoints.down('sm'));
@@ -104,7 +105,6 @@ export function Extensions() {
const areMultipleReposInUse = (serverSettingsData?.settings.extensionRepos.length ?? 0) > 1; const areMultipleReposInUse = (serverSettingsData?.settings.extensionRepos.length ?? 0) > 1;
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const { setTitle, setAction } = useContext(NavBarContext);
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownExtensionLangs', extensionDefaultLangs()); const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownExtensionLangs', extensionDefaultLangs());
const [showNsfw] = useLocalStorage<boolean>('showNsfw', true); const [showNsfw] = useLocalStorage<boolean>('showNsfw', true);
const [query] = useQueryParam('query', StringParam); const [query] = useQueryParam('query', StringParam);
@@ -169,7 +169,6 @@ export function Extensions() {
}; };
useEffect(() => { useEffect(() => {
setTitle(t('extension.title'));
setAction( setAction(
<> <>
<AppbarSearch /> <AppbarSearch />
@@ -182,6 +181,10 @@ export function Extensions() {
<LangSelect shownLangs={shownLangs} setShownLangs={setShownLangs} allLangs={allLangs} /> <LangSelect shownLangs={shownLangs} setShownLangs={setShownLangs} allLangs={allLangs} />
</>, </>,
); );
return () => {
setAction(null);
};
}, [t, shownLangs, allLangs]); }, [t, shownLangs, allLangs]);
useEffect(() => { useEffect(() => {

View File

@@ -58,6 +58,11 @@ export const Manga: React.FC = () => {
useEffect(() => { useEffect(() => {
setTitle(manga?.title ?? t('manga.title')); setTitle(manga?.title ?? t('manga.title'));
setAction(null); setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t, manga?.title]); }, [t, manga?.title]);
useEffect(() => { useEffect(() => {
@@ -86,6 +91,10 @@ export const Manga: React.FC = () => {
{manga && <MangaToolbarMenu manga={manga} onRefresh={refresh} refreshing={refreshing} />} {manga && <MangaToolbarMenu manga={manga} onRefresh={refresh} refreshing={refreshing} />}
</Stack>, </Stack>,
); );
return () => {
setAction(null);
};
}, [t, error, isValidating, refreshing, manga, refresh]); }, [t, error, isValidating, refreshing, manga, refresh]);
if (error && !manga) { if (error && !manga) {

110
src/screens/Migrate.tsx Normal file
View File

@@ -0,0 +1,110 @@
/*
* 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 { useParams } from 'react-router-dom';
import gql from 'graphql-tag';
import { useTranslation } from 'react-i18next';
import { NavBarContext } from '@/components/context/NavbarContext.tsx';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { TMigratableSource } from '@/components/MigrationCard.tsx';
import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder.tsx';
import { EmptyView } from '@/components/util/EmptyView.tsx';
import { MangaGrid } from '@/components/MangaGrid.tsx';
import { TPartialManga } from '@/typings.ts';
import { GridLayouts } from '@/components/source/GridLayouts.tsx';
import { useLocalStorage } from '@/util/useLocalStorage.tsx';
import { GridLayout } from '@/components/context/LibraryOptionsContext.tsx';
export const Migrate = () => {
const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavBarContext);
const { sourceId: paramSourceId } = useParams<{ sourceId: string }>();
const [gridLayout, setGridLayout] = useLocalStorage('migrateGridLayout', GridLayout.List);
const fragmentSource = requestManager.graphQLClient.client.cache.readFragment<
Pick<TMigratableSource, 'id' | 'name'>
>({
id: requestManager.graphQLClient.client.cache.identify({ __typename: 'SourceType', id: paramSourceId }),
fragment: gql`
fragment MigratableSource on SourceType {
id
name
}
`,
});
const [isKnownSource, setIsKnownSource] = useState(fragmentSource !== null ? true : undefined);
const {
data: migratableSourceData,
loading: isSourceLoading,
error: sourceError,
} = requestManager.useGetSource(paramSourceId, { skip: !!isKnownSource });
const { sourceId, name } = {
sourceId: paramSourceId,
name: paramSourceId,
...fragmentSource,
...migratableSourceData?.source,
};
const {
data: migratableSourceMangasData,
loading: areMangasLoading,
error: mangasError,
} = requestManager.useGetMigratableSourceMangas(sourceId, {
skip: !isKnownSource,
});
useEffect(() => {
setTitle(name ?? sourceId ?? t('migrate.title'));
setAction(<GridLayouts gridLayout={gridLayout} onChange={setGridLayout} />);
return () => {
setTitle('');
setAction(null);
};
}, [t, name, sourceId, gridLayout]);
useEffect(() => {
if (isSourceLoading || isKnownSource) {
return;
}
setIsKnownSource(
!!migratableSourceData ||
!!sourceError?.message.includes("The field at path '/source' was declared as a non null type"),
);
}, [isSourceLoading, sourceError]);
const isLoadingSource = isSourceLoading || (!sourceError && !isKnownSource);
const isLoading = isLoadingSource || areMangasLoading;
if (isLoading) {
return <LoadingPlaceholder />;
}
const hasErrorSource = sourceError && isKnownSource === false;
const hasError = hasErrorSource || mangasError;
if (hasError) {
const error = (hasErrorSource ? sourceError : mangasError)!;
return <EmptyView message={t('global.error.label.failed_to_load_data')} messageExtra={error.message} />;
}
return (
<MangaGrid
hasNextPage={false}
loadMore={() => {}}
isLoading={areMangasLoading}
mangas={(migratableSourceMangasData?.mangas.nodes ?? []) as TPartialManga[]}
gridLayout={gridLayout}
mode="migrate.search"
/>
);
};

69
src/screens/Migration.tsx Normal file
View File

@@ -0,0 +1,69 @@
/*
* 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 { useTranslation } from 'react-i18next';
import { useMemo } from 'react';
import List from '@mui/material/List';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { LoadingPlaceholder } from '@/components/util/LoadingPlaceholder.tsx';
import { EmptyView } from '@/components/util/EmptyView.tsx';
import { GetMigratableSourcesQuery } from '@/lib/graphql/generated/graphql.ts';
import { MigrationCard, TMigratableSource } from '@/components/MigrationCard.tsx';
import { StyledGroupItemWrapper } from '@/components/virtuoso/StyledGroupItemWrapper.tsx';
type TMigratableSourcesResult = GetMigratableSourcesQuery['mangas']['nodes'];
type TMigratableSources = Record<string, TMigratableSource>;
const getMigratableSources = (mangas?: TMigratableSourcesResult): TMigratableSources => {
if (!mangas) {
return {};
}
const uniqueSources: TMigratableSources = {};
mangas.forEach(({ sourceId, source }) => {
const uniqueSource = uniqueSources[sourceId] ?? {
...{ id: sourceId, name: sourceId, lang: 'unknown', iconUrl: null, mangaCount: 0, ...source },
};
uniqueSources[sourceId] = {
...uniqueSource,
mangaCount: uniqueSource.mangaCount + 1,
};
});
return uniqueSources;
};
export const Migration = () => {
const { t } = useTranslation();
const { data, loading, error } = requestManager.useGetMigratableSources();
const migratableSources = useMemo(() => getMigratableSources(data?.mangas.nodes), [data?.mangas.nodes]);
if (loading) {
return <LoadingPlaceholder />;
}
if (error) {
return <EmptyView message={t('global.error.label.failed_to_load_data')} messageExtra={error.message} />;
}
return (
<List>
{Object.values(migratableSources).map((migratableSource, index) => (
<StyledGroupItemWrapper
key={migratableSource.id}
isLastItem={index === Object.values(migratableSources).length - 1}
>
<MigrationCard {...migratableSource} />
</StyledGroupItemWrapper>
))}
</List>
);
};

View File

@@ -8,7 +8,7 @@
import { Card, CardActionArea, Typography } from '@mui/material'; import { Card, CardActionArea, Typography } from '@mui/material';
import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { Link } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { StringParam, useQueryParam } from 'use-query-params'; import { StringParam, useQueryParam } from 'use-query-params';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ISource } from '@/typings'; import { ISource } from '@/typings';
@@ -21,6 +21,7 @@ import { LangSelect } from '@/components/navbar/action/LangSelect';
import { MangaGrid } from '@/components/MangaGrid'; import { MangaGrid } from '@/components/MangaGrid';
import { useDebounce } from '@/util/useDebounce.ts'; import { useDebounce } from '@/util/useDebounce.ts';
import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx'; import { NavBarContext, useSetDefaultBackTo } from '@/components/context/NavbarContext.tsx';
import { MangaCardProps } from '@/components/MangaCard.tsx';
type SourceLoadingState = { isLoading: boolean; hasResults: boolean; emptySearch: boolean }; type SourceLoadingState = { isLoading: boolean; hasResults: boolean; emptySearch: boolean };
type SourceToLoadingStateMap = Map<string, SourceLoadingState>; type SourceToLoadingStateMap = Map<string, SourceLoadingState>;
@@ -84,6 +85,7 @@ const SourceSearchPreview = React.memo(
onSearchRequestFinished, onSearchRequestFinished,
searchString, searchString,
emptyQuery, emptyQuery,
mode,
}: { }: {
source: ISource; source: ISource;
onSearchRequestFinished: ( onSearchRequestFinished: (
@@ -94,7 +96,7 @@ const SourceSearchPreview = React.memo(
) => void; ) => void;
searchString: string | null | undefined; searchString: string | null | undefined;
emptyQuery: boolean; emptyQuery: boolean;
}) => { } & Pick<MangaCardProps, 'mode'>) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { id, displayName, lang } = source; const { id, displayName, lang } = source;
@@ -150,6 +152,7 @@ const SourceSearchPreview = React.memo(
noFaces noFaces
message={errorMessage} message={errorMessage}
inLibraryIndicator inLibraryIndicator
mode={mode}
/> />
</> </>
); );
@@ -163,6 +166,11 @@ export const SearchAll: React.FC = () => {
useSetDefaultBackTo('sources'); useSetDefaultBackTo('sources');
const { pathname, state } = useLocation<{ mangaTitle?: string }>();
const isMigrateMode = pathname.startsWith('/migrate/source');
const mangaTitle = state?.mangaTitle;
const [query] = useQueryParam('query', StringParam); const [query] = useQueryParam('query', StringParam);
const searchString = useDebounce(query, TRIGGER_SEARCH_THRESHOLD); const searchString = useDebounce(query, TRIGGER_SEARCH_THRESHOLD);
@@ -203,7 +211,7 @@ export const SearchAll: React.FC = () => {
); );
useEffect(() => { useEffect(() => {
setTitle(t('search.title.global_search')); setTitle(t(isMigrateMode ? 'migrate.search.title' : 'search.title.global_search', { title: mangaTitle }));
setAction( setAction(
<> <>
<AppbarSearch autoOpen /> <AppbarSearch autoOpen />
@@ -215,6 +223,11 @@ export const SearchAll: React.FC = () => {
/> />
</>, </>,
); );
return () => {
setTitle('');
setAction(null);
};
}, [t, shownLangs, setShownLangs, sources]); }, [t, shownLangs, setShownLangs, sources]);
useEffect(() => { useEffect(() => {
@@ -234,6 +247,7 @@ export const SearchAll: React.FC = () => {
onSearchRequestFinished={updateSourceLoadingState} onSearchRequestFinished={updateSourceLoadingState}
searchString={searchString} searchString={searchString}
emptyQuery={!query} emptyQuery={!query}
mode={isMigrateMode ? 'migrate.select' : undefined}
/> />
))} ))}
</> </>

View File

@@ -43,6 +43,11 @@ export function Settings() {
useEffect(() => { useEffect(() => {
setTitle(t('settings.title')); setTitle(t('settings.title'));
setAction(null); setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]); }, [t]);
const { darkTheme, setDarkTheme } = useContext(DarkTheme); const { darkTheme, setDarkTheme } = useContext(DarkTheme);
@@ -68,7 +73,7 @@ export function Settings() {
<ListItemIcon> <ListItemIcon>
<ListAltIcon /> <ListAltIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary={t('category.title.categories')} /> <ListItemText primary={t('category.title.category_other')} />
</ListItemLink> </ListItemLink>
<ListItemLink to="/settings/defaultReaderSettings"> <ListItemLink to="/settings/defaultReaderSettings">
<ListItemIcon> <ListItemIcon>

View File

@@ -43,6 +43,11 @@ export function SourceConfigure() {
useEffect(() => { useEffect(() => {
setTitle(t('source.configuration.title')); setTitle(t('source.configuration.title'));
setAction(null); setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]); }, [t]);
const { sourceId } = useParams<{ sourceId: string }>(); const { sourceId } = useParams<{ sourceId: string }>();

View File

@@ -25,7 +25,7 @@ import {
} from '@/lib/requests/RequestManager.ts'; } from '@/lib/requests/RequestManager.ts';
import { useDebounce } from '@/util/useDebounce.ts'; import { useDebounce } from '@/util/useDebounce.ts';
import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext'; import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext';
import { SourceGridLayout } from '@/components/source/GridLayouts'; import { SourceGridLayout } from '@/components/source/SourceGridLayout';
import { AppbarSearch } from '@/components/util/AppbarSearch'; import { AppbarSearch } from '@/components/util/AppbarSearch';
import { SourceOptions } from '@/components/source/SourceOptions'; import { SourceOptions } from '@/components/source/SourceOptions';
import { SourceMangaGrid } from '@/components/source/SourceMangaGrid'; import { SourceMangaGrid } from '@/components/source/SourceMangaGrid';
@@ -366,6 +366,11 @@ export function SourceMangas() {
)} )}
</>, </>,
); );
return () => {
setTitle('');
setAction(null);
};
}, [t, source]); }, [t, source]);
useEffect(() => { useEffect(() => {

View File

@@ -48,7 +48,7 @@ function groupByLang(sources: ISource[]) {
export function Sources() { export function Sources() {
const { t } = useTranslation(); const { t } = useTranslation();
const { setTitle, setAction } = useContext(NavBarContext); const { setAction } = useContext(NavBarContext);
const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', sourceDefualtLangs()); const [shownLangs, setShownLangs] = useLocalStorage<string[]>('shownSourceLangs', sourceDefualtLangs());
const [showNsfw] = useLocalStorage<boolean>('showNsfw', true); const [showNsfw] = useLocalStorage<boolean>('showNsfw', true);
@@ -84,7 +84,6 @@ export function Sources() {
}, []); }, []);
useEffect(() => { useEffect(() => {
setTitle(t('source.title'));
setAction( setAction(
<> <>
<Tooltip title={t('search.title.global_search')}> <Tooltip title={t('search.title.global_search')}>
@@ -100,6 +99,10 @@ export function Sources() {
/> />
</>, </>,
); );
return () => {
setAction(null);
};
}, [t, shownLangs, sources]); }, [t, shownLangs, sources]);
if (isLoading) return <LoadingPlaceholder />; if (isLoading) return <LoadingPlaceholder />;

View File

@@ -104,8 +104,12 @@ export const Updates: React.FC = () => {
useEffect(() => { useEffect(() => {
setTitle(t('updates.title')); setTitle(t('updates.title'));
setAction(<UpdateChecker />); setAction(<UpdateChecker />);
return () => {
setTitle('');
setAction(null);
};
}, [t, lastUpdateTimestamp]); }, [t, lastUpdateTimestamp]);
const downloadForChapter = (chapter: TChapter) => { const downloadForChapter = (chapter: TChapter) => {

View File

@@ -190,6 +190,11 @@ export function About() {
useEffect(() => { useEffect(() => {
setTitle(t('settings.about.title')); setTitle(t('settings.about.title'));
setAction(null); setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]); }, [t]);
useSetDefaultBackTo('settings'); useSetDefaultBackTo('settings');

View File

@@ -62,6 +62,11 @@ export function Backup() {
useEffect(() => { useEffect(() => {
setTitle(t('settings.backup.title')); setTitle(t('settings.backup.title'));
setAction(null); setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]); }, [t]);
useSetDefaultBackTo('settings'); useSetDefaultBackTo('settings');

View File

@@ -48,8 +48,13 @@ export function Categories() {
const { setTitle, setAction } = useContext(NavBarContext); const { setTitle, setAction } = useContext(NavBarContext);
useEffect(() => { useEffect(() => {
setTitle(t('category.title.categories')); setTitle(t('category.title.category_other'));
setAction(null); setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]); }, [t]);
const { data } = requestManager.useGetCategories(); const { data } = requestManager.useGetCategories();

View File

@@ -28,6 +28,11 @@ export function DefaultReaderSettings() {
useEffect(() => { useEffect(() => {
setTitle(t('reader.settings.title.default_reader_settings')); setTitle(t('reader.settings.title.default_reader_settings'));
setAction(null); setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]); }, [t]);
const { metadata, settings, loading } = useDefaultReaderSettings(); const { metadata, settings, loading } = useDefaultReaderSettings();

View File

@@ -48,6 +48,11 @@ export const DownloadSettings = () => {
useEffect(() => { useEffect(() => {
setTitle(t('download.settings.title')); setTitle(t('download.settings.title'));
setAction(null); setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]); }, [t]);
const { data } = requestManager.useGetServerSettings(); const { data } = requestManager.useGetServerSettings();

View File

@@ -46,6 +46,11 @@ export function LibrarySettings() {
useEffect(() => { useEffect(() => {
setTitle(t('library.settings.title')); setTitle(t('library.settings.title'));
setAction(null); setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]); }, [t]);
useSetDefaultBackTo('settings'); useSetDefaultBackTo('settings');

View File

@@ -55,6 +55,11 @@ export const ServerSettings = () => {
useEffect(() => { useEffect(() => {
setTitle(t('settings.server.title.settings')); setTitle(t('settings.server.title.settings'));
setAction(null); setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]); }, [t]);
const { data } = requestManager.useGetServerSettings(); const { data } = requestManager.useGetServerSettings();

View File

@@ -107,6 +107,11 @@ export const WebUISettings = () => {
useEffect(() => { useEffect(() => {
setTitle(t('settings.webui.title.settings')); setTitle(t('settings.webui.title.settings'));
setAction(null); setAction(null);
return () => {
setTitle('');
setAction(null);
};
}, [t]); }, [t]);
const { data } = requestManager.useGetServerSettings(); const { data } = requestManager.useGetServerSettings();