Add bulk migration
This commit is contained in:
@@ -15,12 +15,12 @@ import { Extensions } from '@/features/browse/extensions/Extensions.tsx';
|
||||
import { TabPanel } from '@/base/components/tabs/TabPanel.tsx';
|
||||
import { TabsWrapper } from '@/base/components/tabs/TabsWrapper.tsx';
|
||||
import { TabsMenu } from '@/base/components/tabs/TabsMenu.tsx';
|
||||
import { Migration } from '@/features/migration/screens/Migration.tsx';
|
||||
import { useResizeObserver } from '@/base/hooks/useResizeObserver.tsx';
|
||||
import { useAppTitle } from '@/features/navigation-bar/hooks/useAppTitle.ts';
|
||||
import { BrowseTab } from '@/features/browse/Browse.types.ts';
|
||||
import { GROUPED_VIRTUOSO_Z_INDEX } from '@/lib/virtuoso/Virtuoso.constants.ts';
|
||||
import { SearchParam } from '@/base/Base.types.ts';
|
||||
import { Migration } from '@/features/migration/screens/Migration.tsx';
|
||||
|
||||
export function Browse() {
|
||||
const { t } = useLingui();
|
||||
|
||||
@@ -10,7 +10,7 @@ import Card from '@mui/material/Card';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Link, useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { StringParam, useQueryParam } from 'use-query-params';
|
||||
import Box from '@mui/material/Box';
|
||||
import Stack from '@mui/material/Stack';
|
||||
@@ -28,7 +28,7 @@ import { AppbarSearch } from '@/base/components/AppbarSearch.tsx';
|
||||
import { useDebounce } from '@/base/hooks/useDebounce.ts';
|
||||
import type { MangaCardProps } from '@/features/manga/Manga.types.ts';
|
||||
import { EmptyView } from '@/base/components/feedback/EmptyView.tsx';
|
||||
import { STABLE_EMPTY_ARRAY } from '@/base/Base.constants.ts';
|
||||
import { STABLE_EMPTY_ARRAY, STABLE_EMPTY_OBJECT } from '@/base/Base.constants.ts';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
|
||||
import { BaseMangaGrid } from '@/features/manga/components/BaseMangaGrid.tsx';
|
||||
@@ -56,6 +56,9 @@ import { MUIUtil } from '@/lib/mui/MUI.util.ts';
|
||||
import type { MetadataBrowseSettings } from '@/features/browse/Browse.types.ts';
|
||||
import { SourceLanguageSelect } from '@/features/source/components/SourceLanguageSelect.tsx';
|
||||
import { SearchParam } from '@/base/Base.types.ts';
|
||||
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
|
||||
import { assertIsDefined } from '@/base/Asserts.ts';
|
||||
import { useBackButton } from '@/base/hooks/useBackButton.ts';
|
||||
|
||||
type SourceLoadingState = { isLoading: boolean; hasResults: boolean; emptySearch: boolean; error: any };
|
||||
type SourceToLoadingStateMap = Map<string, SourceLoadingState>;
|
||||
@@ -122,12 +125,13 @@ const SourceSearchPreview = React.memo(
|
||||
emptyQuery,
|
||||
mode,
|
||||
shouldShowOnlySourcesWithResults,
|
||||
onMigrateSelect,
|
||||
}: {
|
||||
source: SourceIdInfo & SourceDisplayNameInfo & SourceNameInfo & SourceLanguageInfo;
|
||||
onSearchRequestFinished: (source: SourceIdInfo, state: SourceLoadingState) => void;
|
||||
searchString: string | null | undefined;
|
||||
emptyQuery: boolean;
|
||||
} & Pick<MangaCardProps, 'mode'> &
|
||||
} & Pick<MangaCardProps, 'mode' | 'onMigrateSelect'> &
|
||||
Pick<MetadataBrowseSettings, 'shouldShowOnlySourcesWithResults'>) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
@@ -230,6 +234,7 @@ const SourceSearchPreview = React.memo(
|
||||
message={errorMessage}
|
||||
inLibraryIndicator
|
||||
mode={mode}
|
||||
onMigrateSelect={onMigrateSelect}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
@@ -237,15 +242,21 @@ const SourceSearchPreview = React.memo(
|
||||
},
|
||||
);
|
||||
|
||||
export const SearchAll: React.FC = () => {
|
||||
export const SearchAll = ({
|
||||
migrationDestinationSourceIds,
|
||||
}: {
|
||||
migrationDestinationSourceIds?: SourceIdInfo['id'][];
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
const navigate = useNavigate();
|
||||
const handleBack = useBackButton();
|
||||
const { pathname, state } = useLocation<{ mangaTitle?: string; shouldShowOnlyPinnedSources?: boolean }>();
|
||||
const { ref: filterHeaderRef, height: filterHeaderHeight } = useElementSize();
|
||||
|
||||
const shouldShowOnlyPinnedSources = state?.shouldShowOnlyPinnedSources ?? true;
|
||||
const isMigrateMode = pathname.startsWith('/migrate/source');
|
||||
const isMigrateMode = pathname.startsWith('/migrate/source') || pathname.startsWith('/migrate/manual-search');
|
||||
|
||||
const { mangaId } = useParams<{ mangaId?: string }>() ?? STABLE_EMPTY_OBJECT;
|
||||
const [query] = useQueryParam(SearchParam.QUERY, StringParam);
|
||||
const searchString = useDebounce(query, TRIGGER_SEARCH_THRESHOLD);
|
||||
|
||||
@@ -255,7 +266,14 @@ export const SearchAll: React.FC = () => {
|
||||
} = useMetadataServerSettings();
|
||||
|
||||
const { data, loading, error, refetch } = requestManager.useGetSourceList({ notifyOnNetworkStatusChange: true });
|
||||
const sources = data?.sources.nodes ?? STABLE_EMPTY_ARRAY;
|
||||
const tmpSources = data?.sources.nodes ?? STABLE_EMPTY_ARRAY;
|
||||
const sources = useMemo(
|
||||
() =>
|
||||
tmpSources.filter(
|
||||
(source) => !migrationDestinationSourceIds || migrationDestinationSourceIds.includes(source.id),
|
||||
),
|
||||
[tmpSources, migrationDestinationSourceIds],
|
||||
);
|
||||
|
||||
const [sourceToLoadingStateMap, setSourceToLoadingStateMap] = useState<SourceToLoadingStateMap>(new Map());
|
||||
const debouncedSourceToLoadingStateMap = useDebounce(sourceToLoadingStateMap, 500);
|
||||
@@ -398,6 +416,19 @@ export const SearchAll: React.FC = () => {
|
||||
emptyQuery={!query}
|
||||
mode={isMigrateMode ? 'migrate.select' : 'source'}
|
||||
shouldShowOnlySourcesWithResults={shouldShowOnlySourcesWithResults}
|
||||
onMigrateSelect={
|
||||
migrationDestinationSourceIds
|
||||
? (match) => {
|
||||
assertIsDefined(mangaId);
|
||||
MigrationManager.selectManualMatch(Number(mangaId), {
|
||||
...match,
|
||||
sourceTitle: Sources.getFromCache(match.sourceId)?.displayName,
|
||||
latestChapterNumber: undefined,
|
||||
});
|
||||
handleBack();
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
import type { SingleModeProps } from '@/features/manga/components/MangaActionMenuItems.tsx';
|
||||
import type { GridLayout } from '@/base/Base.types.ts';
|
||||
import type { ChapterListOptions } from '@/features/chapter/Chapter.types.ts';
|
||||
import type { MigrationMatch } from '@/features/migration/Migration.types.ts';
|
||||
|
||||
export type MangaCardMode = 'default' | 'source' | 'migrate.search' | 'migrate.select' | 'duplicate';
|
||||
|
||||
@@ -33,6 +34,7 @@ type MangaCardBaseProps = Pick<MangaTypeGql, 'id' | 'title' | 'sourceId'> &
|
||||
|
||||
export type MangaIdInfo = Pick<MangaTypeGql, 'id'>;
|
||||
export type MangaChapterCountInfo = { chapters: Pick<MangaTypeGql['chapters'], 'totalCount'> };
|
||||
export type MangaHighestChapterNumberInfo = { highestNumberedChapter?: Pick<ChapterType, 'id' | 'chapterNumber'> };
|
||||
export type MangaInLibraryInfo = Pick<MangaTypeGql, 'inLibrary'>;
|
||||
export type MangaDownloadInfo = Pick<MangaTypeGql, 'downloadCount'> & MangaChapterCountInfo;
|
||||
export type MangaUnreadInfo = Pick<MangaTypeGql, 'unreadCount'> & MangaChapterCountInfo;
|
||||
@@ -42,7 +44,7 @@ export type MangaTrackRecordInfo = MangaIdInfo & {
|
||||
};
|
||||
export type MangaGenreInfo = Pick<MangaTypeGql, 'genre'>;
|
||||
export type MangaSourceIdInfo = Pick<MangaTypeGql, 'sourceId'>;
|
||||
export type MangaSourceNameInfo = { source?: Maybe<Pick<SourceType, 'name'>> };
|
||||
export type MangaSourceNameInfo = { source?: Maybe<Pick<SourceType, 'name' | 'displayName'>> };
|
||||
export type MangaSourceLngInfo = { source?: Maybe<Pick<SourceType, 'lang'>> };
|
||||
export type MangaArtistInfo = Pick<MangaTypeGql, 'artist'>;
|
||||
export type MangaAuthorInfo = Pick<MangaTypeGql, 'author'>;
|
||||
@@ -58,6 +60,7 @@ export interface MangaCardProps {
|
||||
inLibraryIndicator?: boolean;
|
||||
selected?: boolean | null;
|
||||
handleSelection?: SelectableCollectionReturnType<MangaTypeGql['id']>['handleSelection'];
|
||||
onMigrateSelect?: (manga: Omit<MigrationMatch, 'sourceTitle' | 'isManualMatch' | 'latestChapterNumber'>) => void;
|
||||
mode?: MangaCardMode;
|
||||
}
|
||||
|
||||
|
||||
@@ -150,7 +150,11 @@ export const MangaActionMenuItems = ({
|
||||
)}
|
||||
{isSingleMode && (
|
||||
<Link
|
||||
to={AppRoutes.migrate.childRoutes.search.path(manga?.sourceId ?? -1, manga?.id ?? -1, manga?.title)}
|
||||
to={AppRoutes.migrate.childRoutes.singleMangaSearch.path(
|
||||
manga?.sourceId ?? -1,
|
||||
manga?.id ?? -1,
|
||||
manga?.title,
|
||||
)}
|
||||
state={{ mangaTitle: manga?.title }}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
|
||||
@@ -25,7 +25,6 @@ import { useLingui } from '@lingui/react/macro';
|
||||
import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewAbsoluteCentered.tsx';
|
||||
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
|
||||
import { MangaCard } from '@/features/manga/components/cards/MangaCard.tsx';
|
||||
import type { SelectableCollectionReturnType } from '@/base/collection/hooks/useSelectableCollection.ts';
|
||||
import { DEFAULT_FULL_FAB_HEIGHT } from '@/base/components/buttons/StyledFab.tsx';
|
||||
import type { MangaCardProps } from '@/features/manga/Manga.types.ts';
|
||||
import type { MangaType } from '@/lib/graphql/generated/graphql.ts';
|
||||
@@ -68,6 +67,7 @@ const createMangaCard = (
|
||||
selectedMangaIds?: MangaType['id'][],
|
||||
handleSelection?: DefaultGridProps['handleSelection'],
|
||||
mode?: MangaCardProps['mode'],
|
||||
onMigrateSelect?: DefaultGridProps['onMigrateSelect'],
|
||||
) => (
|
||||
<MangaCard
|
||||
manga={manga}
|
||||
@@ -76,10 +76,11 @@ const createMangaCard = (
|
||||
selected={isSelectModeActive ? selectedMangaIds?.includes(manga.id) : null}
|
||||
handleSelection={handleSelection}
|
||||
mode={mode}
|
||||
onMigrateSelect={onMigrateSelect}
|
||||
/>
|
||||
);
|
||||
|
||||
type DefaultGridProps = Pick<MangaCardProps, 'mode'> & {
|
||||
type DefaultGridProps = Omit<MangaCardProps, 'manga' | 'selected'> & {
|
||||
isLoading: boolean;
|
||||
mangas: TManga[];
|
||||
inLibraryIndicator?: boolean;
|
||||
@@ -87,7 +88,6 @@ type DefaultGridProps = Pick<MangaCardProps, 'mode'> & {
|
||||
gridLayout?: GridLayout;
|
||||
isSelectModeActive?: boolean;
|
||||
selectedMangaIds?: Required<MangaType['id']>[];
|
||||
handleSelection?: SelectableCollectionReturnType<MangaType['id']>['handleSelection'];
|
||||
ref?: ForwardedRef<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
@@ -102,6 +102,7 @@ const HorizontalGrid = ({
|
||||
handleSelection,
|
||||
mode,
|
||||
ref,
|
||||
onMigrateSelect,
|
||||
}: DefaultGridProps) => (
|
||||
<Grid
|
||||
ref={ref}
|
||||
@@ -127,6 +128,7 @@ const HorizontalGrid = ({
|
||||
selectedMangaIds,
|
||||
handleSelection,
|
||||
mode,
|
||||
onMigrateSelect,
|
||||
)}
|
||||
</GridItemContainer>
|
||||
))
|
||||
@@ -149,6 +151,7 @@ const VerticalGrid = ({
|
||||
handleSelection,
|
||||
mode,
|
||||
ref,
|
||||
onMigrateSelect,
|
||||
}: DefaultGridProps & {
|
||||
hasNextPage: boolean;
|
||||
loadMore: () => void;
|
||||
@@ -175,6 +178,7 @@ const VerticalGrid = ({
|
||||
selectedMangaIds,
|
||||
handleSelection,
|
||||
mode,
|
||||
onMigrateSelect,
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -220,6 +224,7 @@ export const MangaGrid: React.FC<IMangaGridProps> = ({
|
||||
mode,
|
||||
retry,
|
||||
gridWrapperProps,
|
||||
onMigrateSelect,
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
@@ -346,6 +351,7 @@ export const MangaGrid: React.FC<IMangaGridProps> = ({
|
||||
selectedMangaIds={selectedMangaIds}
|
||||
handleSelection={handleSelection}
|
||||
mode={mode}
|
||||
onMigrateSelect={onMigrateSelect}
|
||||
/>
|
||||
) : (
|
||||
<VerticalGrid
|
||||
@@ -361,6 +367,7 @@ export const MangaGrid: React.FC<IMangaGridProps> = ({
|
||||
selectedMangaIds={selectedMangaIds}
|
||||
handleSelection={handleSelection}
|
||||
mode={mode}
|
||||
onMigrateSelect={onMigrateSelect}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -84,7 +84,7 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
|
||||
<>
|
||||
<CustomTooltip title={t`Migrate`}>
|
||||
<Link
|
||||
to={AppRoutes.migrate.childRoutes.search.path(
|
||||
to={AppRoutes.migrate.childRoutes.singleMangaSearch.path(
|
||||
manga.sourceId,
|
||||
manga.id,
|
||||
manga.title,
|
||||
@@ -156,7 +156,11 @@ export const MangaToolbarMenu = ({ manga, onRefresh, refreshing }: IProps) => {
|
||||
<MenuItem
|
||||
key="migrate"
|
||||
component={Link}
|
||||
to={AppRoutes.migrate.childRoutes.search.path(manga.sourceId, manga.id, manga.title)}
|
||||
to={AppRoutes.migrate.childRoutes.singleMangaSearch.path(
|
||||
manga.sourceId,
|
||||
manga.id,
|
||||
manga.title,
|
||||
)}
|
||||
state={{ mangaTitle: manga.title }}
|
||||
style={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
|
||||
@@ -7,12 +7,11 @@
|
||||
*/
|
||||
|
||||
import PopupState, { bindMenu } from 'material-ui-popup-state';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { useLongPress } from 'use-long-press';
|
||||
import type { SingleModeProps } from '@/features/manga/components/MangaActionMenuItems.tsx';
|
||||
import { MangaActionMenuItems } from '@/features/manga/components/MangaActionMenuItems.tsx';
|
||||
import { Menu } from '@/base/components/menu/Menu.tsx';
|
||||
import { MigrateDialog } from '@/features/migration/components/MigrateDialog.tsx';
|
||||
import { useManageMangaLibraryState } from '@/features/manga/hooks/useManageMangaLibraryState.tsx';
|
||||
import { MangaGridCard } from '@/features/manga/components/cards/MangaGridCard.tsx';
|
||||
import { MangaListCard } from '@/features/manga/components/cards/MangaListCard.tsx';
|
||||
@@ -22,6 +21,14 @@ import { MangaBadges } from '@/features/manga/components/MangaBadges.tsx';
|
||||
import { GridLayout } from '@/base/Base.types.ts';
|
||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||
import { makeToast } from '@/base/utils/Toast.ts';
|
||||
import { Mangas } from '@/features/manga/services/Mangas.ts';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { ReactRouter } from '@/lib/react-router/ReactRouter.ts';
|
||||
import { MigrationOptionsDialog } from '@/features/migration/components/MigrationOptionsDialog.tsx';
|
||||
import { AwaitableComponent } from 'awaitable-component';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler';
|
||||
|
||||
const getMangaLinkTo = (
|
||||
mode: MangaCardMode,
|
||||
@@ -35,7 +42,7 @@ const getMangaLinkTo = (
|
||||
case 'duplicate':
|
||||
return AppRoutes.manga.path(mangaId);
|
||||
case 'migrate.search':
|
||||
return AppRoutes.migrate.childRoutes.search.path(sourceId ?? '-1', mangaId, mangaTitle);
|
||||
return AppRoutes.migrate.childRoutes.singleMangaSearch.path(sourceId ?? '-1', mangaId, mangaTitle);
|
||||
case 'migrate.select':
|
||||
return '';
|
||||
default:
|
||||
@@ -44,8 +51,20 @@ const getMangaLinkTo = (
|
||||
};
|
||||
|
||||
export const MangaCard = memo((props: MangaCardProps) => {
|
||||
const { manga, gridLayout, inLibraryIndicator, selected, handleSelection, mode = 'default' } = props;
|
||||
const {
|
||||
manga,
|
||||
gridLayout,
|
||||
inLibraryIndicator,
|
||||
selected,
|
||||
handleSelection,
|
||||
mode = 'default',
|
||||
onMigrateSelect,
|
||||
} = props;
|
||||
const { id, firstUnreadChapter, downloadCount, unreadCount } = manga;
|
||||
|
||||
const { mangaId: mangaIdAsString } = useParams<{ mangaId: string }>();
|
||||
const migrationSourceMangaId = Number(mangaIdAsString);
|
||||
|
||||
const {
|
||||
settings: { showContinueReadingButton },
|
||||
} = useMetadataServerSettings();
|
||||
@@ -54,8 +73,6 @@ export const MangaCard = memo((props: MangaCardProps) => {
|
||||
|
||||
const mangaLinkTo = getMangaLinkTo(mode, manga.id, manga.sourceId, manga.title);
|
||||
|
||||
const [isMigrateDialogOpen, setIsMigrateDialogOpen] = useState(false);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent | React.TouchEvent, openMenu?: () => void) => {
|
||||
const isDefaultMode = mode === 'default';
|
||||
@@ -88,10 +105,42 @@ export const MangaCard = memo((props: MangaCardProps) => {
|
||||
}
|
||||
|
||||
if (isMigrateSelectMode) {
|
||||
setIsMigrateDialogOpen(true);
|
||||
const isBulkMigrationManualSearch = !!onMigrateSelect;
|
||||
if (isBulkMigrationManualSearch) {
|
||||
onMigrateSelect(manga);
|
||||
return;
|
||||
}
|
||||
|
||||
const migrate = () => {
|
||||
const optionsDialog = AwaitableComponent.showControlled(
|
||||
MigrationOptionsDialog,
|
||||
{
|
||||
mangaIdToMigrateTo: id,
|
||||
isMigrating: false,
|
||||
startMigration: async (options) => {
|
||||
makeToast(t`Migrating manga…`, 'info');
|
||||
|
||||
optionsDialog.update({ isMigrating: true });
|
||||
|
||||
try {
|
||||
await Mangas.migrate(migrationSourceMangaId, id, options);
|
||||
|
||||
optionsDialog.submit(options);
|
||||
|
||||
ReactRouter.navigate(AppRoutes.manga.path(id), { replace: true });
|
||||
} catch (e) {
|
||||
optionsDialog.update({ isMigrating: false, startMigration: () => migrate() });
|
||||
}
|
||||
},
|
||||
},
|
||||
{ id: `manga-migration-single-manga-${migrationSourceMangaId}-${id}` },
|
||||
);
|
||||
optionsDialog.promise.catch(defaultPromiseErrorHandler('MangaCard::migrate'));
|
||||
};
|
||||
migrate();
|
||||
}
|
||||
},
|
||||
[mode, selected, updateLibraryState, handleSelection],
|
||||
[mode, selected, updateLibraryState, handleSelection, migrationSourceMangaId],
|
||||
);
|
||||
|
||||
const longPressBind = useLongPress(
|
||||
@@ -111,54 +160,49 @@ export const MangaCard = memo((props: MangaCardProps) => {
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isMigrateDialogOpen && (
|
||||
<MigrateDialog mangaIdToMigrateTo={manga.id} onClose={() => setIsMigrateDialogOpen(false)} />
|
||||
<PopupState variant="popover" popupId="manga-card-action-menu">
|
||||
{(popupState) => (
|
||||
<>
|
||||
<MangaCardComponent
|
||||
{...props}
|
||||
longPressBind={longPressBind}
|
||||
popupState={popupState}
|
||||
handleClick={handleClick}
|
||||
mangaLinkTo={mangaLinkTo}
|
||||
isInLibrary={isInLibrary}
|
||||
inLibraryIndicator={inLibraryIndicator}
|
||||
continueReadingButton={
|
||||
<ContinueReadingButton
|
||||
showContinueReadingButton={showContinueReadingButton && mode === 'default'}
|
||||
chapter={firstUnreadChapter}
|
||||
mangaLinkTo={mangaLinkTo}
|
||||
/>
|
||||
}
|
||||
mangaBadges={
|
||||
<MangaBadges
|
||||
inLibraryIndicator={inLibraryIndicator}
|
||||
isInLibrary={isInLibrary}
|
||||
unread={unreadCount}
|
||||
downloadCount={downloadCount}
|
||||
updateLibraryState={updateLibraryState}
|
||||
mode={mode}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{!!handleSelection && popupState.isOpen && (
|
||||
<Menu {...bindMenu(popupState)}>
|
||||
{(onClose, setHideMenu) => (
|
||||
<MangaActionMenuItems
|
||||
manga={manga as SingleModeProps['manga']}
|
||||
handleSelection={handleSelection}
|
||||
onClose={onClose}
|
||||
setHideMenu={setHideMenu}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<PopupState variant="popover" popupId="manga-card-action-menu">
|
||||
{(popupState) => (
|
||||
<>
|
||||
<MangaCardComponent
|
||||
{...props}
|
||||
longPressBind={longPressBind}
|
||||
popupState={popupState}
|
||||
handleClick={handleClick}
|
||||
mangaLinkTo={mangaLinkTo}
|
||||
isInLibrary={isInLibrary}
|
||||
inLibraryIndicator={inLibraryIndicator}
|
||||
continueReadingButton={
|
||||
<ContinueReadingButton
|
||||
showContinueReadingButton={showContinueReadingButton && mode === 'default'}
|
||||
chapter={firstUnreadChapter}
|
||||
mangaLinkTo={mangaLinkTo}
|
||||
/>
|
||||
}
|
||||
mangaBadges={
|
||||
<MangaBadges
|
||||
inLibraryIndicator={inLibraryIndicator}
|
||||
isInLibrary={isInLibrary}
|
||||
unread={unreadCount}
|
||||
downloadCount={downloadCount}
|
||||
updateLibraryState={updateLibraryState}
|
||||
mode={mode}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{!!handleSelection && popupState.isOpen && (
|
||||
<Menu {...bindMenu(popupState)}>
|
||||
{(onClose, setHideMenu) => (
|
||||
<MangaActionMenuItems
|
||||
manga={manga as SingleModeProps['manga']}
|
||||
handleSelection={handleSelection}
|
||||
onClose={onClose}
|
||||
setHideMenu={setHideMenu}
|
||||
/>
|
||||
)}
|
||||
</Menu>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PopupState>
|
||||
</>
|
||||
</PopupState>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
|
||||
import type { MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { SortBy, SortOrder } from '@/features/migration/Migration.types.ts';
|
||||
import { MigrationEntryStatus, MigrationPhase, SortBy, SortOrder } from '@/features/migration/Migration.types.ts';
|
||||
import type { MigrationState } from '@/features/migration/Migration.types.ts';
|
||||
|
||||
export const sortByToTranslation: Record<SortBy, MessageDescriptor> = {
|
||||
[SortBy.SOURCE_NAME]: msg`By source name`,
|
||||
@@ -24,3 +25,70 @@ export const DEFAULT_SORT_SETTINGS = {
|
||||
sortBy: SortBy.SOURCE_NAME,
|
||||
sortOrder: SortOrder.ASC,
|
||||
};
|
||||
|
||||
export const MIGRATION_LOCAL_STORAGE_KEY = 'migration_state';
|
||||
|
||||
export const MAX_MANGAS_IN_PARALLEL = 5;
|
||||
|
||||
export const MAX_SOURCES_IN_PARALLEL = 6;
|
||||
|
||||
export const DEFAULT_MIGRATION_STATE: MigrationState = {
|
||||
phase: MigrationPhase.IDLE,
|
||||
sourceId: null,
|
||||
entries: {},
|
||||
destinationSourceIds: [],
|
||||
migrateOptions: null,
|
||||
searchProgress: { total: 0, completed: 0, success: 0, failed: 0 },
|
||||
migrationProgress: { total: 0, completed: 0, success: 0, failed: 0 },
|
||||
startedAt: null,
|
||||
lastUpdatedAt: null,
|
||||
groupExpandState: {},
|
||||
};
|
||||
|
||||
export const ENTRY_STATUS_TRANSLATION: Record<MigrationEntryStatus, MessageDescriptor> = {
|
||||
[MigrationEntryStatus.PENDING]: msg`Pending…`,
|
||||
[MigrationEntryStatus.SEARCHING]: msg`Searching…`,
|
||||
[MigrationEntryStatus.SEARCH_COMPLETE]: msg`Match found`,
|
||||
[MigrationEntryStatus.SEARCH_FAILED]: msg`Search failed`,
|
||||
[MigrationEntryStatus.NO_MATCH]: msg`No match found`,
|
||||
[MigrationEntryStatus.MIGRATING]: msg`Migrating…`,
|
||||
[MigrationEntryStatus.MIGRATION_COMPLETE]: msg`Successfully migrated`,
|
||||
[MigrationEntryStatus.MIGRATION_FAILED]: msg`Migration failed`,
|
||||
[MigrationEntryStatus.EXCLUDED]: msg`Excluded`,
|
||||
};
|
||||
|
||||
export const MIGRATE_SEARCH_ENTRY_GROUPS = [
|
||||
MigrationEntryStatus.SEARCHING,
|
||||
MigrationEntryStatus.SEARCH_FAILED,
|
||||
MigrationEntryStatus.NO_MATCH,
|
||||
MigrationEntryStatus.SEARCH_COMPLETE,
|
||||
] as const satisfies readonly MigrationEntryStatus[];
|
||||
|
||||
export const MIGRATE_SEARCH_ENTRY_GROUP_EXPAND_DEFAULT_STATE: Record<
|
||||
(typeof MIGRATE_SEARCH_ENTRY_GROUPS)[number],
|
||||
boolean
|
||||
> = {
|
||||
[MigrationEntryStatus.SEARCHING]: true,
|
||||
[MigrationEntryStatus.SEARCH_FAILED]: false,
|
||||
[MigrationEntryStatus.NO_MATCH]: false,
|
||||
[MigrationEntryStatus.SEARCH_COMPLETE]: false,
|
||||
};
|
||||
|
||||
export const MIGRATE_EXECUTE_ENTRY_GROUPS = [
|
||||
MigrationEntryStatus.MIGRATING,
|
||||
MigrationEntryStatus.MIGRATION_FAILED,
|
||||
MigrationEntryStatus.NO_MATCH,
|
||||
MigrationEntryStatus.EXCLUDED,
|
||||
MigrationEntryStatus.MIGRATION_COMPLETE,
|
||||
] as const satisfies readonly MigrationEntryStatus[];
|
||||
|
||||
export const MIGRATE_EXECUTE_ENTRY_GROUP_EXPAND_DEFAULT_STATE: Record<
|
||||
(typeof MIGRATE_EXECUTE_ENTRY_GROUPS)[number],
|
||||
boolean
|
||||
> = {
|
||||
[MigrationEntryStatus.MIGRATING]: true,
|
||||
[MigrationEntryStatus.MIGRATION_FAILED]: false,
|
||||
[MigrationEntryStatus.NO_MATCH]: false,
|
||||
[MigrationEntryStatus.EXCLUDED]: false,
|
||||
[MigrationEntryStatus.MIGRATION_COMPLETE]: false,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,23 @@
|
||||
*/
|
||||
|
||||
import type { GetMigratableSourcesQuery } from '@/lib/graphql/generated/graphql.ts';
|
||||
import type {
|
||||
SourceDisplayNameInfo,
|
||||
SourceIconInfo,
|
||||
SourceIdInfo,
|
||||
SourceLanguageInfo,
|
||||
SourceMetaInfo,
|
||||
SourceNameInfo,
|
||||
} from '@/features/source/Source.types.ts';
|
||||
import type {
|
||||
MangaArtistInfo,
|
||||
MangaAuthorInfo,
|
||||
MangaIdInfo,
|
||||
MangaSourceIdInfo,
|
||||
MangaThumbnailInfo,
|
||||
MangaTitleInfo,
|
||||
} from '@/features/manga/Manga.types.ts';
|
||||
import type { ChapterNumberInfo } from '@/features/chapter/Chapter.types.ts';
|
||||
|
||||
export enum SortBy {
|
||||
SOURCE_NAME,
|
||||
@@ -40,3 +57,69 @@ export type MetadataMigrationSettings = {
|
||||
migrateMetadata: boolean;
|
||||
migrateSortSettings: SortSettings;
|
||||
};
|
||||
|
||||
export enum MigrationPhase {
|
||||
IDLE = 'idle',
|
||||
SELECT_SOURCE = 'select_source',
|
||||
SELECT_MANGAS = 'select_mangas',
|
||||
SELECTING_SOURCES = 'selecting_sources',
|
||||
SEARCHING = 'searching',
|
||||
MIGRATING = 'migrating',
|
||||
}
|
||||
|
||||
export enum MigrationEntryStatus {
|
||||
PENDING = 'pending',
|
||||
SEARCHING = 'searching',
|
||||
SEARCH_COMPLETE = 'search_complete',
|
||||
SEARCH_FAILED = 'search_failed',
|
||||
NO_MATCH = 'no_match',
|
||||
MIGRATING = 'migrating',
|
||||
MIGRATION_COMPLETE = 'migration_complete',
|
||||
MIGRATION_FAILED = 'migration_failed',
|
||||
EXCLUDED = 'excluded',
|
||||
}
|
||||
|
||||
export interface MigrationMatch
|
||||
extends MangaIdInfo, MangaTitleInfo, MangaThumbnailInfo, MangaSourceIdInfo, MangaArtistInfo, MangaAuthorInfo {
|
||||
sourceTitle: SourceDisplayNameInfo['displayName'] | undefined;
|
||||
latestChapterNumber: ChapterNumberInfo['chapterNumber'] | undefined;
|
||||
}
|
||||
|
||||
export interface TMigrationEntry {
|
||||
mangaId: MangaIdInfo['id'];
|
||||
mangaTitle: MangaTitleInfo['title'];
|
||||
mangaArtist: MangaArtistInfo['artist'];
|
||||
mangaAuthor: MangaAuthorInfo['author'];
|
||||
latestChapterNumber: ChapterNumberInfo['chapterNumber'] | undefined;
|
||||
mangaThumbnailUrl: MangaThumbnailInfo['thumbnailUrl'] | undefined;
|
||||
sourceId: SourceIdInfo['id'];
|
||||
sourceTitle: SourceDisplayNameInfo['displayName'] | undefined;
|
||||
status: MigrationEntryStatus;
|
||||
searchMatches: MigrationMatch[];
|
||||
manualMatches: MigrationMatch[];
|
||||
selectedMatchMangaId: MangaIdInfo['id'] | null;
|
||||
selectedMatchSourceId: SourceIdInfo['id'] | null;
|
||||
destSourceIdToSearchState: Record<SourceIdInfo['id'], boolean | undefined>;
|
||||
error: string | undefined;
|
||||
isExcluded: boolean;
|
||||
areMatchesExpanded: boolean;
|
||||
}
|
||||
|
||||
export type MigratableEntry = NonNullableProperty<TMigrationEntry, 'selectedMatchMangaId' | 'selectedMatchSourceId'>;
|
||||
|
||||
export type MigrationProgress = { total: number; completed: number; success: number; failed: number };
|
||||
|
||||
export interface MigrationState {
|
||||
phase: MigrationPhase;
|
||||
sourceId: SourceIdInfo['id'] | null;
|
||||
entries: Record<MangaIdInfo['id'], TMigrationEntry>;
|
||||
destinationSourceIds: SourceIdInfo['id'][];
|
||||
migrateOptions: Omit<MigrateOptions, 'mangaIdToMigrateTo'> | null;
|
||||
searchProgress: MigrationProgress;
|
||||
migrationProgress: MigrationProgress;
|
||||
startedAt: number | null;
|
||||
lastUpdatedAt: number | null;
|
||||
groupExpandState: Partial<Record<MigrationEntryStatus, boolean>>;
|
||||
}
|
||||
|
||||
export interface SourceItem extends SourceIdInfo, SourceNameInfo, SourceLanguageInfo, SourceIconInfo, SourceMetaInfo {}
|
||||
|
||||
918
src/features/migration/MigrationManager.ts
Normal file
918
src/features/migration/MigrationManager.ts
Normal file
@@ -0,0 +1,918 @@
|
||||
/*
|
||||
* 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 type { LimitFunction } from 'p-limit';
|
||||
import pLimit from 'p-limit';
|
||||
import { create } from 'zustand';
|
||||
import { immer } from 'zustand/middleware/immer';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import {
|
||||
type MigratableEntry,
|
||||
type MigrateOptions,
|
||||
type TMigrationEntry,
|
||||
MigrationEntryStatus,
|
||||
MigrationPhase,
|
||||
type MigrationMatch,
|
||||
type MigrationState,
|
||||
} from '@/features/migration/Migration.types.ts';
|
||||
import {
|
||||
DEFAULT_MIGRATION_STATE,
|
||||
MAX_MANGAS_IN_PARALLEL,
|
||||
MAX_SOURCES_IN_PARALLEL,
|
||||
MIGRATE_EXECUTE_ENTRY_GROUP_EXPAND_DEFAULT_STATE,
|
||||
MIGRATE_SEARCH_ENTRY_GROUP_EXPAND_DEFAULT_STATE,
|
||||
MIGRATION_LOCAL_STORAGE_KEY,
|
||||
} from '@/features/migration/Migration.constants.ts';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { GET_MIGRATION_SOURCE_MANGAS_FETCH } from '@/lib/graphql/source/SourceMutation.ts';
|
||||
import type {
|
||||
GetMigrationSourceMangasFetchMutation,
|
||||
GetMigrationSourceMangasFetchMutationVariables,
|
||||
GetServerSettingsQuery,
|
||||
GetServerSettingsQueryVariables,
|
||||
MangaMigrationFieldsFragment,
|
||||
} from '@/lib/graphql/generated/graphql.ts';
|
||||
import { FetchSourceMangaType } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { GET_SERVER_SETTINGS } from '@/lib/graphql/settings/SettingsQuery.ts';
|
||||
import { MangaMigration } from '@/features/migration/MangaMigration.ts';
|
||||
import type { MangaIdInfo } from '@/features/manga/Manga.types.ts';
|
||||
import type { SourceIdInfo } from '@/features/source/Source.types.ts';
|
||||
import { assertIsDefined } from '@/base/Asserts.ts';
|
||||
import { ReactRouter } from '@/lib/react-router/ReactRouter.ts';
|
||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||
import { Confirmation } from '@/base/AppAwaitableComponent.ts';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { t } from '@lingui/core/macro';
|
||||
import { enhancedCleanup } from '@/base/utils/Strings.ts';
|
||||
import { BrowseTab } from '@/features/browse/Browse.types.ts';
|
||||
import { Mangas } from '@/features/manga/services/Mangas.ts';
|
||||
import { MANGA_MIGRATION_FIELDS } from '@/lib/graphql/manga/MangaFragments.ts';
|
||||
import { ZustandUtil } from '@/lib/zustand/ZustandUtil.ts';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
import isEqual from 'lodash/fp/isEqual';
|
||||
|
||||
const RESUMABLE_PHASES: readonly MigrationPhase[] = [MigrationPhase.SEARCHING, MigrationPhase.MIGRATING];
|
||||
|
||||
const migrationStore = create<MigrationState>()(
|
||||
devtools(
|
||||
persist(
|
||||
immer(() => ({ ...DEFAULT_MIGRATION_STATE })),
|
||||
{
|
||||
name: MIGRATION_LOCAL_STORAGE_KEY,
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = persistedState as MigrationState | undefined;
|
||||
|
||||
if (!persisted || !RESUMABLE_PHASES.includes(persisted.phase)) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
return { ...currentState, ...persisted };
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const useMigrationStore = ZustandUtil.createStoreHook(migrationStore);
|
||||
|
||||
export class MigrationManager {
|
||||
private static abortController: AbortController | null = null;
|
||||
|
||||
private static mangaProcessQueue = pLimit(MAX_MANGAS_IN_PARALLEL);
|
||||
private static parallelSourcesQueue: LimitFunction | undefined;
|
||||
private static queueBySource = new Map<string, LimitFunction>();
|
||||
|
||||
private static abortAndResetAbortController(reason: unknown): void {
|
||||
MigrationManager.abortController?.abort(reason);
|
||||
MigrationManager.abortController = null;
|
||||
}
|
||||
|
||||
private static abortAndCreateAbortController(abortReason: unknown): AbortController {
|
||||
MigrationManager.abortController?.abort(abortReason);
|
||||
MigrationManager.abortController = new AbortController();
|
||||
|
||||
return MigrationManager.abortController;
|
||||
}
|
||||
|
||||
private static getOrCreateAbortController(): AbortController {
|
||||
if (!MigrationManager.abortController) {
|
||||
MigrationManager.abortController = new AbortController();
|
||||
}
|
||||
|
||||
return MigrationManager.abortController;
|
||||
}
|
||||
|
||||
private static getParallelSourceQueue(): LimitFunction {
|
||||
if (MigrationManager.parallelSourcesQueue) {
|
||||
return MigrationManager.parallelSourcesQueue;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = requestManager.graphQLClient.client.readQuery<
|
||||
GetServerSettingsQuery,
|
||||
GetServerSettingsQueryVariables
|
||||
>({
|
||||
query: GET_SERVER_SETTINGS,
|
||||
});
|
||||
|
||||
MigrationManager.parallelSourcesQueue = pLimit(
|
||||
result?.settings.maxSourcesInParallel ?? MAX_SOURCES_IN_PARALLEL,
|
||||
);
|
||||
} catch (error) {
|
||||
MigrationManager.parallelSourcesQueue = pLimit(MAX_SOURCES_IN_PARALLEL);
|
||||
}
|
||||
|
||||
return MigrationManager.parallelSourcesQueue!;
|
||||
}
|
||||
|
||||
private static getOrCreateSourceQueue(sourceId: SourceIdInfo['id']): LimitFunction {
|
||||
if (this.queueBySource.has(sourceId)) {
|
||||
return this.queueBySource.get(sourceId)!;
|
||||
}
|
||||
|
||||
const queue = pLimit(1);
|
||||
this.queueBySource.set(sourceId, queue);
|
||||
|
||||
return queue;
|
||||
}
|
||||
|
||||
static async confirmAbort(): Promise<boolean> {
|
||||
try {
|
||||
return await Confirmation.show({
|
||||
title: t`Abort migration`,
|
||||
message: t`Are you sure you want to abort the migration?`,
|
||||
});
|
||||
} catch (e) {
|
||||
defaultPromiseErrorHandler('MigrationManager::abort')(e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static async goToPreviousPhase(): Promise<boolean> {
|
||||
switch (MigrationManager.getState().phase) {
|
||||
case MigrationPhase.IDLE:
|
||||
return true;
|
||||
case MigrationPhase.SELECT_SOURCE:
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.phase = MigrationPhase.IDLE;
|
||||
});
|
||||
|
||||
return true;
|
||||
case MigrationPhase.SELECT_MANGAS:
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.phase = MigrationPhase.SELECT_SOURCE;
|
||||
draft.entries = {};
|
||||
});
|
||||
|
||||
ReactRouter.navigate(AppRoutes.browse.path(BrowseTab.MIGRATE));
|
||||
return false;
|
||||
case MigrationPhase.SELECTING_SOURCES:
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.phase = MigrationPhase.SELECT_MANGAS;
|
||||
draft.destinationSourceIds = [];
|
||||
});
|
||||
|
||||
return false;
|
||||
default:
|
||||
try {
|
||||
await MigrationManager.abort('goToPreviousPhase');
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static isPhaseComplete(): boolean {
|
||||
const { phase } = MigrationManager.getState();
|
||||
|
||||
switch (phase) {
|
||||
case MigrationPhase.SEARCHING:
|
||||
return (
|
||||
MigrationManager.getState().searchProgress.completed ===
|
||||
MigrationManager.getState().searchProgress.total
|
||||
);
|
||||
case MigrationPhase.MIGRATING:
|
||||
return (
|
||||
MigrationManager.getState().migrationProgress.completed ===
|
||||
MigrationManager.getState().migrationProgress.total
|
||||
);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static getUpToDateSearchMatch(searchMatch: MigrationMatch): MigrationMatch {
|
||||
const cachedEntry = Mangas.getFromCache<MangaMigrationFieldsFragment>(
|
||||
searchMatch.id,
|
||||
MANGA_MIGRATION_FIELDS,
|
||||
'MANGA_MIGRATION_FIELDS',
|
||||
);
|
||||
|
||||
return {
|
||||
id: cachedEntry?.id ?? searchMatch.id,
|
||||
title: cachedEntry?.title ?? searchMatch.title,
|
||||
thumbnailUrl: cachedEntry?.thumbnailUrl ?? searchMatch.thumbnailUrl,
|
||||
thumbnailUrlLastFetched: cachedEntry?.thumbnailUrlLastFetched ?? searchMatch.thumbnailUrlLastFetched,
|
||||
sourceId: cachedEntry?.sourceId ?? searchMatch.sourceId,
|
||||
artist: cachedEntry?.artist ?? searchMatch.artist,
|
||||
author: cachedEntry?.author ?? searchMatch.author,
|
||||
sourceTitle: cachedEntry?.source?.displayName ?? searchMatch.sourceTitle,
|
||||
latestChapterNumber: cachedEntry?.highestNumberedChapter?.chapterNumber ?? searchMatch.latestChapterNumber,
|
||||
};
|
||||
}
|
||||
|
||||
static getUpToDateMigrationEntry(entry: TMigrationEntry): TMigrationEntry {
|
||||
const cachedEntry = Mangas.getFromCache<MangaMigrationFieldsFragment>(
|
||||
entry.mangaId,
|
||||
MANGA_MIGRATION_FIELDS,
|
||||
'MANGA_MIGRATION_FIELDS',
|
||||
);
|
||||
|
||||
const updatedEntry = {
|
||||
mangaId: cachedEntry?.id ?? entry.mangaId,
|
||||
mangaTitle: cachedEntry?.title ?? entry.mangaTitle,
|
||||
mangaArtist: cachedEntry?.artist ?? entry.mangaArtist,
|
||||
mangaAuthor: cachedEntry?.author ?? entry.mangaAuthor,
|
||||
latestChapterNumber: cachedEntry?.highestNumberedChapter?.chapterNumber ?? entry.latestChapterNumber,
|
||||
mangaThumbnailUrl: cachedEntry?.thumbnailUrl ?? entry.mangaThumbnailUrl,
|
||||
sourceId: cachedEntry?.sourceId ?? entry.sourceId,
|
||||
sourceTitle: cachedEntry?.source?.displayName ?? entry.sourceTitle,
|
||||
status: entry.status,
|
||||
searchMatches: entry.searchMatches.map(MigrationManager.getUpToDateSearchMatch.bind(MigrationManager)),
|
||||
manualMatches: entry.manualMatches.map(MigrationManager.getUpToDateSearchMatch.bind(MigrationManager)),
|
||||
selectedMatchMangaId: entry.selectedMatchMangaId,
|
||||
selectedMatchSourceId: entry.selectedMatchSourceId,
|
||||
destSourceIdToSearchState: entry.destSourceIdToSearchState,
|
||||
error: entry.error,
|
||||
isExcluded: entry.isExcluded,
|
||||
areMatchesExpanded: entry.areMatchesExpanded,
|
||||
} satisfies TMigrationEntry;
|
||||
|
||||
if (isEqual(entry, updatedEntry)) {
|
||||
return entry;
|
||||
}
|
||||
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.entries[entry.mangaId] = updatedEntry;
|
||||
});
|
||||
|
||||
return updatedEntry;
|
||||
}
|
||||
|
||||
static selectSource(sourceId: SourceIdInfo['id']): void {
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.phase = MigrationPhase.SELECT_MANGAS;
|
||||
draft.sourceId = sourceId;
|
||||
draft.entries = {};
|
||||
});
|
||||
ReactRouter.navigate(AppRoutes.migrate.path);
|
||||
}
|
||||
|
||||
static selectMangas(mangas: MangaMigrationFieldsFragment[]): void {
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.phase = MigrationPhase.SELECTING_SOURCES;
|
||||
draft.entries = Object.fromEntries(
|
||||
mangas.map((manga) => [
|
||||
manga.id,
|
||||
{
|
||||
mangaId: manga.id,
|
||||
mangaTitle: manga.title,
|
||||
mangaArtist: manga.artist,
|
||||
mangaAuthor: manga.author,
|
||||
latestChapterNumber: manga.highestNumberedChapter?.chapterNumber,
|
||||
mangaThumbnailUrl: manga.thumbnailUrl,
|
||||
sourceId: manga.sourceId,
|
||||
sourceTitle: manga.source?.displayName,
|
||||
status: MigrationEntryStatus.PENDING,
|
||||
searchMatches: [],
|
||||
manualMatches: [],
|
||||
selectedMatchMangaId: null,
|
||||
selectedMatchSourceId: null,
|
||||
destSourceIdToSearchState: {},
|
||||
isExcluded: false,
|
||||
areMatchesExpanded: false,
|
||||
error: undefined,
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
static async startSearch(destinationSourceIds: SourceIdInfo['id'][]): Promise<void> {
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.destinationSourceIds = destinationSourceIds;
|
||||
});
|
||||
|
||||
const state = MigrationManager.getState();
|
||||
const entryIds = Object.keys(state.entries).map(Number);
|
||||
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.phase = MigrationPhase.SEARCHING;
|
||||
draft.searchProgress = { total: entryIds.length, completed: 0, success: 0, failed: 0 };
|
||||
draft.startedAt = Date.now();
|
||||
draft.groupExpandState = MIGRATE_SEARCH_ENTRY_GROUP_EXPAND_DEFAULT_STATE;
|
||||
});
|
||||
|
||||
try {
|
||||
await MigrationManager.search(Object.values(state.entries));
|
||||
} finally {
|
||||
const { searchProgress } = MigrationManager.getState();
|
||||
|
||||
if (searchProgress.completed === searchProgress.total) {
|
||||
const allSearchesFailed = searchProgress.failed === searchProgress.total;
|
||||
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.groupExpandState = {
|
||||
...MIGRATE_SEARCH_ENTRY_GROUP_EXPAND_DEFAULT_STATE,
|
||||
[MigrationEntryStatus.SEARCHING]: false,
|
||||
[MigrationEntryStatus.NO_MATCH]: !allSearchesFailed && !searchProgress.success,
|
||||
[MigrationEntryStatus.SEARCH_FAILED]: allSearchesFailed,
|
||||
[MigrationEntryStatus.SEARCH_COMPLETE]: !!searchProgress.success,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async search(entries: TMigrationEntry[]): Promise<void> {
|
||||
const { signal } = MigrationManager.abortAndCreateAbortController('search');
|
||||
|
||||
const searchPromises = entries.map((entry) =>
|
||||
MigrationManager.mangaProcessQueue(async () => {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await MigrationManager.searchForManga(entry.mangaId, entry.mangaTitle, signal);
|
||||
}),
|
||||
);
|
||||
|
||||
await Promise.allSettled(searchPromises);
|
||||
}
|
||||
|
||||
static getMigratableEntries(): MigratableEntry[] {
|
||||
const { entries } = MigrationManager.getState();
|
||||
|
||||
return Object.values(entries).filter(
|
||||
(entry): entry is MigratableEntry =>
|
||||
entry.status === MigrationEntryStatus.SEARCH_COMPLETE &&
|
||||
!entry.isExcluded &&
|
||||
entry.selectedMatchMangaId != null &&
|
||||
entry.selectedMatchSourceId != null,
|
||||
);
|
||||
}
|
||||
|
||||
static async startMigration(options: Omit<MigrateOptions, 'mangaIdToMigrateTo'>): Promise<void> {
|
||||
const migratableEntries = MigrationManager.getMigratableEntries();
|
||||
|
||||
await Confirmation.show({
|
||||
title: t`Migration information`,
|
||||
message: t`The migration runs on the client on the current device, NOT the server.\nAs long as the client is open, the migration will run in the background.\nThe client can be closed. The migration will be resumed once it gets opened again on the same device it got started on.`,
|
||||
actions: {
|
||||
confirm: {
|
||||
title: t`Understood`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.phase = MigrationPhase.MIGRATING;
|
||||
draft.migrateOptions = options;
|
||||
draft.migrationProgress = { total: migratableEntries.length, completed: 0, success: 0, failed: 0 };
|
||||
draft.groupExpandState = MIGRATE_EXECUTE_ENTRY_GROUP_EXPAND_DEFAULT_STATE;
|
||||
});
|
||||
|
||||
try {
|
||||
await MigrationManager.migrate(migratableEntries, options);
|
||||
} finally {
|
||||
const { migrationProgress } = MigrationManager.getState();
|
||||
|
||||
if (migrationProgress.completed === migrationProgress.total) {
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.groupExpandState = {
|
||||
...MIGRATE_SEARCH_ENTRY_GROUP_EXPAND_DEFAULT_STATE,
|
||||
[MigrationEntryStatus.MIGRATING]: false,
|
||||
[MigrationEntryStatus.MIGRATION_FAILED]: !!migrationProgress.failed,
|
||||
[MigrationEntryStatus.MIGRATION_COMPLETE]: !migrationProgress.failed,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async migrate(
|
||||
entries: MigratableEntry[],
|
||||
options: Omit<MigrateOptions, 'mangaIdToMigrateTo'>,
|
||||
): Promise<void> {
|
||||
const { signal } = MigrationManager.abortAndCreateAbortController('migrate');
|
||||
|
||||
const entriesBySource = Object.groupBy(entries, (entry) => entry.selectedMatchSourceId);
|
||||
|
||||
const migrationPromises = Object.values(entriesBySource).map((sourceEntries = []) =>
|
||||
MigrationManager.mangaProcessQueue(async () => {
|
||||
for (const entry of sourceEntries) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
// oxlint-disable-next-line no-await-in-loop
|
||||
await MigrationManager.migrateSingleEntry(entry.mangaId, options, signal);
|
||||
}
|
||||
}),
|
||||
);
|
||||
await Promise.allSettled(migrationPromises);
|
||||
|
||||
if (!signal.aborted) {
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.lastUpdatedAt = Date.now();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async abort(reason: unknown = 'abort'): Promise<boolean> {
|
||||
if (!(await MigrationManager.confirmAbort())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
MigrationManager.abortAndResetAbortController(reason);
|
||||
|
||||
MigrationManager.reset();
|
||||
|
||||
ReactRouter.navigate(AppRoutes.browse.path(BrowseTab.MIGRATE));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static async resume(): Promise<void> {
|
||||
const state = MigrationManager.getState();
|
||||
|
||||
const isResumeablePhase = RESUMABLE_PHASES.includes(state.phase);
|
||||
if (!isResumeablePhase) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resumeMigrationPhase = state.phase === MigrationPhase.MIGRATING && state.migrateOptions;
|
||||
if (resumeMigrationPhase) {
|
||||
assertIsDefined(state.migrateOptions);
|
||||
|
||||
const migratableEntries = MigrationManager.getMigratableEntries();
|
||||
|
||||
await MigrationManager.migrate(migratableEntries, state.migrateOptions);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingEntries = Object.values(state.entries).filter(
|
||||
(entry) => entry.status === MigrationEntryStatus.PENDING,
|
||||
);
|
||||
|
||||
await MigrationManager.search(pendingEntries);
|
||||
}
|
||||
|
||||
static reset(): void {
|
||||
MigrationManager.abortAndResetAbortController('reset');
|
||||
migrationStore.setState({ ...DEFAULT_MIGRATION_STATE });
|
||||
}
|
||||
|
||||
static excludeManga(mangaId: MangaIdInfo['id']): void {
|
||||
MigrationManager.updateState((draft) => {
|
||||
const entry = draft.entries[mangaId];
|
||||
|
||||
if (entry) {
|
||||
entry.isExcluded = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static includeManga(mangaId: MangaIdInfo['id']): void {
|
||||
MigrationManager.updateState((draft) => {
|
||||
const entry = draft.entries[mangaId];
|
||||
|
||||
if (entry) {
|
||||
entry.isExcluded = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static selectMatch(
|
||||
mangaId: MangaIdInfo['id'],
|
||||
targetMangaId: MangaIdInfo['id'],
|
||||
targetSourceId: SourceIdInfo['id'],
|
||||
): void {
|
||||
MigrationManager.updateState((draft) => {
|
||||
const entry = draft.entries[mangaId];
|
||||
if (entry) {
|
||||
entry.selectedMatchMangaId = targetMangaId;
|
||||
entry.selectedMatchSourceId = targetSourceId;
|
||||
entry.status = MigrationEntryStatus.SEARCH_COMPLETE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static selectManualMatch(mangaId: MangaIdInfo['id'], match: MigrationMatch): void {
|
||||
MigrationManager.updateState((draft) => {
|
||||
const entry = draft.entries[mangaId];
|
||||
if (entry) {
|
||||
const isExistingSearchMatch = draft.entries[mangaId].searchMatches.some(
|
||||
(searchMatch) => searchMatch.id === match.id,
|
||||
);
|
||||
const isExistingManualMatch = draft.entries[mangaId].manualMatches.some(
|
||||
(manualMatch) => manualMatch.id === match.id,
|
||||
);
|
||||
|
||||
const isExistingEntry = isExistingSearchMatch || isExistingManualMatch;
|
||||
if (!isExistingEntry) {
|
||||
draft.entries[mangaId].manualMatches = [...draft.entries[mangaId].manualMatches, match];
|
||||
}
|
||||
|
||||
entry.selectedMatchMangaId = match.id;
|
||||
entry.selectedMatchSourceId = match.sourceId;
|
||||
entry.status = MigrationEntryStatus.SEARCH_COMPLETE;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static getState(): MigrationState {
|
||||
return migrationStore.getState();
|
||||
}
|
||||
|
||||
static isActive(): boolean {
|
||||
const { phase } = migrationStore.getState();
|
||||
return phase === MigrationPhase.SEARCHING || phase === MigrationPhase.MIGRATING;
|
||||
}
|
||||
|
||||
static hasPausedMigration(): boolean {
|
||||
return RESUMABLE_PHASES.includes(MigrationManager.getState().phase);
|
||||
}
|
||||
|
||||
private static getHigherPrioritySourceIds(sourceId: SourceIdInfo['id']): SourceIdInfo['id'][] {
|
||||
const { destinationSourceIds } = MigrationManager.getState();
|
||||
|
||||
const sourceIdPriority = destinationSourceIds.indexOf(sourceId);
|
||||
return destinationSourceIds.slice(0, Math.max(0, sourceIdPriority - 1));
|
||||
}
|
||||
|
||||
private static isHigherPrioritySourceUnsettled(mangaId: MangaIdInfo['id'], sourceId: SourceIdInfo['id']): boolean {
|
||||
const { entries } = MigrationManager.getState();
|
||||
const entry = entries[mangaId];
|
||||
|
||||
assertIsDefined(entry);
|
||||
|
||||
return MigrationManager.getHigherPrioritySourceIds(sourceId).some(
|
||||
(higherPrioritySourceId) => entry.destSourceIdToSearchState[higherPrioritySourceId] == null,
|
||||
);
|
||||
}
|
||||
|
||||
private static hasHigherSourcePriorityMatch(mangaId: MangaIdInfo['id'], sourceId: SourceIdInfo['id']): boolean {
|
||||
const { entries } = MigrationManager.getState();
|
||||
const entry = entries[mangaId];
|
||||
|
||||
if (!entry || entry.selectedMatchSourceId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return MigrationManager.getHigherPrioritySourceIds(sourceId).some(
|
||||
(higherPrioritySourceId) => entry.destSourceIdToSearchState[higherPrioritySourceId],
|
||||
);
|
||||
}
|
||||
|
||||
private static async findMatchesForMangaInSource(
|
||||
mangaId: MangaIdInfo['id'],
|
||||
mangaTitle: string,
|
||||
sourceId: SourceIdInfo['id'],
|
||||
signal: AbortSignal,
|
||||
): Promise<MangaMigrationFieldsFragment[]> {
|
||||
if (signal.aborted) {
|
||||
throw new Error(signal.reason);
|
||||
}
|
||||
|
||||
return MigrationManager.getOrCreateSourceQueue(sourceId)(async () => {
|
||||
if (signal.aborted) {
|
||||
throw new Error(signal.reason);
|
||||
}
|
||||
|
||||
if (MigrationManager.hasHigherSourcePriorityMatch(mangaId, sourceId)) {
|
||||
throw new Error('Entry already has a selected match from a higher priority source');
|
||||
}
|
||||
|
||||
const searchResponse = await requestManager.graphQLClient.client.mutate<
|
||||
GetMigrationSourceMangasFetchMutation,
|
||||
GetMigrationSourceMangasFetchMutationVariables
|
||||
>({
|
||||
mutation: GET_MIGRATION_SOURCE_MANGAS_FETCH,
|
||||
variables: {
|
||||
input: {
|
||||
source: sourceId,
|
||||
query: mangaTitle,
|
||||
page: 1,
|
||||
type: FetchSourceMangaType.Search,
|
||||
},
|
||||
},
|
||||
context: { fetchOptions: { signal } },
|
||||
});
|
||||
|
||||
const searchMatches = searchResponse?.data?.fetchSourceManga?.mangas ?? [];
|
||||
const matches = searchMatches.filter(
|
||||
(searchMatch) => enhancedCleanup(searchMatch.title) === enhancedCleanup(mangaTitle),
|
||||
);
|
||||
|
||||
const matchUpdatePromises = matches.map(async (match) => {
|
||||
if (signal.aborted) {
|
||||
throw new Error(signal.reason);
|
||||
}
|
||||
|
||||
const updatedMatch = await requestManager.getMangaFetch(match.id).response;
|
||||
|
||||
return updatedMatch.data?.fetchManga?.manga ?? match;
|
||||
});
|
||||
|
||||
const updatedMatches = await Promise.all(matchUpdatePromises);
|
||||
|
||||
return updatedMatches;
|
||||
});
|
||||
}
|
||||
|
||||
private static async searchForManga(
|
||||
mangaId: MangaIdInfo['id'],
|
||||
mangaTitle: string,
|
||||
mainSignal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const state = MigrationManager.getState();
|
||||
const entry = state.entries[mangaId];
|
||||
|
||||
const searchController = new AbortController();
|
||||
const signal = AbortSignal.any([mainSignal, searchController.signal]);
|
||||
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.entries[mangaId].status = MigrationEntryStatus.SEARCHING;
|
||||
});
|
||||
|
||||
try {
|
||||
const searchPromises = state.destinationSourceIds.map((destSourceId) =>
|
||||
MigrationManager.getParallelSourceQueue()(async () => {
|
||||
if (signal.aborted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (MigrationManager.hasHigherSourcePriorityMatch(mangaId, destSourceId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const foundMatches = await (async () => {
|
||||
try {
|
||||
return await MigrationManager.findMatchesForMangaInSource(
|
||||
mangaId,
|
||||
mangaTitle,
|
||||
destSourceId,
|
||||
signal,
|
||||
);
|
||||
} catch (e) {
|
||||
MigrationManager.updateState((draft) => {
|
||||
const draftEntry = draft.entries[mangaId];
|
||||
|
||||
draftEntry.destSourceIdToSearchState[destSourceId] = false;
|
||||
});
|
||||
|
||||
throw e;
|
||||
}
|
||||
})();
|
||||
|
||||
if (!foundMatches.length) {
|
||||
MigrationManager.updateState((draft) => {
|
||||
const draftEntry = draft.entries[mangaId];
|
||||
|
||||
draftEntry.destSourceIdToSearchState[destSourceId] = false;
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
MigrationManager.updateState((draft) => {
|
||||
const draftEntry = draft.entries[mangaId];
|
||||
|
||||
const matches = foundMatches.map((manga) => ({
|
||||
id: manga.id,
|
||||
title: manga.title,
|
||||
artist: manga.artist,
|
||||
author: manga.author,
|
||||
latestChapterNumber: manga.highestNumberedChapter?.chapterNumber,
|
||||
thumbnailUrl: manga.thumbnailUrl,
|
||||
sourceId: manga.sourceId,
|
||||
sourceTitle: manga.source?.displayName,
|
||||
}));
|
||||
|
||||
draftEntry.destSourceIdToSearchState[destSourceId] = true;
|
||||
|
||||
draftEntry.searchMatches = [...draftEntry.searchMatches, ...matches];
|
||||
|
||||
if (!MigrationManager.hasHigherSourcePriorityMatch(mangaId, destSourceId)) {
|
||||
const matchesByChapterNumber = Object.groupBy(
|
||||
matches,
|
||||
(match) => match.latestChapterNumber ?? -1,
|
||||
);
|
||||
const latestChapterNumber = Math.max(
|
||||
...Object.keys(matchesByChapterNumber).map((chapterNumber) => Number(chapterNumber)),
|
||||
);
|
||||
const bestMatch = matchesByChapterNumber[latestChapterNumber]?.[0];
|
||||
|
||||
assertIsDefined(bestMatch);
|
||||
|
||||
draftEntry.selectedMatchMangaId = bestMatch.id;
|
||||
draftEntry.selectedMatchSourceId = destSourceId;
|
||||
}
|
||||
|
||||
if (!MigrationManager.isHigherPrioritySourceUnsettled(mangaId, destSourceId)) {
|
||||
searchController.abort(`Found best match in source "${destSourceId}"`);
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const searchMatchPromises = await Promise.allSettled(searchPromises);
|
||||
|
||||
const hasFulfilledSearch = searchMatchPromises.some((result) => result.status === 'fulfilled');
|
||||
if (!hasFulfilledSearch) {
|
||||
throw new Error('All source searches failed');
|
||||
}
|
||||
|
||||
MigrationManager.updateState((draft) => {
|
||||
const draftEntry = draft.entries[mangaId];
|
||||
|
||||
if (draftEntry.searchMatches.length) {
|
||||
draft.searchProgress.success += 1;
|
||||
draftEntry.status = MigrationEntryStatus.SEARCH_COMPLETE;
|
||||
} else {
|
||||
draftEntry.status = MigrationEntryStatus.NO_MATCH;
|
||||
}
|
||||
|
||||
draft.searchProgress.completed += 1;
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
MigrationManager.updateState((draft) => {
|
||||
const draftEntry = draft.entries[mangaId];
|
||||
|
||||
draftEntry.status = MigrationEntryStatus.SEARCH_FAILED;
|
||||
draftEntry.error = getErrorMessage(error);
|
||||
draft.searchProgress.completed += 1;
|
||||
draft.searchProgress.failed += 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static async migrateSingleEntry(
|
||||
mangaId: MangaIdInfo['id'],
|
||||
options: Omit<MigrateOptions, 'mangaIdToMigrateTo'>,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
const state = MigrationManager.getState();
|
||||
const entry = state.entries[mangaId];
|
||||
|
||||
if (!entry || !entry.selectedMatchSourceId || entry.selectedMatchMangaId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.entries[mangaId].status = MigrationEntryStatus.MIGRATING;
|
||||
});
|
||||
|
||||
try {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await MigrationManager.getParallelSourceQueue()(() =>
|
||||
MigrationManager.getOrCreateSourceQueue(entry.sourceId)(() => {
|
||||
assertIsDefined(entry.selectedMatchSourceId);
|
||||
|
||||
return MigrationManager.getOrCreateSourceQueue(entry.selectedMatchSourceId)(async () => {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertIsDefined(entry.selectedMatchMangaId);
|
||||
await MangaMigration.migrate(mangaId, entry.selectedMatchMangaId, options);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.entries[mangaId].status = MigrationEntryStatus.MIGRATION_COMPLETE;
|
||||
draft.migrationProgress.success += 1;
|
||||
draft.migrationProgress.completed += 1;
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.entries[mangaId].status = MigrationEntryStatus.MIGRATION_FAILED;
|
||||
draft.entries[mangaId].error = error instanceof Error ? error.message : String(error);
|
||||
draft.migrationProgress.failed += 1;
|
||||
draft.migrationProgress.completed += 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static async retryEntry(id: MangaIdInfo['id']): Promise<void> {
|
||||
const entry = MigrationManager.getState().entries[id];
|
||||
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { signal } = MigrationManager.getOrCreateAbortController();
|
||||
const { status } = entry;
|
||||
|
||||
if (status === MigrationEntryStatus.SEARCH_FAILED) {
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.searchProgress.completed -= 1;
|
||||
draft.searchProgress.failed -= 1;
|
||||
});
|
||||
|
||||
await MigrationManager.searchForManga(id, entry.mangaTitle, signal);
|
||||
return;
|
||||
}
|
||||
|
||||
if (status === MigrationEntryStatus.MIGRATION_FAILED) {
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.migrationProgress.completed -= 1;
|
||||
draft.migrationProgress.failed -= 1;
|
||||
});
|
||||
|
||||
const migrationOptions = MigrationManager.getState().migrateOptions;
|
||||
assertIsDefined(migrationOptions);
|
||||
|
||||
await MigrationManager.migrateSingleEntry(id, migrationOptions, signal);
|
||||
}
|
||||
}
|
||||
|
||||
private static updateState(updater: (draft: MigrationState) => void): void {
|
||||
migrationStore.setState(updater);
|
||||
}
|
||||
|
||||
static setEntryMatchesExpandState(id: MangaIdInfo['id'], expanded: boolean): void {
|
||||
MigrationManager.updateState((draft) => {
|
||||
const entry = draft.entries[id];
|
||||
|
||||
if (entry) {
|
||||
entry.areMatchesExpanded = expanded;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static useEntryMatchesExpandState(id: MangaIdInfo['id']): boolean {
|
||||
return useMigrationStore((state) => state.entries[id]?.areMatchesExpanded ?? false);
|
||||
}
|
||||
|
||||
static setGroupExpandState(status: MigrationEntryStatus, expanded: boolean): void {
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.groupExpandState[status] = expanded;
|
||||
});
|
||||
}
|
||||
|
||||
static useGroupExpandState(status: MigrationEntryStatus): boolean {
|
||||
return useMigrationStore((state) => state.groupExpandState[status] ?? false);
|
||||
}
|
||||
|
||||
static usePhase(): MigrationPhase {
|
||||
return useMigrationStore((state) => state.phase);
|
||||
}
|
||||
|
||||
static useSourceId(): SourceIdInfo['id'] | null {
|
||||
return useMigrationStore((state) => state.sourceId);
|
||||
}
|
||||
|
||||
static useEntries(): Record<number, TMigrationEntry> {
|
||||
return useMigrationStore((state) => state.entries);
|
||||
}
|
||||
|
||||
static useSearchProgress(): MigrationState['searchProgress'] {
|
||||
return useMigrationStore((state) => state.searchProgress);
|
||||
}
|
||||
|
||||
static useMigrationProgress(): MigrationState['migrationProgress'] {
|
||||
return useMigrationStore((state) => state.migrationProgress);
|
||||
}
|
||||
|
||||
static useIsActive(): boolean {
|
||||
// Listen to phase changes
|
||||
MigrationManager.usePhase();
|
||||
|
||||
return MigrationManager.isActive();
|
||||
}
|
||||
}
|
||||
68
src/features/migration/components/MIgrationEntryGroup.tsx
Normal file
68
src/features/migration/components/MIgrationEntryGroup.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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 type { MigrationEntryStatus, TMigrationEntry } from '@/features/migration/Migration.types.ts';
|
||||
import type { ButtonProps } from '@mui/material/Button';
|
||||
import Button from '@mui/material/Button';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import {} from 'react';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import { MigrationEntry } from '@/features/migration/components/migration-entry/MigrationEntry.tsx';
|
||||
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
|
||||
|
||||
export const MigrationEntryGroup = ({
|
||||
status,
|
||||
title,
|
||||
entries,
|
||||
color,
|
||||
isMigrating = false,
|
||||
}: {
|
||||
status: MigrationEntryStatus;
|
||||
title: string;
|
||||
entries: TMigrationEntry[];
|
||||
color: ButtonProps['color'];
|
||||
isMigrating?: boolean;
|
||||
}) => {
|
||||
const isExpanded = MigrationManager.useGroupExpandState(status);
|
||||
|
||||
if (!entries.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack sx={{ width: '100%', gap: 2 }}>
|
||||
<Button
|
||||
onClick={() => MigrationManager.setGroupExpandState(status, !isExpanded)}
|
||||
color={color}
|
||||
variant="outlined"
|
||||
startIcon={isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
size="large"
|
||||
sx={{
|
||||
py: 2,
|
||||
justifyContent: 'center',
|
||||
'& .MuiButton-startIcon': {
|
||||
position: 'absolute',
|
||||
left: (theme) => theme.spacing(4),
|
||||
margin: 0,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Button>
|
||||
<Collapse in={isExpanded} unmountOnExit>
|
||||
<Stack sx={{ gap: 1 }}>
|
||||
{entries.map((entry) => (
|
||||
<MigrationEntry key={entry.mangaId} entry={entry} isMigrating={isMigrating} />
|
||||
))}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -11,12 +11,11 @@ import Box from '@mui/material/Box';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import type { GetMigratableSourcesQuery } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { translateExtensionLanguage } from '@/features/extension/Extensions.utils.ts';
|
||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
|
||||
import { ListCardAvatar } from '@/base/components/lists/cards/ListCardAvatar.tsx';
|
||||
import { ListCardContent } from '@/base/components/lists/cards/ListCardContent.tsx';
|
||||
|
||||
@@ -33,7 +32,7 @@ export const MigrationCard = ({ id, name, lang, iconUrl, mangaCount }: TMigratab
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardActionArea component={Link} to={AppRoutes.migrate.path(id)}>
|
||||
<CardActionArea onClick={() => MigrationManager.selectSource(id)}>
|
||||
<ListCardContent sx={{ justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
||||
<ListCardAvatar
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
* 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 { useLingui } from '@lingui/react/macro';
|
||||
import Fab from '@mui/material/Fab';
|
||||
|
||||
export const MigrationContinueButton = ({
|
||||
onClick,
|
||||
isDisabled,
|
||||
title,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
isDisabled?: boolean;
|
||||
title?: string;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<Fab
|
||||
variant="extended"
|
||||
color="primary"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: (theme) => theme.spacing(2),
|
||||
right: (theme) => theme.spacing(2),
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
>
|
||||
{title ?? t`Continue`}
|
||||
</Fab>
|
||||
);
|
||||
};
|
||||
74
src/features/migration/components/MigrationFABIndicator.tsx
Normal file
74
src/features/migration/components/MigrationFABIndicator.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* 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 Chip from '@mui/material/Chip';
|
||||
import CircularProgress from '@mui/material/CircularProgress';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import type { MigrationProgress } from '@/features/migration/Migration.types.ts';
|
||||
import { MigrationPhase } from '@/features/migration/Migration.types.ts';
|
||||
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
|
||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||
import DoneAllIcon from '@mui/icons-material/DoneAll';
|
||||
import { useLayoutEffect, useState } from 'react';
|
||||
|
||||
const getButtonText = (phase: MigrationPhase, progress: MigrationProgress) => {
|
||||
switch (phase) {
|
||||
case MigrationPhase.SEARCHING:
|
||||
if (progress.total === progress.completed) {
|
||||
return `Searching`;
|
||||
}
|
||||
|
||||
return `Searching (${progress.completed}/${progress.total})`;
|
||||
|
||||
case MigrationPhase.MIGRATING:
|
||||
if (progress.total === progress.completed) {
|
||||
return `Migrating`;
|
||||
}
|
||||
|
||||
return `Migrating (${progress.completed}/${progress.total})`;
|
||||
default:
|
||||
return 'Migrating';
|
||||
}
|
||||
};
|
||||
|
||||
export const MigrationFABIndicator = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const isActive = MigrationManager.useIsActive();
|
||||
const phase = MigrationManager.usePhase();
|
||||
const searchProgress = MigrationManager.useSearchProgress();
|
||||
const migrationProgress = MigrationManager.useMigrationProgress();
|
||||
|
||||
const [isVisible, setIsVisible] = useState(true);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setIsVisible(true);
|
||||
}, [phase]);
|
||||
|
||||
if (!isVisible || !isActive || location.pathname.startsWith(AppRoutes.migrate.path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Chip
|
||||
icon={MigrationManager.isPhaseComplete() ? <DoneAllIcon /> : <CircularProgress size={16} color="inherit" />}
|
||||
label={getButtonText(phase, phase === MigrationPhase.SEARCHING ? searchProgress : migrationProgress)}
|
||||
color="primary"
|
||||
onClick={() => navigate(AppRoutes.migrate.path)}
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: (theme) => theme.spacing(2),
|
||||
right: (theme) => theme.spacing(2),
|
||||
zIndex: (theme) => theme.zIndex.fab,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onDelete={() => setIsVisible(false)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -11,96 +11,83 @@ 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 Stack from '@mui/material/Stack';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import FormGroup from '@mui/material/FormGroup';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { CheckboxInput } from '@/base/components/inputs/CheckboxInput.tsx';
|
||||
import { Mangas } from '@/features/manga/services/Mangas.ts';
|
||||
import { makeToast } from '@/base/utils/Toast.ts';
|
||||
import {
|
||||
createUpdateMetadataServerSettings,
|
||||
useMetadataServerSettings,
|
||||
} from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import type { MetadataMigrationSettings, MigrateMode } from '@/features/migration/Migration.types.ts';
|
||||
import type { MetadataMigrationSettings, MigrateOptions } from '@/features/migration/Migration.types.ts';
|
||||
import type { AwaitableComponentProps } from 'awaitable-component';
|
||||
import type { MangaIdInfo } from '@/features/manga/Manga.types.ts';
|
||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export const MigrateDialog = ({ mangaIdToMigrateTo, onClose }: { mangaIdToMigrateTo: number; onClose: () => void }) => {
|
||||
export const MigrationOptionsDialog = ({
|
||||
isVisible,
|
||||
onDismiss,
|
||||
onSubmit,
|
||||
onExitComplete,
|
||||
isMigrating = false,
|
||||
mangaIdToMigrateTo,
|
||||
startMigration = onSubmit,
|
||||
}: AwaitableComponentProps<Omit<MigrateOptions, 'mangaIdToMigrateTo'>> & {
|
||||
isMigrating?: boolean;
|
||||
mangaIdToMigrateTo?: MangaIdInfo['id'];
|
||||
startMigration?: (options: Omit<MigrateOptions, 'mangaIdToMigrateTo'>) => void;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { mangaId: mangaIdAsString } = useParams<{ mangaId: string }>();
|
||||
const mangaId = Number(mangaIdAsString);
|
||||
|
||||
const {
|
||||
settings: { migrateChapters, migrateCategories, migrateTracking, deleteChapters, migrateMetadata },
|
||||
} = useMetadataServerSettings();
|
||||
|
||||
const [isMigrationInProcess, setIsMigrationInProcess] = useState(false);
|
||||
|
||||
const setMigrationFlag = createUpdateMetadataServerSettings<keyof MetadataMigrationSettings>(
|
||||
defaultPromiseErrorHandler('MigrateDialog::updateSetting'),
|
||||
);
|
||||
|
||||
const migrate = async (mode: MigrateMode) => {
|
||||
if (mangaId == null) {
|
||||
throw new Error(`MigrateDialog::migrate: unexpected mangaId "${mangaId}"`);
|
||||
}
|
||||
|
||||
makeToast(t`Migrating manga…`, 'info');
|
||||
|
||||
setIsMigrationInProcess(true);
|
||||
|
||||
try {
|
||||
await Mangas.migrate(mangaId, mangaIdToMigrateTo, {
|
||||
mode,
|
||||
migrateChapters,
|
||||
migrateCategories,
|
||||
migrateTracking,
|
||||
deleteChapters,
|
||||
migrateMetadata,
|
||||
});
|
||||
|
||||
navigate(AppRoutes.manga.path(mangaIdToMigrateTo), { replace: true });
|
||||
} catch (e) {
|
||||
setIsMigrationInProcess(false);
|
||||
}
|
||||
const options: Omit<MigrateOptions, 'mangaIdToMigrateTo' | 'mode'> = {
|
||||
migrateChapters,
|
||||
migrateCategories,
|
||||
migrateTracking,
|
||||
deleteChapters,
|
||||
migrateMetadata,
|
||||
};
|
||||
|
||||
const setMigrationFlag = createUpdateMetadataServerSettings<keyof MetadataMigrationSettings>(
|
||||
defaultPromiseErrorHandler('MigrationOptionsDialog::updateSetting'),
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open fullWidth onClose={onClose}>
|
||||
<DialogTitle>{t`Select data to include`}</DialogTitle>
|
||||
<Dialog open={isVisible} fullWidth onClose={onDismiss} onTransitionExited={onExitComplete}>
|
||||
<DialogTitle>{t`Migration options`}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<FormGroup>
|
||||
<CheckboxInput
|
||||
disabled={isMigrationInProcess}
|
||||
disabled={isMigrating}
|
||||
label={t`Chapter`}
|
||||
checked={migrateChapters}
|
||||
onChange={(_, checked) => setMigrationFlag('migrateChapters', checked)}
|
||||
/>
|
||||
<CheckboxInput
|
||||
disabled={isMigrationInProcess}
|
||||
disabled={isMigrating}
|
||||
label={t`Category`}
|
||||
checked={migrateCategories}
|
||||
onChange={(_, checked) => setMigrationFlag('migrateCategories', checked)}
|
||||
/>
|
||||
<CheckboxInput
|
||||
disabled={isMigrationInProcess}
|
||||
disabled={isMigrating}
|
||||
label={t`Tracking`}
|
||||
checked={migrateTracking}
|
||||
onChange={(_, checked) => setMigrationFlag('migrateTracking', checked)}
|
||||
/>
|
||||
<CheckboxInput
|
||||
disabled={isMigrationInProcess}
|
||||
disabled={isMigrating}
|
||||
label={t`Client data`}
|
||||
checked={migrateMetadata}
|
||||
onChange={(_, checked) => setMigrationFlag('migrateMetadata', checked)}
|
||||
/>
|
||||
<CheckboxInput
|
||||
disabled={isMigrationInProcess}
|
||||
disabled={isMigrating}
|
||||
label={t`Delete downloaded`}
|
||||
checked={deleteChapters}
|
||||
onChange={(_, checked) => setMigrationFlag('deleteChapters', checked)}
|
||||
@@ -115,21 +102,28 @@ export const MigrateDialog = ({ mangaIdToMigrateTo, onClose }: { mangaIdToMigrat
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
disabled={isMigrationInProcess}
|
||||
component={Link}
|
||||
to={AppRoutes.manga.path(mangaIdToMigrateTo)}
|
||||
>
|
||||
{t`Show entry`}
|
||||
</Button>
|
||||
{mangaIdToMigrateTo !== undefined && (
|
||||
<Button
|
||||
disabled={isMigrating}
|
||||
component={Link}
|
||||
to={AppRoutes.manga.path(mangaIdToMigrateTo)}
|
||||
onClick={() => onDismiss('show entry')}
|
||||
>
|
||||
{t`Show entry`}
|
||||
</Button>
|
||||
)}
|
||||
<Stack direction="row">
|
||||
<Button disabled={isMigrationInProcess} onClick={onClose}>
|
||||
<Button disabled={isMigrating} onClick={onDismiss}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button disabled={isMigrationInProcess} onClick={() => migrate('copy')}>
|
||||
<Button disabled={isMigrating} onClick={() => startMigration({ ...options, mode: 'copy' })}>
|
||||
{t`Copy`}
|
||||
</Button>
|
||||
<Button disabled={isMigrationInProcess} onClick={() => migrate('migrate')}>
|
||||
<Button
|
||||
disabled={isMigrating}
|
||||
variant="contained"
|
||||
onClick={() => startMigration({ ...options, mode: 'migrate' })}
|
||||
>
|
||||
{t`Migrate`}
|
||||
</Button>
|
||||
</Stack>
|
||||
52
src/features/migration/components/MigrationProgressBar.tsx
Normal file
52
src/features/migration/components/MigrationProgressBar.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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 Box from '@mui/material/Box';
|
||||
import LinearProgress from '@mui/material/LinearProgress';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
|
||||
import type { MigrationProgress } from '@/features/migration/Migration.types.ts';
|
||||
|
||||
export const MigrationProgressBar = ({
|
||||
completed,
|
||||
total,
|
||||
label,
|
||||
}: {
|
||||
label: string;
|
||||
} & MigrationProgress) => {
|
||||
const { appBarHeight } = useNavBarContext();
|
||||
|
||||
const progress = total > 0 ? (completed / total) * 100 : 0;
|
||||
|
||||
if (completed === total) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
top: appBarHeight,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 2,
|
||||
px: 2,
|
||||
py: 1,
|
||||
backgroundColor: 'background.default',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<LinearProgress variant="determinate" value={progress} />
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ minWidth: 'fit-content' }}>
|
||||
{label}
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
242
src/features/migration/components/MigrationSourceList.tsx
Normal file
242
src/features/migration/components/MigrationSourceList.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* 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 { useCallback, useMemo, useState } from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import { closestCenter, DndContext } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { DndSortableItem } from '@/lib/dnd-kit/DndSortableItem.tsx';
|
||||
import { DndKitUtil } from '@/lib/dnd-kit/DndKitUtil.ts';
|
||||
import { ListCardAvatar } from '@/base/components/lists/cards/ListCardAvatar.tsx';
|
||||
import { ListCardContent } from '@/base/components/lists/cards/ListCardContent.tsx';
|
||||
import type { SourceItem } from '@/features/migration/Migration.types.ts';
|
||||
import type { SourceIdInfo } from '@/features/source/Source.types.ts';
|
||||
import type { SelectableCollectionReturnType } from '@/base/collection/hooks/useSelectableCollection.ts';
|
||||
import { assertIsDefined } from '@/base/Asserts.ts';
|
||||
import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx';
|
||||
import { StyledGroupedVirtuoso } from '@/base/components/virtuoso/StyledGroupedVirtuoso.tsx';
|
||||
import { StyledGroupHeader } from '@/base/components/virtuoso/StyledGroupHeader.tsx';
|
||||
import { DndOverlayItem } from '@/lib/dnd-kit/DndOverlayItem';
|
||||
import { noOp } from '@/lib/HelperFunctions';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import { StyledGroupItemWrapper } from '@/base/components/virtuoso/StyledGroupItemWrapper.tsx';
|
||||
import DragHandle from '@mui/icons-material/DragHandle';
|
||||
import { languageCodeToName } from '@/base/utils/Languages.ts';
|
||||
import { DEFAULT_FULL_FAB_HEIGHT } from '@/base/components/buttons/StyledFab.tsx';
|
||||
import Stack from '@mui/material/Stack';
|
||||
|
||||
const SourceCard = ({
|
||||
source,
|
||||
onToggle,
|
||||
isCurrentSource,
|
||||
isSelected,
|
||||
isDragging,
|
||||
}: {
|
||||
source: SourceItem;
|
||||
onToggle: (id: SourceIdInfo['id']) => void;
|
||||
isCurrentSource: boolean;
|
||||
isSelected: boolean;
|
||||
isDragging?: boolean;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<StyledGroupItemWrapper>
|
||||
<Card>
|
||||
<CardActionArea onClick={() => onToggle(source.id)}>
|
||||
<ListCardContent sx={{ justifyContent: 'space-between' }}>
|
||||
<Stack sx={{ flexFlow: 'row', gap: 1, alignItems: 'center' }}>
|
||||
<ListCardAvatar
|
||||
iconUrl={requestManager.getValidImgUrlFor(source.iconUrl)}
|
||||
alt={source.name}
|
||||
slots={{ spinnerImageProps: { ignoreQueue: true } }}
|
||||
/>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<Typography variant="h6" component="h3">
|
||||
{source.name}
|
||||
</Typography>
|
||||
<Typography variant="caption">{languageCodeToName(source.lang)}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack sx={{ flexDirection: 'row', gap: 4 }}>
|
||||
{isCurrentSource && (
|
||||
<Chip size="small" label={t`Current source`} color="primary" variant="outlined" />
|
||||
)}
|
||||
{isSelected && (
|
||||
<Box>
|
||||
<DragHandle sx={{ mr: 2, cursor: isDragging ? 'grabbing' : 'grab' }} />
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</ListCardContent>
|
||||
</CardActionArea>
|
||||
</Card>
|
||||
</StyledGroupItemWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const MigrationSourceList = ({
|
||||
sources,
|
||||
handleSelection,
|
||||
selectedSourceIds,
|
||||
handlePriorityChange,
|
||||
currentSourceId,
|
||||
}: {
|
||||
sources: SourceItem[];
|
||||
handleSelection: SelectableCollectionReturnType<SourceIdInfo['id'], 'default'>['handleSelection'];
|
||||
handlePriorityChange: (oldIndex: number, newIndex: number) => void;
|
||||
selectedSourceIds: SourceIdInfo['id'][];
|
||||
currentSourceId: SourceIdInfo['id'] | null;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
const dndSensors = DndKitUtil.useSensorsForDevice();
|
||||
const [dndActiveSource, setDndActiveSource] = useState<SourceItem | null>(null);
|
||||
|
||||
const selectedSources = useMemo(
|
||||
() =>
|
||||
selectedSourceIds.map((sourceId) => {
|
||||
const selectedSource = sources.find((source) => source.id === sourceId);
|
||||
|
||||
assertIsDefined(selectedSource);
|
||||
return selectedSource;
|
||||
}),
|
||||
[sources, selectedSourceIds],
|
||||
);
|
||||
const unselectedSources = useMemo(
|
||||
() => sources.filter((source) => !selectedSourceIds.includes(source.id)),
|
||||
[sources, selectedSourceIds],
|
||||
);
|
||||
const allSources = useMemo(() => [...selectedSources, ...unselectedSources], [selectedSources, unselectedSources]);
|
||||
const groupedSourcesBySelectionState = useMemo<[boolean, SourceItem[]][]>(
|
||||
() =>
|
||||
[selectedSources.length ? [true, selectedSources] : undefined, [false, unselectedSources]].filter(
|
||||
(entry) => entry !== undefined,
|
||||
) as [boolean, SourceItem[]][],
|
||||
[selectedSources, unselectedSources],
|
||||
);
|
||||
|
||||
const groupCounts = useMemo(
|
||||
() => groupedSourcesBySelectionState.map(([, sourcesOfGroup]) => sourcesOfGroup.length),
|
||||
[groupedSourcesBySelectionState],
|
||||
);
|
||||
const computeItemKey = VirtuosoUtil.useCreateGroupedComputeItemKey(
|
||||
groupCounts,
|
||||
useCallback(
|
||||
(index) => String(groupedSourcesBySelectionState[index][VirtuosoUtil.GROUP]),
|
||||
[groupedSourcesBySelectionState],
|
||||
),
|
||||
useCallback((index) => allSources[index].id, [groupedSourcesBySelectionState]),
|
||||
);
|
||||
|
||||
const handleToggle = useCallback(
|
||||
(id: SourceIdInfo['id']) => {
|
||||
handleSelection(id, !selectedSourceIds.includes(id));
|
||||
},
|
||||
[handleSelection, selectedSourceIds],
|
||||
);
|
||||
|
||||
const onDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setDndActiveSource(null);
|
||||
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldIndex = selectedSourceIds.indexOf(String(active.id));
|
||||
const newIndex = selectedSourceIds.indexOf(String(over.id));
|
||||
handlePriorityChange(oldIndex, newIndex);
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={dndSensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={(event) => setDndActiveSource(sources.find((source) => source.id === event.active.id) ?? null)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragCancel={() => setDndActiveSource(null)}
|
||||
onDragAbort={() => setDndActiveSource(null)}
|
||||
>
|
||||
<SortableContext items={selectedSources} strategy={verticalListSortingStrategy}>
|
||||
<StyledGroupedVirtuoso
|
||||
style={{ marginBottom: DEFAULT_FULL_FAB_HEIGHT }}
|
||||
persistKey="migration-source-selection"
|
||||
groupCounts={groupCounts}
|
||||
computeItemKey={computeItemKey}
|
||||
groupContent={(index) => {
|
||||
const [group] = groupedSourcesBySelectionState[index];
|
||||
|
||||
return (
|
||||
<StyledGroupHeader
|
||||
isFirstItem={!index}
|
||||
sx={{
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
pr: 1,
|
||||
}}
|
||||
>
|
||||
<Typography variant="h5" component="h2">
|
||||
{group ? t`Selected` : t`Available`}
|
||||
</Typography>
|
||||
|
||||
{group && !!selectedSources.length && (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{t`Drag to prioritize`}
|
||||
</Typography>
|
||||
)}
|
||||
</StyledGroupHeader>
|
||||
);
|
||||
}}
|
||||
itemContent={(index, groupIndex) => {
|
||||
const [isSelected] = groupedSourcesBySelectionState[groupIndex];
|
||||
const source = allSources[index];
|
||||
|
||||
const sourceCard = (
|
||||
<SourceCard
|
||||
source={source}
|
||||
onToggle={handleToggle}
|
||||
isCurrentSource={source.id === currentSourceId}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isSelected) {
|
||||
return (
|
||||
<DndSortableItem
|
||||
key={source.id}
|
||||
id={source.id}
|
||||
isDragging={source.id === dndActiveSource?.id}
|
||||
>
|
||||
{sourceCard}
|
||||
</DndSortableItem>
|
||||
);
|
||||
}
|
||||
|
||||
return sourceCard;
|
||||
}}
|
||||
/>
|
||||
</SortableContext>
|
||||
<DndOverlayItem isActive={!!dndActiveSource}>
|
||||
<SourceCard
|
||||
source={dndActiveSource!}
|
||||
onToggle={noOp}
|
||||
isCurrentSource={dndActiveSource?.id === currentSourceId}
|
||||
isSelected
|
||||
isDragging
|
||||
/>
|
||||
</DndOverlayItem>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,292 @@
|
||||
/*
|
||||
* 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 type { MangaIdInfo } from '@/features/manga/Manga.types.ts';
|
||||
import type { MigrationMatch, TMigrationEntry } from '@/features/migration/Migration.types.ts';
|
||||
import { MigrationEntryStatus, MigrationPhase } from '@/features/migration/Migration.types.ts';
|
||||
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
|
||||
import { MigrationEntryCard } from '@/features/migration/components/migration-entry/MigrationEntryCard.tsx';
|
||||
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
|
||||
import { MigrationEntryCardContent } from '@/features/migration/components/migration-entry/MigrationEntryCardContent.tsx';
|
||||
import { ENTRY_STATUS_TRANSLATION } from '@/features/migration/Migration.constants.ts';
|
||||
import { ReactRouter } from '@/lib/react-router/ReactRouter.ts';
|
||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
|
||||
import { extractGraphqlExceptionInfo } from '@/lib/HelperFunctions.ts';
|
||||
import { Confirmation } from '@/base/AppAwaitableComponent.ts';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { ListCardAvatar } from '@/base/components/lists/cards/ListCardAvatar.tsx';
|
||||
import { Mangas } from '@/features/manga/services/Mangas.ts';
|
||||
import { TypographyMaxLines } from '@/base/components/texts/TypographyMaxLines.tsx';
|
||||
import { MigrationEntryMetadataText } from '@/features/migration/components/migration-entry/MigrationEntryMetadataText.tsx';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import CardActions from '@mui/material/CardActions';
|
||||
import Button from '@mui/material/Button';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import { alpha } from '@mui/material/styles';
|
||||
import ReplayIcon from '@mui/icons-material/Replay';
|
||||
import Link from '@mui/material/Link';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
const EntryStatus = ({
|
||||
sourceMangaId,
|
||||
sourceMangaTitle,
|
||||
status,
|
||||
isMigrating,
|
||||
}: {
|
||||
sourceMangaId: MangaIdInfo['id'];
|
||||
sourceMangaTitle: string;
|
||||
status: MigrationEntryStatus;
|
||||
isMigrating: boolean;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<Stack sx={{ alignItems: 'center', justifyContent: 'center', gap: 2 }}>
|
||||
<Typography color={status === MigrationEntryStatus.NO_MATCH ? 'warning' : undefined}>
|
||||
{t(ENTRY_STATUS_TRANSLATION[status])}
|
||||
</Typography>
|
||||
{!isMigrating && status === MigrationEntryStatus.NO_MATCH && (
|
||||
<Button
|
||||
startIcon={<SearchIcon />}
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
ReactRouter.navigate(
|
||||
AppRoutes.migrate.childRoutes.manualSearch.path(sourceMangaId, sourceMangaTitle),
|
||||
{
|
||||
state: { mangaTitle: sourceMangaTitle },
|
||||
},
|
||||
);
|
||||
}}
|
||||
>{t`Manual search`}</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const EntryError = ({
|
||||
id,
|
||||
title,
|
||||
isMigrating,
|
||||
sourceMangaId,
|
||||
error,
|
||||
}: {
|
||||
sourceMangaId: MangaIdInfo['id'];
|
||||
isMigrating: boolean;
|
||||
} & Pick<TMigrationEntry, 'error'> &
|
||||
Pick<MigrationMatch, 'id' | 'title'>) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const { phase } = MigrationManager.getState();
|
||||
|
||||
const MAX_ERROR_LENGTH = 100;
|
||||
|
||||
const { isGraphqlException, graphqlError, graphqlStackTrace } = extractGraphqlExceptionInfo(error);
|
||||
const tmpError = isGraphqlException ? graphqlError : error;
|
||||
const isErrorTooLong = (tmpError?.length ?? 0) > MAX_ERROR_LENGTH;
|
||||
const finalError = isErrorTooLong ? tmpError?.slice(0, MAX_ERROR_LENGTH).concat('…') : tmpError;
|
||||
|
||||
const isSearchRetryable = status === MigrationEntryStatus.SEARCH_FAILED && phase === MigrationPhase.SEARCHING;
|
||||
const isMigrationRetryable = status === MigrationEntryStatus.MIGRATION_FAILED && phase === MigrationPhase.MIGRATING;
|
||||
|
||||
const isRetryable = isSearchRetryable || isMigrationRetryable;
|
||||
|
||||
return (
|
||||
<Stack sx={{ width: '100%', alignItems: 'center', justifyContent: 'center', gap: 2 }}>
|
||||
<Stack sx={{ flexDirection: 'row', alignItems: 'center', gap: 1 }}>
|
||||
<Typography color="error" title={finalError}>
|
||||
{finalError}
|
||||
</Typography>
|
||||
{(isErrorTooLong || (isGraphqlException && graphqlStackTrace)) && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
Confirmation.show({
|
||||
title: isMigrating ? t`Migration failed` : t`Search failed`,
|
||||
message: (
|
||||
<Stack sx={{ gap: 2 }}>
|
||||
{isMigrating ? (
|
||||
<Typography>{t`Migration for "${title}" failed with error:`}</Typography>
|
||||
) : (
|
||||
<Typography>{t`Search for "${title}" failed with error:`}</Typography>
|
||||
)}
|
||||
<Typography>{error}</Typography>
|
||||
</Stack>
|
||||
),
|
||||
actions: {
|
||||
cancel: { show: false },
|
||||
confirm: {
|
||||
title: t`Close`,
|
||||
},
|
||||
},
|
||||
}).catch(defaultPromiseErrorHandler(`MigrationEntryRow: ${id} - ${error}`));
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{t`Show more`}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
{isRetryable && (
|
||||
<Button
|
||||
sx={{ width: 'fit-content' }}
|
||||
variant="contained"
|
||||
color="error"
|
||||
startIcon={<ReplayIcon />}
|
||||
onClick={() =>
|
||||
MigrationManager.retryEntry(sourceMangaId).catch(
|
||||
defaultPromiseErrorHandler('MigrationEntryRow::retry'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t`Retry`}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const EntryData = (entry: MigrationMatch) => {
|
||||
const { id, title } = entry;
|
||||
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Link component={RouterLink} to={AppRoutes.manga.path(id)}>
|
||||
<ListCardAvatar
|
||||
iconUrl={Mangas.getThumbnailUrl(entry)}
|
||||
alt={title}
|
||||
slots={{
|
||||
avatarProps: {
|
||||
sx: {
|
||||
width: 'unset',
|
||||
height: 112,
|
||||
aspectRatio: '3 / 4',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
<Stack sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography
|
||||
variant="overline"
|
||||
color="textSecondary"
|
||||
>{t`Destination - ${entry.sourceTitle}`}</Typography>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={AppRoutes.manga.path(id)}
|
||||
sx={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<TypographyMaxLines variant="h6" component="h3" title={title}>
|
||||
{title}
|
||||
</TypographyMaxLines>
|
||||
</Link>
|
||||
<MigrationEntryMetadataText {...entry} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const MigrationDestinationEntry = ({
|
||||
sourceMangaId,
|
||||
sourceMangaTitle,
|
||||
entry,
|
||||
error,
|
||||
status,
|
||||
otherResultsCount,
|
||||
isExpanded,
|
||||
setIsExpanded,
|
||||
isMigrating,
|
||||
}: {
|
||||
sourceMangaId: MangaIdInfo['id'];
|
||||
sourceMangaTitle: string;
|
||||
entry: MigrationMatch | undefined;
|
||||
error?: string;
|
||||
status: MigrationEntryStatus;
|
||||
otherResultsCount: number;
|
||||
isExpanded: boolean;
|
||||
setIsExpanded: (expanded: boolean) => void;
|
||||
isMigrating: boolean;
|
||||
}) => {
|
||||
const isTabletWidth = MediaQuery.useIsTabletWidth();
|
||||
|
||||
return (
|
||||
<MigrationEntryCard
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
...applyStyles(!isTabletWidth, {
|
||||
width: '400px',
|
||||
height: '100%',
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<MigrationEntryCardContent>
|
||||
{(() => {
|
||||
if (!entry) {
|
||||
return (
|
||||
<EntryStatus
|
||||
sourceMangaId={sourceMangaId}
|
||||
sourceMangaTitle={sourceMangaTitle}
|
||||
status={status}
|
||||
isMigrating={isMigrating}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EntryError
|
||||
id={entry.id}
|
||||
title={entry.title}
|
||||
error={error}
|
||||
sourceMangaId={sourceMangaId}
|
||||
isMigrating={isMigrating}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <EntryData {...entry} />;
|
||||
})()}
|
||||
</MigrationEntryCardContent>
|
||||
{!isTabletWidth && !isMigrating && !!otherResultsCount && (
|
||||
<CardActions
|
||||
sx={{
|
||||
p: 0,
|
||||
backgroundColor: 'primary.dark',
|
||||
'&:hover': {
|
||||
backgroundColor: (theme) => alpha(theme.palette.primary.dark, 0.8),
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
sx={{
|
||||
width: '100%',
|
||||
borderRadius: 0,
|
||||
color: 'primary.contrastText',
|
||||
}}
|
||||
variant="text"
|
||||
startIcon={isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{plural(otherResultsCount, {
|
||||
one: '# more match',
|
||||
other: '# more matches',
|
||||
})}
|
||||
</Button>
|
||||
</CardActions>
|
||||
)}
|
||||
</MigrationEntryCard>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,241 @@
|
||||
/*
|
||||
* 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 type { MigrationMatch, TMigrationEntry } from '@/features/migration/Migration.types.ts';
|
||||
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import { useMemo } from 'react';
|
||||
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
|
||||
import { applyStyles } from '@/base/utils/ApplyStyles.ts';
|
||||
import { MigrationSourceEntry } from '@/features/migration/components/migration-entry/MigrationSourceEntry.tsx';
|
||||
import { MigrationDestinationEntry } from '@/features/migration/components/migration-entry/MigrationDestinationEntry.tsx';
|
||||
import { ReactRouter } from '@/lib/react-router/ReactRouter.ts';
|
||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||
import { MigrationMatchedEntry } from '@/features/migration/components/migration-entry/MigrationMatchedEntry.tsx';
|
||||
import { MigrationEntrySearchExcludeActions } from '@/features/migration/components/migration-entry/MigrationEntrySearchExcludeActions.tsx';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import Button from '@mui/material/Button';
|
||||
import Collapse from '@mui/material/Collapse';
|
||||
import Divider from '@mui/material/Divider';
|
||||
import { MigrationEntryStatusIndicator } from '@/features/migration/components/migration-entry/MigrationEntryStatusIndicator.tsx';
|
||||
import Box from '@mui/material/Box';
|
||||
|
||||
const MigrationEntryMobile = ({
|
||||
entry,
|
||||
entry: { mangaId, mangaTitle, status, error, isExcluded },
|
||||
destinationEntry,
|
||||
otherSearchMatches,
|
||||
isExpanded,
|
||||
setIsExpanded,
|
||||
isMigrating,
|
||||
}: {
|
||||
entry: TMigrationEntry;
|
||||
destinationEntry: MigrationMatch | undefined;
|
||||
otherSearchMatches: MigrationMatch[];
|
||||
isExpanded: boolean;
|
||||
setIsExpanded: (expanded: boolean) => void;
|
||||
isMigrating: boolean;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MigrationSourceEntry {...entry} />
|
||||
<MigrationDestinationEntry
|
||||
sourceMangaId={mangaId}
|
||||
sourceMangaTitle={mangaTitle}
|
||||
entry={destinationEntry}
|
||||
status={status}
|
||||
otherResultsCount={otherSearchMatches.length}
|
||||
isExpanded={isExpanded}
|
||||
setIsExpanded={setIsExpanded}
|
||||
isMigrating={isMigrating}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
<Collapse in={isExpanded} unmountOnExit>
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
<Stack sx={{ flexDirection: 'row', gap: 1, alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Typography variant="h6" component="h2">{t`Matches`}</Typography>
|
||||
<Button
|
||||
startIcon={<SearchIcon />}
|
||||
variant="text"
|
||||
onClick={() => {
|
||||
ReactRouter.navigate(AppRoutes.migrate.childRoutes.manualSearch.path(mangaId, mangaTitle), {
|
||||
state: { mangaTitle: mangaTitle },
|
||||
});
|
||||
}}
|
||||
>{t`Manual search`}</Button>
|
||||
</Stack>
|
||||
<Stack sx={{ pt: 2 }}>
|
||||
{otherSearchMatches.map((searchMatch) => {
|
||||
if (destinationEntry?.id === searchMatch.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MigrationMatchedEntry key={searchMatch.id} sourceMangaId={mangaId} entry={searchMatch} />
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
|
||||
{!isMigrating && (
|
||||
<MigrationEntrySearchExcludeActions
|
||||
hasResults={!!destinationEntry}
|
||||
otherResultsCount={otherSearchMatches.length}
|
||||
isExpanded={isExpanded}
|
||||
setIsExpanded={setIsExpanded}
|
||||
isExcluded={isExcluded}
|
||||
mangaId={mangaId}
|
||||
mangaTitle={mangaTitle}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const MigrationEntryDesktop = ({
|
||||
entry,
|
||||
entry: { mangaId, mangaTitle, status, error },
|
||||
destinationEntry,
|
||||
otherSearchMatches,
|
||||
isExpanded,
|
||||
setIsExpanded,
|
||||
isMigrating,
|
||||
}: {
|
||||
entry: TMigrationEntry;
|
||||
destinationEntry: MigrationMatch | undefined;
|
||||
otherSearchMatches: MigrationMatch[];
|
||||
isExpanded: boolean;
|
||||
setIsExpanded: (expanded: boolean) => void;
|
||||
isMigrating: boolean;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<MigrationSourceEntry {...entry} />
|
||||
|
||||
<Box sx={{ display: 'flex', alignItems: 'stretch', gap: 2 }}>
|
||||
<Box sx={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<MigrationEntryStatusIndicator status={status} hasResults={!!destinationEntry} />
|
||||
<MigrationDestinationEntry
|
||||
sourceMangaId={mangaId}
|
||||
sourceMangaTitle={mangaTitle}
|
||||
entry={destinationEntry}
|
||||
status={status}
|
||||
otherResultsCount={otherSearchMatches.length}
|
||||
isExpanded={isExpanded}
|
||||
setIsExpanded={setIsExpanded}
|
||||
isMigrating={isMigrating}
|
||||
error={error}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{!isMigrating && (
|
||||
<MigrationEntrySearchExcludeActions
|
||||
hasResults={!!destinationEntry}
|
||||
otherResultsCount={otherSearchMatches.length}
|
||||
isExpanded={isExpanded}
|
||||
setIsExpanded={setIsExpanded}
|
||||
isExcluded={entry.isExcluded}
|
||||
mangaId={entry.mangaId}
|
||||
mangaTitle={entry.mangaTitle}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Collapse in={isExpanded && !isMigrating} unmountOnExit>
|
||||
<Divider sx={{ my: 4 }} />
|
||||
<Typography variant="h6" component="h2">{t`Matches`}</Typography>
|
||||
<Stack sx={{ pt: 2 }}>
|
||||
{otherSearchMatches.map((searchMatch) => {
|
||||
if (destinationEntry?.id === searchMatch.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MigrationMatchedEntry
|
||||
key={searchMatch.id}
|
||||
sourceMangaId={entry.mangaId}
|
||||
entry={searchMatch}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const MigrationEntry = ({ entry: propEntry, isMigrating }: { entry: TMigrationEntry; isMigrating: boolean }) => {
|
||||
const isTabletWidth = MediaQuery.useIsTabletWidth();
|
||||
|
||||
const entry = useMemo(() => MigrationManager.getUpToDateMigrationEntry(propEntry), [propEntry]);
|
||||
|
||||
const destinationEntry = useMemo(() => {
|
||||
const match = entry.searchMatches.find((matchEntry) => matchEntry.id === entry.selectedMatchMangaId);
|
||||
const manualMatch = entry.manualMatches.find((matchEntry) => matchEntry.id === entry.selectedMatchMangaId);
|
||||
|
||||
return match ?? manualMatch;
|
||||
}, [entry.searchMatches, entry.selectedMatchMangaId]);
|
||||
const otherMatches = useMemo(
|
||||
() =>
|
||||
entry.searchMatches
|
||||
.filter((searchMatch) => searchMatch.id !== entry.selectedMatchMangaId)
|
||||
.sort((a, b) => (b.latestChapterNumber ?? 0) - (a.latestChapterNumber ?? 0)),
|
||||
[entry.searchMatches, entry.selectedMatchMangaId],
|
||||
);
|
||||
|
||||
const MigrationComponent = useMemo(
|
||||
() => (isTabletWidth ? MigrationEntryMobile : MigrationEntryDesktop),
|
||||
[isTabletWidth],
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
opacity: !isMigrating && entry.isExcluded ? 0.5 : 1,
|
||||
...applyStyles(isTabletWidth, {
|
||||
flexDirection: 'column',
|
||||
display: 'flex',
|
||||
p: 2,
|
||||
gap: 2,
|
||||
}),
|
||||
...applyStyles(!isTabletWidth, {
|
||||
px: 2,
|
||||
py: 2,
|
||||
pr: 6,
|
||||
}),
|
||||
}}
|
||||
>
|
||||
<MigrationComponent
|
||||
entry={entry}
|
||||
destinationEntry={destinationEntry}
|
||||
isExpanded={entry.areMatchesExpanded}
|
||||
setIsExpanded={() =>
|
||||
MigrationManager.setEntryMatchesExpandState(entry.mangaId, !entry.areMatchesExpanded)
|
||||
}
|
||||
otherSearchMatches={otherMatches}
|
||||
isMigrating={isMigrating}
|
||||
/>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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 { styled } from '@mui/material/styles';
|
||||
|
||||
export const MigrationEntryCard = styled(Card)(({ theme }) => ({
|
||||
backgroundColor: theme.palette.background.default,
|
||||
borderStyle: 'solid',
|
||||
borderWidth: 2,
|
||||
borderColor: theme.palette.divider,
|
||||
}));
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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 CardContent from '@mui/material/CardContent';
|
||||
import { styled } from '@mui/material/styles';
|
||||
|
||||
export const MigrationEntryCardContent = styled(CardContent)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: theme.spacing(2),
|
||||
padding: theme.spacing(2),
|
||||
'&:last-child': {
|
||||
paddingBottom: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 type { MigrationMatch } from '@/features/migration/Migration.types.ts';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
|
||||
export const MigrationEntryMetadataText = (
|
||||
entry: Pick<MigrationMatch, 'artist' | 'author' | 'latestChapterNumber'>,
|
||||
) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const latestChapterNumber = (entry.latestChapterNumber ?? 0) > 1 ? entry.latestChapterNumber : t`Unknown`;
|
||||
const latestChapter = t`Latest: ${latestChapterNumber}`;
|
||||
|
||||
const artist = entry.artist ? `${entry.artist} - ` : '';
|
||||
const author = entry.author ? `${entry.author} - ` : '';
|
||||
const isSameArtistAuthor = artist === author;
|
||||
const artistAuthor = isSameArtistAuthor ? artist : `${artist}${author}`;
|
||||
|
||||
return (
|
||||
<Typography variant="body2" color="textSecondary">
|
||||
{artistAuthor}
|
||||
{latestChapter}
|
||||
</Typography>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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 type { MangaIdInfo } from '@/features/manga/Manga.types.ts';
|
||||
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
|
||||
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
|
||||
import { CustomButtonIcon } from '@/base/components/buttons/CustomButtonIcon.tsx';
|
||||
import { ReactRouter } from '@/lib/react-router/ReactRouter.ts';
|
||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import Button from '@mui/material/Button';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
|
||||
import ExpandLessIcon from '@mui/icons-material/ExpandLess';
|
||||
import ButtonGroup from '@mui/material/ButtonGroup';
|
||||
|
||||
export const MigrationEntrySearchExcludeActions = ({
|
||||
hasResults,
|
||||
otherResultsCount,
|
||||
isExpanded,
|
||||
setIsExpanded,
|
||||
isExcluded,
|
||||
mangaId,
|
||||
mangaTitle,
|
||||
}: {
|
||||
hasResults: boolean;
|
||||
otherResultsCount: number;
|
||||
isExpanded: boolean;
|
||||
setIsExpanded: (expanded: boolean) => void;
|
||||
isExcluded: boolean;
|
||||
mangaId: MangaIdInfo['id'];
|
||||
mangaTitle: string;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
const isTabletWidth = MediaQuery.useIsTabletWidth();
|
||||
|
||||
if (isTabletWidth) {
|
||||
if (!hasResults) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup variant="contained">
|
||||
{!!otherResultsCount && (
|
||||
<Button
|
||||
sx={{ flexGrow: 1 }}
|
||||
startIcon={isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{plural(otherResultsCount, {
|
||||
one: '# more match',
|
||||
other: '# more matches',
|
||||
})}
|
||||
</Button>
|
||||
)}
|
||||
{!isExpanded && (
|
||||
<CustomTooltip title={t`Manual search`}>
|
||||
<CustomButtonIcon
|
||||
sx={{
|
||||
flexGrow: Number(!otherResultsCount),
|
||||
}}
|
||||
onClick={() => {
|
||||
ReactRouter.navigate(
|
||||
AppRoutes.migrate.childRoutes.manualSearch.path(mangaId, mangaTitle),
|
||||
{
|
||||
state: { mangaTitle: mangaTitle },
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
<SearchIcon />
|
||||
</CustomButtonIcon>
|
||||
</CustomTooltip>
|
||||
)}
|
||||
<CustomTooltip title={isExcluded ? t`Include` : t`Exclude`}>
|
||||
<CustomButtonIcon
|
||||
sx={{
|
||||
flexGrow: Number(!otherResultsCount),
|
||||
}}
|
||||
onClick={() =>
|
||||
isExcluded ? MigrationManager.includeManga(mangaId) : MigrationManager.excludeManga(mangaId)
|
||||
}
|
||||
>
|
||||
{isExcluded ? <AddIcon /> : <CloseIcon />}
|
||||
</CustomButtonIcon>
|
||||
</CustomTooltip>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack sx={{ gap: 1, justifyContent: 'center' }}>
|
||||
<CustomTooltip title={isExcluded ? t`Include` : t`Exclude`} placement="auto">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
isExcluded ? MigrationManager.includeManga(mangaId) : MigrationManager.excludeManga(mangaId)
|
||||
}
|
||||
>
|
||||
{isExcluded ? <AddIcon /> : <CloseIcon />}
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
<CustomTooltip title={t`Manual search`} placement="auto">
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
ReactRouter.navigate(AppRoutes.migrate.childRoutes.manualSearch.path(mangaId, mangaTitle));
|
||||
}}
|
||||
>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -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 { MediaQuery } from '@/base/utils/MediaQuery.tsx';
|
||||
import { MigrationEntryStatus } from '@/features/migration/Migration.types.ts';
|
||||
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
|
||||
import Box from '@mui/material/Box';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { Theme } from '@mui/material/styles';
|
||||
import type { SystemCssProperties } from '@mui/system/styleFunctionSx';
|
||||
import PriorityHighIcon from '@mui/icons-material/PriorityHigh';
|
||||
import CheckIcon from '@mui/icons-material/Check';
|
||||
|
||||
const StatusIndicatorWrapper = ({
|
||||
backgroundColor,
|
||||
statusIcon,
|
||||
}: {
|
||||
backgroundColor: SystemCssProperties<Theme>['backgroundColor'];
|
||||
statusIcon: ReactNode;
|
||||
}) => {
|
||||
const isTabletWidth = MediaQuery.useIsTabletWidth();
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
backgroundColor,
|
||||
p: isTabletWidth ? 0 : 0.5,
|
||||
m: 0,
|
||||
borderRadius: isTabletWidth ? 100 : 2,
|
||||
}}
|
||||
>
|
||||
{statusIcon}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
export const MigrationEntryStatusIndicator = ({
|
||||
status,
|
||||
hasResults,
|
||||
}: {
|
||||
status: MigrationEntryStatus;
|
||||
hasResults: boolean;
|
||||
}) => {
|
||||
const isTabletWidth = MediaQuery.useIsTabletWidth();
|
||||
|
||||
if ([MigrationEntryStatus.SEARCHING, MigrationEntryStatus.MIGRATING].includes(status)) {
|
||||
return <LoadingPlaceholder size={isTabletWidth ? 22 : 32} />;
|
||||
}
|
||||
|
||||
if ([MigrationEntryStatus.SEARCH_FAILED, MigrationEntryStatus.MIGRATION_FAILED].includes(status)) {
|
||||
return (
|
||||
<StatusIndicatorWrapper
|
||||
backgroundColor={(theme) => theme.palette.error.main}
|
||||
statusIcon={
|
||||
<PriorityHighIcon
|
||||
fontSize={isTabletWidth ? undefined : 'large'}
|
||||
sx={{ color: 'error.contrastText' }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === MigrationEntryStatus.NO_MATCH) {
|
||||
return (
|
||||
<StatusIndicatorWrapper
|
||||
backgroundColor={(theme) => theme.palette.warning.main}
|
||||
statusIcon={
|
||||
<PriorityHighIcon
|
||||
fontSize={isTabletWidth ? undefined : 'large'}
|
||||
sx={{ color: 'warning.contrastText' }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === MigrationEntryStatus.MIGRATION_COMPLETE) {
|
||||
return (
|
||||
<StatusIndicatorWrapper
|
||||
backgroundColor="primary.dark"
|
||||
statusIcon={
|
||||
<CheckIcon fontSize={isTabletWidth ? undefined : 'large'} sx={{ color: 'primary.contrastText' }} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasResults) {
|
||||
return (
|
||||
<StatusIndicatorWrapper
|
||||
backgroundColor="primary.dark"
|
||||
statusIcon={
|
||||
<ArrowForwardIcon
|
||||
fontSize={isTabletWidth ? undefined : 'large'}
|
||||
sx={{ color: 'primary.contrastText' }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* 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 type { MangaIdInfo } from '@/features/manga/Manga.types.ts';
|
||||
import type { MigrationMatch } from '@/features/migration/Migration.types.ts';
|
||||
import { MigrationEntryCard } from '@/features/migration/components/migration-entry/MigrationEntryCard.tsx';
|
||||
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
|
||||
import { MigrationEntryCardContent } from '@/features/migration/components/migration-entry/MigrationEntryCardContent.tsx';
|
||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||
import { ListCardAvatar } from '@/base/components/lists/cards/ListCardAvatar.tsx';
|
||||
import { Mangas } from '@/features/manga/services/Mangas.ts';
|
||||
import { TypographyMaxLines } from '@/base/components/texts/TypographyMaxLines.tsx';
|
||||
import { MigrationEntryMetadataText } from '@/features/migration/components/migration-entry/MigrationEntryMetadataText.tsx';
|
||||
import { MUIUtil } from '@/lib/mui/MUI.util.ts';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Button from '@mui/material/Button';
|
||||
import CardActionArea from '@mui/material/CardActionArea';
|
||||
import Link from '@mui/material/Link';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
export const MigrationMatchedEntry = ({
|
||||
sourceMangaId,
|
||||
entry,
|
||||
}: {
|
||||
sourceMangaId: MangaIdInfo['id'];
|
||||
entry: MigrationMatch;
|
||||
}) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
return (
|
||||
<MigrationEntryCard sx={{ mb: 1 }}>
|
||||
<CardActionArea onClick={() => MigrationManager.selectMatch(sourceMangaId, entry.id, entry.sourceId)}>
|
||||
<MigrationEntryCardContent>
|
||||
{(() => (
|
||||
<>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={AppRoutes.manga.path(entry.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ListCardAvatar
|
||||
iconUrl={Mangas.getThumbnailUrl(entry)}
|
||||
alt={entry.title}
|
||||
slots={{
|
||||
avatarProps: {
|
||||
sx: {
|
||||
width: 'unset',
|
||||
height: 80,
|
||||
aspectRatio: '3 / 4',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
<Stack sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="overline" color="textSecondary">
|
||||
{entry.sourceTitle}
|
||||
</Typography>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={AppRoutes.manga.path(entry.id)}
|
||||
sx={{ textDecoration: 'none', color: 'inherit', width: 'max-content' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<TypographyMaxLines variant="h6" component="h3" title={entry.title}>
|
||||
{entry.title}
|
||||
</TypographyMaxLines>
|
||||
</Link>
|
||||
<MigrationEntryMetadataText {...entry} />
|
||||
</Stack>
|
||||
</>
|
||||
))()}
|
||||
{(() => (
|
||||
<Button
|
||||
variant="outlined"
|
||||
{...MUIUtil.preventRippleProp({
|
||||
onClick: () => MigrationManager.selectMatch(sourceMangaId, entry.id, entry.sourceId),
|
||||
})}
|
||||
>{t`Select`}</Button>
|
||||
))()}
|
||||
</MigrationEntryCardContent>
|
||||
</CardActionArea>
|
||||
</MigrationEntryCard>
|
||||
);
|
||||
};
|
||||
@@ -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 type { TMigrationEntry } from '@/features/migration/Migration.types.ts';
|
||||
import { MediaQuery } from '@/base/utils/MediaQuery.tsx';
|
||||
import { TypographyMaxLines } from '@/base/components/texts/TypographyMaxLines.tsx';
|
||||
import { MigrationEntryMetadataText } from '@/features/migration/components/migration-entry/MigrationEntryMetadataText.tsx';
|
||||
import { MigrationEntryStatusIndicator } from '@/features/migration/components/migration-entry/MigrationEntryStatusIndicator.tsx';
|
||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||
import { ListCardAvatar } from '@/base/components/lists/cards/ListCardAvatar.tsx';
|
||||
import { Mangas } from '@/features/manga/services/Mangas.ts';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import Link from '@mui/material/Link';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
|
||||
export const MigrationSourceEntry = (entry: TMigrationEntry) => {
|
||||
const {
|
||||
mangaId,
|
||||
mangaThumbnailUrl,
|
||||
mangaTitle,
|
||||
sourceTitle,
|
||||
mangaArtist,
|
||||
mangaAuthor,
|
||||
latestChapterNumber,
|
||||
searchMatches,
|
||||
status,
|
||||
} = entry;
|
||||
|
||||
const { t } = useLingui();
|
||||
const isTabletWidth = MediaQuery.useIsTabletWidth();
|
||||
|
||||
if (isTabletWidth) {
|
||||
return (
|
||||
<Stack sx={{ flexDirection: 'row', gap: 1, alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
<Stack>
|
||||
<Typography variant="overline" color="textSecondary">{t`Source entry - ${sourceTitle}`}</Typography>
|
||||
<TypographyMaxLines variant="h6" component="h3" title={mangaTitle}>
|
||||
{mangaTitle}
|
||||
</TypographyMaxLines>
|
||||
<MigrationEntryMetadataText
|
||||
artist={mangaArtist}
|
||||
author={mangaAuthor}
|
||||
latestChapterNumber={latestChapterNumber}
|
||||
/>
|
||||
</Stack>
|
||||
<Box sx={{ display: 'flex', gap: 4, alignItems: 'center' }}>
|
||||
<MigrationEntryStatusIndicator status={status} hasResults={!!searchMatches.length} />
|
||||
</Box>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '400px' }}>
|
||||
<Link component={RouterLink} to={AppRoutes.manga.path(mangaId)}>
|
||||
<ListCardAvatar
|
||||
iconUrl={Mangas.getThumbnailUrl({ ...entry, thumbnailUrl: mangaThumbnailUrl })}
|
||||
alt={mangaTitle}
|
||||
slots={{
|
||||
avatarProps: {
|
||||
sx: {
|
||||
width: 'unset',
|
||||
height: 112,
|
||||
aspectRatio: '3 / 4',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Link>
|
||||
<Stack sx={{ minWidth: 0, flex: 1 }}>
|
||||
<Typography variant="overline" color="textSecondary">{t`Source entry - ${sourceTitle}`}</Typography>
|
||||
<Link
|
||||
component={RouterLink}
|
||||
to={AppRoutes.manga.path(mangaId)}
|
||||
sx={{ textDecoration: 'none', color: 'inherit' }}
|
||||
>
|
||||
<TypographyMaxLines variant="h6" component="h3" title={mangaTitle}>
|
||||
{mangaTitle}
|
||||
</TypographyMaxLines>
|
||||
</Link>
|
||||
<MigrationEntryMetadataText
|
||||
artist={mangaArtist}
|
||||
author={mangaAuthor}
|
||||
latestChapterNumber={latestChapterNumber}
|
||||
/>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) Contributors to the Suwayomi project
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import type { TMigratableSource } from '@/features/migration/components/MigrationCard.tsx';
|
||||
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
|
||||
import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewAbsoluteCentered.tsx';
|
||||
import { GridLayouts } from '@/base/components/GridLayouts.tsx';
|
||||
import { useLocalStorage } from '@/base/hooks/useStorage.tsx';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import type { GetSourceMigratableQuery, GetSourceMigratableQueryVariables } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { GET_SOURCE_MIGRATABLE } from '@/lib/graphql/source/SourceQuery.ts';
|
||||
import { SOURCE_BASE_FIELDS } from '@/lib/graphql/source/SourceFragments.ts';
|
||||
import { BaseMangaGrid } from '@/features/manga/components/BaseMangaGrid.tsx';
|
||||
import { GridLayout } from '@/base/Base.types.ts';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
import { useAppTitleAndAction } from '@/features/navigation-bar/hooks/useAppTitleAndAction.ts';
|
||||
|
||||
export const Migrate = () => {
|
||||
const { t } = useLingui();
|
||||
|
||||
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: SOURCE_BASE_FIELDS,
|
||||
});
|
||||
|
||||
const [isKnownSource, setIsKnownSource] = useState(fragmentSource !== null ? true : undefined);
|
||||
const {
|
||||
data: migratableSourceData,
|
||||
loading: isSourceLoading,
|
||||
error: sourceError,
|
||||
refetch: refetchSource,
|
||||
} = requestManager.useGetSource<GetSourceMigratableQuery, GetSourceMigratableQueryVariables>(
|
||||
GET_SOURCE_MIGRATABLE,
|
||||
paramSourceId,
|
||||
{ skip: !!isKnownSource, notifyOnNetworkStatusChange: true },
|
||||
);
|
||||
|
||||
const { sourceId, name } = {
|
||||
sourceId: paramSourceId,
|
||||
name: paramSourceId,
|
||||
...fragmentSource,
|
||||
...migratableSourceData?.source,
|
||||
};
|
||||
|
||||
const {
|
||||
data: migratableSourceMangasData,
|
||||
loading: areMangasLoading,
|
||||
error: mangasError,
|
||||
refetch: refetchMangas,
|
||||
} = requestManager.useGetMigratableSourceMangas(sourceId, {
|
||||
skip: !isKnownSource,
|
||||
notifyOnNetworkStatusChange: true,
|
||||
});
|
||||
|
||||
useAppTitleAndAction(
|
||||
name ?? sourceId ?? t`Migrate`,
|
||||
<GridLayouts gridLayout={gridLayout} onChange={setGridLayout} />,
|
||||
[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 (
|
||||
<EmptyViewAbsoluteCentered
|
||||
message={t`Unable to load data`}
|
||||
messageExtra={getErrorMessage(error)}
|
||||
retry={() => {
|
||||
if (hasErrorSource) {
|
||||
refetchSource().catch(defaultPromiseErrorHandler('Migrate::refetchSource'));
|
||||
}
|
||||
|
||||
if (mangasError) {
|
||||
refetchMangas().catch(defaultPromiseErrorHandler('Migrate::refetchMangas'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BaseMangaGrid
|
||||
hasNextPage={false}
|
||||
loadMore={() => {}}
|
||||
isLoading={areMangasLoading}
|
||||
mangas={migratableSourceMangasData?.mangas.nodes ?? []}
|
||||
gridLayout={gridLayout}
|
||||
mode="migrate.search"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -6,156 +6,59 @@
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import List from '@mui/material/List';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import SortByAlphaIcon from '@mui/icons-material/SortByAlpha';
|
||||
import TagIcon from '@mui/icons-material/Tag';
|
||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
|
||||
import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewAbsoluteCentered.tsx';
|
||||
import type { TMigratableSource } from '@/features/migration/components/MigrationCard.tsx';
|
||||
import { MigrationCard } from '@/features/migration/components/MigrationCard.tsx';
|
||||
import { StyledGroupItemWrapper } from '@/base/components/virtuoso/StyledGroupItemWrapper.tsx';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import type { SortSettings, TMigratableSourcesResult } from '@/features/migration/Migration.types.ts';
|
||||
import { SortBy, SortOrder } from '@/features/migration/Migration.types.ts';
|
||||
import { sortByToTranslation, sortOrderToTranslation } from '@/features/migration/Migration.constants.ts';
|
||||
import {
|
||||
createUpdateMetadataServerSettings,
|
||||
useMetadataServerSettings,
|
||||
} from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||
import { makeToast } from '@/base/utils/Toast.ts';
|
||||
import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { MigrationPhase } from '@/features/migration/Migration.types.ts';
|
||||
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
|
||||
import { MigrationSelectSource } from '@/features/migration/screens/MigrationSelectSource.tsx';
|
||||
import { MigrationSelectMangas } from '@/features/migration/screens/MigrationSelectMangas.tsx';
|
||||
import { MigrationSelectDestinationSources } from '@/features/migration/screens/MigrationSelectDestinationSources.tsx';
|
||||
import { MigrationSearch } from '@/features/migration/screens/MigrationSearch.tsx';
|
||||
import { MigrationExecute } from '@/features/migration/screens/MigrationExecute.tsx';
|
||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||
import { BrowseTab } from '@/features/browse/Browse.types.ts';
|
||||
import { useAppPageHistoryContext } from '@/base/contexts/AppPageHistoryContext.tsx';
|
||||
import { useEffect } from 'react';
|
||||
import { ReactRouter } from '@/lib/react-router/ReactRouter.ts';
|
||||
|
||||
const getMigratableSources = (
|
||||
mangas: TMigratableSourcesResult | undefined,
|
||||
{ sortBy, sortOrder }: SortSettings,
|
||||
): TMigratableSource[] => {
|
||||
if (!mangas) {
|
||||
return [];
|
||||
}
|
||||
export const Migration = ({ tabsMenuHeight = 0 }: { tabsMenuHeight?: number }) => {
|
||||
const phase = MigrationManager.usePhase();
|
||||
const { setOnBack } = useAppPageHistoryContext();
|
||||
|
||||
const sourceBySourceId: Record<string, TMigratableSource> = {};
|
||||
useEffect(() => {
|
||||
if (window.location.pathname !== AppRoutes.migrate.path) {
|
||||
if (!MigrationManager.isActive()) {
|
||||
MigrationManager.reset();
|
||||
} else {
|
||||
ReactRouter.navigate(AppRoutes.migrate.path);
|
||||
}
|
||||
|
||||
mangas.forEach(({ sourceId, source }) => {
|
||||
const uniqueSource = sourceBySourceId[sourceId] ?? {
|
||||
...{ id: sourceId, name: sourceId, lang: 'unknown', iconUrl: null, mangaCount: 0, ...source },
|
||||
};
|
||||
|
||||
sourceBySourceId[sourceId] = {
|
||||
...uniqueSource,
|
||||
mangaCount: uniqueSource.mangaCount + 1,
|
||||
};
|
||||
});
|
||||
|
||||
const sourcesSortedBy = Object.values(sourceBySourceId).toSorted((a, b) => {
|
||||
switch (sortBy) {
|
||||
case SortBy.SOURCE_NAME:
|
||||
return a.name.localeCompare(b.name);
|
||||
case SortBy.MANGA_COUNT:
|
||||
return a.mangaCount - b.mangaCount;
|
||||
default:
|
||||
throw new Error(`Unexpected "sortBy" "${sortBy}"`);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
switch (sortOrder) {
|
||||
case SortOrder.ASC:
|
||||
return sourcesSortedBy;
|
||||
case SortOrder.DESC:
|
||||
return sourcesSortedBy.toReversed();
|
||||
setOnBack(() => MigrationManager.goToPreviousPhase());
|
||||
|
||||
return () => {
|
||||
setOnBack(null);
|
||||
};
|
||||
}, []);
|
||||
|
||||
switch (phase) {
|
||||
case MigrationPhase.IDLE:
|
||||
case MigrationPhase.SELECT_SOURCE:
|
||||
return <MigrationSelectSource tabsMenuHeight={tabsMenuHeight} />;
|
||||
case MigrationPhase.SELECT_MANGAS:
|
||||
return <MigrationSelectMangas />;
|
||||
case MigrationPhase.SELECTING_SOURCES:
|
||||
return <MigrationSelectDestinationSources />;
|
||||
case MigrationPhase.SEARCHING:
|
||||
return <MigrationSearch />;
|
||||
case MigrationPhase.MIGRATING:
|
||||
return <MigrationExecute />;
|
||||
// @ts-ignore - fall through
|
||||
case MigrationPhase.ABORTED:
|
||||
MigrationManager.reset();
|
||||
// fall through
|
||||
default:
|
||||
throw new Error(`Unexpected "sortOrder" "${sortOrder}"`);
|
||||
return <Navigate to={AppRoutes.browse.path(BrowseTab.MIGRATE)} replace />;
|
||||
}
|
||||
};
|
||||
|
||||
export const Migration = ({ tabsMenuHeight }: { tabsMenuHeight: number }) => {
|
||||
const { t } = useLingui();
|
||||
const { appBarHeight } = useNavBarContext();
|
||||
|
||||
const {
|
||||
settings: { migrateSortSettings },
|
||||
} = useMetadataServerSettings();
|
||||
const updateMetadataServerSettings = createUpdateMetadataServerSettings<'migrateSortSettings'>((e) =>
|
||||
makeToast(t`Failed to save changes`, 'error', getErrorMessage(e)),
|
||||
);
|
||||
const { sortBy, sortOrder } = migrateSortSettings;
|
||||
|
||||
const { data, loading, error, refetch } = requestManager.useGetMigratableSources({
|
||||
notifyOnNetworkStatusChange: true,
|
||||
});
|
||||
const migratableSources = useMemo(
|
||||
() => getMigratableSources(data?.mangas.nodes, migrateSortSettings),
|
||||
[data?.mangas.nodes, migrateSortSettings],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingPlaceholder />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyViewAbsoluteCentered
|
||||
message={t`Unable to load data`}
|
||||
messageExtra={getErrorMessage(error)}
|
||||
retry={() => refetch().catch(defaultPromiseErrorHandler('Migration::refetch'))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
top: `${appBarHeight + tabsMenuHeight}px`,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'end',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
p: 1,
|
||||
backgroundColor: 'background.default',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<CustomTooltip title={t(sortByToTranslation[sortBy])}>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={() =>
|
||||
updateMetadataServerSettings('migrateSortSettings', { sortBy: (sortBy + 1) % 2, sortOrder })
|
||||
}
|
||||
>
|
||||
{sortBy ? <TagIcon /> : <SortByAlphaIcon />}
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
<CustomTooltip title={t(sortOrderToTranslation[sortOrder])}>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={() =>
|
||||
updateMetadataServerSettings('migrateSortSettings', {
|
||||
sortBy,
|
||||
sortOrder: (sortOrder + 1) % 2,
|
||||
})
|
||||
}
|
||||
>
|
||||
{sortOrder ? <ArrowDownwardIcon /> : <ArrowUpwardIcon />}
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
</Stack>
|
||||
<List sx={{ p: 0 }}>
|
||||
{migratableSources.map((migratableSource) => (
|
||||
<StyledGroupItemWrapper key={migratableSource.id}>
|
||||
<MigrationCard {...migratableSource} />
|
||||
</StyledGroupItemWrapper>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
123
src/features/migration/screens/MigrationExecute.tsx
Normal file
123
src/features/migration/screens/MigrationExecute.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* 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 Stack from '@mui/material/Stack';
|
||||
import { MigrationProgressBar } from '@/features/migration/components/MigrationProgressBar.tsx';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { useAppTitleAndAction } from '@/features/navigation-bar/hooks/useAppTitleAndAction.ts';
|
||||
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
|
||||
import { MigrationEntryStatus } from '@/features/migration/Migration.types.ts';
|
||||
import { useMemo } from 'react';
|
||||
import { DEFAULT_FULL_FAB_HEIGHT } from '@/base/components/buttons/StyledFab.tsx';
|
||||
import { MigrationContinueButton } from '@/features/migration/components/MigrationContinueButton.tsx';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
import { MigrationEntryGroup } from '@/features/migration/components/MIgrationEntryGroup.tsx';
|
||||
|
||||
export const MigrationExecute = () => {
|
||||
const { t } = useLingui();
|
||||
const entries = MigrationManager.useEntries();
|
||||
const progress = MigrationManager.useMigrationProgress();
|
||||
|
||||
useAppTitleAndAction(MigrationManager.isPhaseComplete() ? t`Migration complete` : t`Migrating`, undefined, [
|
||||
MigrationManager.isPhaseComplete(),
|
||||
]);
|
||||
|
||||
const entryList = useMemo(() => Object.values(entries), [entries]);
|
||||
const migratingEntries = useMemo(
|
||||
() =>
|
||||
entryList.filter((entry) =>
|
||||
[MigrationEntryStatus.PENDING, MigrationEntryStatus.MIGRATING].includes(entry.status),
|
||||
),
|
||||
[entryList],
|
||||
);
|
||||
const migratedEntries = useMemo(
|
||||
() => entryList.filter((entry) => entry.status === MigrationEntryStatus.MIGRATION_COMPLETE),
|
||||
[entryList],
|
||||
);
|
||||
const failedEntries = useMemo(
|
||||
() => entryList.filter((entry) => entry.status === MigrationEntryStatus.MIGRATION_FAILED),
|
||||
[entryList],
|
||||
);
|
||||
const excludedEntries = useMemo(() => entryList.filter((entry) => entry.isExcluded), [entryList]);
|
||||
const noMatchEntries = useMemo(
|
||||
() => entryList.filter((entry) => entry.status === MigrationEntryStatus.NO_MATCH),
|
||||
[entryList],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MigrationProgressBar
|
||||
{...progress}
|
||||
label={t`${progress.completed} / ${progress.total}${progress.failed > 0 ? ` (${progress.failed} failed)` : ''}`}
|
||||
/>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ p: 2, pb: DEFAULT_FULL_FAB_HEIGHT, gap: 4, flexWrap: 'wrap', justifyContent: 'center' }}
|
||||
>
|
||||
<MigrationEntryGroup
|
||||
status={MigrationEntryStatus.MIGRATING}
|
||||
title={plural(migratingEntries.length, {
|
||||
one: '1 migrating entry',
|
||||
other: '# migrating entries',
|
||||
})}
|
||||
entries={migratingEntries}
|
||||
isMigrating
|
||||
color="error"
|
||||
/>
|
||||
<MigrationEntryGroup
|
||||
status={MigrationEntryStatus.MIGRATION_FAILED}
|
||||
title={plural(failedEntries.length, {
|
||||
one: '1 failed entry',
|
||||
other: '# failed entries',
|
||||
})}
|
||||
entries={failedEntries}
|
||||
isMigrating
|
||||
color="error"
|
||||
/>
|
||||
<MigrationEntryGroup
|
||||
status={MigrationEntryStatus.NO_MATCH}
|
||||
title={plural(noMatchEntries.length, {
|
||||
one: '1 entry with no match',
|
||||
other: '# entries with no match',
|
||||
})}
|
||||
entries={noMatchEntries}
|
||||
color="warning"
|
||||
isMigrating
|
||||
/>
|
||||
<MigrationEntryGroup
|
||||
status={MigrationEntryStatus.EXCLUDED}
|
||||
title={plural(excludedEntries.length, {
|
||||
one: '1 excluded entry',
|
||||
other: '# excluded entries',
|
||||
})}
|
||||
entries={excludedEntries}
|
||||
color="info"
|
||||
isMigrating
|
||||
/>
|
||||
<MigrationEntryGroup
|
||||
status={MigrationEntryStatus.MIGRATION_COMPLETE}
|
||||
title={plural(migratedEntries.length, {
|
||||
one: '1 migrated entry',
|
||||
other: '# migrated entries',
|
||||
})}
|
||||
entries={migratedEntries}
|
||||
color="success"
|
||||
isMigrating
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<MigrationContinueButton
|
||||
title={MigrationManager.isPhaseComplete() ? t`Done` : t`Abort`}
|
||||
onClick={() =>
|
||||
MigrationManager.isPhaseComplete() ? MigrationManager.reset() : MigrationManager.abort()
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
14
src/features/migration/screens/MigrationManualSearch.tsx
Normal file
14
src/features/migration/screens/MigrationManualSearch.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* 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 { SearchAll } from '@/features/global-search/screens/SearchAll.tsx';
|
||||
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
|
||||
|
||||
export const MigrationManualSearch = () => (
|
||||
<SearchAll migrationDestinationSourceIds={MigrationManager.getState().destinationSourceIds} />
|
||||
);
|
||||
115
src/features/migration/screens/MigrationSearch.tsx
Normal file
115
src/features/migration/screens/MigrationSearch.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* 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 { useLingui } from '@lingui/react/macro';
|
||||
import { useAppTitleAndAction } from '@/features/navigation-bar/hooks/useAppTitleAndAction.ts';
|
||||
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
|
||||
import { MigrationProgressBar } from '@/features/migration/components/MigrationProgressBar.tsx';
|
||||
import { MigrationOptionsDialog } from '@/features/migration/components/MigrationOptionsDialog.tsx';
|
||||
import { MigrationContinueButton } from '@/features/migration/components/MigrationContinueButton.tsx';
|
||||
import { AwaitableComponent } from 'awaitable-component';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { DEFAULT_FULL_FAB_HEIGHT } from '@/base/components/buttons/StyledFab.tsx';
|
||||
import { useMemo } from 'react';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { MigrationEntryStatus } from '@/features/migration/Migration.types.ts';
|
||||
import { MigrationEntryGroup } from '@/features/migration/components/MIgrationEntryGroup.tsx';
|
||||
import { plural } from '@lingui/core/macro';
|
||||
|
||||
export const MigrationSearch = () => {
|
||||
const { t } = useLingui();
|
||||
const entries = MigrationManager.useEntries();
|
||||
const searchProgress = MigrationManager.useSearchProgress();
|
||||
|
||||
const isSearchComplete = searchProgress.completed === searchProgress.total && searchProgress.total > 0;
|
||||
|
||||
useAppTitleAndAction(t`Search results`, undefined, []);
|
||||
|
||||
const entryList = useMemo(() => Object.values(entries), [entries]);
|
||||
const searchingEntries = useMemo(
|
||||
() => entryList.filter((entry) => entry.status === MigrationEntryStatus.SEARCHING),
|
||||
[entryList],
|
||||
);
|
||||
const failedEntries = useMemo(
|
||||
() => entryList.filter((entry) => entry.status === MigrationEntryStatus.SEARCH_FAILED),
|
||||
[entryList],
|
||||
);
|
||||
const noMatchEntries = useMemo(
|
||||
() => entryList.filter((entry) => entry.status === MigrationEntryStatus.NO_MATCH),
|
||||
[entryList],
|
||||
);
|
||||
const matchedEntries = useMemo(
|
||||
() => entryList.filter((entry) => entry.status === MigrationEntryStatus.SEARCH_COMPLETE),
|
||||
[entryList],
|
||||
);
|
||||
|
||||
const hasMigratableEntries = useMemo(() => !!MigrationManager.getMigratableEntries().length, [entryList]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<MigrationProgressBar
|
||||
{...searchProgress}
|
||||
label={t`${searchProgress.completed} / ${searchProgress.total}`}
|
||||
/>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{ p: 2, pb: DEFAULT_FULL_FAB_HEIGHT, gap: 4, flexWrap: 'wrap', justifyContent: 'center' }}
|
||||
>
|
||||
<MigrationEntryGroup
|
||||
status={MigrationEntryStatus.SEARCHING}
|
||||
title={plural(searchingEntries.length, {
|
||||
one: '1 searching',
|
||||
other: '# searching',
|
||||
})}
|
||||
entries={searchingEntries}
|
||||
color="error"
|
||||
/>
|
||||
<MigrationEntryGroup
|
||||
status={MigrationEntryStatus.SEARCH_FAILED}
|
||||
title={plural(failedEntries.length, {
|
||||
one: '1 failed entry',
|
||||
other: '# failed entries',
|
||||
})}
|
||||
entries={failedEntries}
|
||||
color="error"
|
||||
/>
|
||||
<MigrationEntryGroup
|
||||
status={MigrationEntryStatus.NO_MATCH}
|
||||
title={plural(noMatchEntries.length, {
|
||||
one: '1 entry with no match',
|
||||
other: '# entries with no match',
|
||||
})}
|
||||
entries={noMatchEntries}
|
||||
color="warning"
|
||||
/>
|
||||
<MigrationEntryGroup
|
||||
status={MigrationEntryStatus.SEARCH_COMPLETE}
|
||||
title={plural(matchedEntries.length, {
|
||||
one: '1 matched entry',
|
||||
other: '# matched entries',
|
||||
})}
|
||||
entries={matchedEntries}
|
||||
color="success"
|
||||
/>
|
||||
</Stack>
|
||||
<MigrationContinueButton
|
||||
title={t`Start migration`}
|
||||
isDisabled={!isSearchComplete || !hasMigratableEntries}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const options = await AwaitableComponent.show(MigrationOptionsDialog);
|
||||
await MigrationManager.startMigration(options);
|
||||
} catch (e) {
|
||||
defaultPromiseErrorHandler('MigrationSearch')(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* 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 { useLingui } from '@lingui/react/macro';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
|
||||
import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewAbsoluteCentered.tsx';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
|
||||
import { MigrationSourceList } from '@/features/migration/components/MigrationSourceList.tsx';
|
||||
import { useSelectableCollection } from '@/base/collection/hooks/useSelectableCollection.ts';
|
||||
import type { SourceIdInfo } from '@/features/source/Source.types.ts';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { STABLE_EMPTY_ARRAY } from '@/base/Base.constants.ts';
|
||||
import { arrayMove } from '@dnd-kit/sortable';
|
||||
import Fab from '@mui/material/Fab';
|
||||
import { useAppTitleAndAction } from '@/features/navigation-bar/hooks/useAppTitleAndAction.ts';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import { SelectableCollectionSelectMode } from '@/base/collection/components/SelectableCollectionSelectMode.tsx';
|
||||
import { Sources } from '@/features/source/services/Sources.ts';
|
||||
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
|
||||
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
|
||||
|
||||
export const MigrationSelectDestinationSources = () => {
|
||||
const { t } = useLingui();
|
||||
const currentSourceId = MigrationManager.useSourceId();
|
||||
|
||||
const {
|
||||
settings: { browseLanguages, showNsfw },
|
||||
loading: areSettingsLoading,
|
||||
request: { error: settingsError, refetch: refetchSettings },
|
||||
} = useMetadataServerSettings();
|
||||
|
||||
const {
|
||||
data,
|
||||
loading: areSourcesLoading,
|
||||
error: sourceError,
|
||||
refetch: refetchSources,
|
||||
} = requestManager.useGetSourceList({
|
||||
notifyOnNetworkStatusChange: true,
|
||||
});
|
||||
|
||||
const allSources = data?.sources.nodes ?? STABLE_EMPTY_ARRAY;
|
||||
const sources = useMemo(
|
||||
() =>
|
||||
Sources.filter(allSources, {
|
||||
languages: browseLanguages,
|
||||
isNsfw: showNsfw ? undefined : false,
|
||||
}),
|
||||
[allSources, browseLanguages, showNsfw],
|
||||
);
|
||||
const sourceIds = useMemo(() => Sources.getIds(sources), [sources]);
|
||||
const pinnedSourceIds = useMemo(() => Sources.getIds(Sources.filter(sources, { pinned: true })), [sources]);
|
||||
const enabledSourceIds = useMemo(() => Sources.getIds(Sources.filter(sources, { enabled: true })), [sources]);
|
||||
|
||||
const {
|
||||
selectedItemIds,
|
||||
areAllItemsSelected,
|
||||
areNoItemsSelected,
|
||||
handleSelectAll,
|
||||
setSelectionForKey,
|
||||
handleSelection,
|
||||
} = useSelectableCollection<SourceIdInfo['id']>(sources.length ?? 0, {
|
||||
currentKey: 'default',
|
||||
initialState: useMemo(
|
||||
() => ({
|
||||
default: pinnedSourceIds,
|
||||
}),
|
||||
[pinnedSourceIds],
|
||||
),
|
||||
});
|
||||
|
||||
useAppTitleAndAction(
|
||||
t`Select destination sources`,
|
||||
<>
|
||||
<CustomTooltip title={t`Select pinned sources`}>
|
||||
<IconButton color="inherit" onClick={() => setSelectionForKey('default', pinnedSourceIds)}>
|
||||
<PushPinIcon />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
<CustomTooltip title={t`Select enabled sources`}>
|
||||
<IconButton color="inherit" onClick={() => setSelectionForKey('default', enabledSourceIds)}>
|
||||
<ToggleOnIcon />
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
<SelectableCollectionSelectMode
|
||||
isActive
|
||||
isCancelable={false}
|
||||
areAllItemsSelected={areAllItemsSelected}
|
||||
areNoItemsSelected={areNoItemsSelected}
|
||||
onSelectAll={(selectAll) =>
|
||||
handleSelectAll(selectAll, [...new Set([...selectedItemIds, ...sourceIds])])
|
||||
}
|
||||
onModeChange={(checked) => {
|
||||
handleSelectAll(checked, [...new Set([...selectedItemIds, ...sourceIds])]);
|
||||
}}
|
||||
/>
|
||||
</>,
|
||||
[
|
||||
setSelectionForKey,
|
||||
selectedItemIds,
|
||||
pinnedSourceIds,
|
||||
enabledSourceIds,
|
||||
areAllItemsSelected,
|
||||
areNoItemsSelected,
|
||||
handleSelectAll,
|
||||
sourceIds,
|
||||
],
|
||||
);
|
||||
|
||||
const handlePriorityChange = useCallback(
|
||||
(oldIndex: number, newIndex: number) => {
|
||||
setSelectionForKey('default', arrayMove(selectedItemIds, oldIndex, newIndex));
|
||||
},
|
||||
[selectedItemIds, setSelectionForKey],
|
||||
);
|
||||
|
||||
const loading = areSourcesLoading || areSettingsLoading;
|
||||
if (loading) {
|
||||
return <LoadingPlaceholder />;
|
||||
}
|
||||
|
||||
const error = settingsError ?? sourceError;
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyViewAbsoluteCentered
|
||||
message={t`Unable to load sources`}
|
||||
messageExtra={getErrorMessage(error)}
|
||||
retry={() => {
|
||||
if (settingsError) {
|
||||
refetchSettings().catch(
|
||||
defaultPromiseErrorHandler('MigrationSelectingSources::refetchSettings'),
|
||||
);
|
||||
}
|
||||
|
||||
if (sourceError) {
|
||||
refetchSources().catch(defaultPromiseErrorHandler('MigrationSelectingSources::refetchSources'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MigrationSourceList
|
||||
sources={sources}
|
||||
selectedSourceIds={selectedItemIds}
|
||||
handleSelection={handleSelection}
|
||||
handlePriorityChange={handlePriorityChange}
|
||||
currentSourceId={currentSourceId}
|
||||
/>
|
||||
<Fab
|
||||
variant="extended"
|
||||
color="primary"
|
||||
sx={{
|
||||
position: 'fixed',
|
||||
bottom: (theme) => theme.spacing(2),
|
||||
right: (theme) => theme.spacing(2),
|
||||
}}
|
||||
onClick={() => MigrationManager.startSearch(selectedItemIds)}
|
||||
>
|
||||
{t`Start Search`}
|
||||
</Fab>
|
||||
</>
|
||||
);
|
||||
};
|
||||
171
src/features/migration/screens/MigrationSelectMangas.tsx
Normal file
171
src/features/migration/screens/MigrationSelectMangas.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* 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 { useMemo } from 'react';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
|
||||
import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewAbsoluteCentered.tsx';
|
||||
import { BaseMangaGrid } from '@/features/manga/components/BaseMangaGrid.tsx';
|
||||
import { GridLayout } from '@/base/Base.types.ts';
|
||||
import { getErrorMessage, noOp } from '@/lib/HelperFunctions.ts';
|
||||
import { useAppTitleAndAction } from '@/features/navigation-bar/hooks/useAppTitleAndAction.ts';
|
||||
import { GridLayouts } from '@/base/components/GridLayouts.tsx';
|
||||
import { useLocalStorage } from '@/base/hooks/useStorage.tsx';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
|
||||
import { useSelectableCollection } from '@/base/collection/hooks/useSelectableCollection.ts';
|
||||
import type { GetSourceMigratableQuery, GetSourceMigratableQueryVariables } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { GET_SOURCE_MIGRATABLE } from '@/lib/graphql/source/SourceQuery.ts';
|
||||
import { SelectableCollectionSelectMode } from '@/base/collection/components/SelectableCollectionSelectMode.tsx';
|
||||
import { Mangas } from '@/features/manga/services/Mangas.ts';
|
||||
import { STABLE_EMPTY_ARRAY } from '@/base/Base.constants.ts';
|
||||
import { MigrationContinueButton } from '@/features/migration/components/MigrationContinueButton.tsx';
|
||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||
import { ReactRouter } from '@/lib/react-router/ReactRouter.ts';
|
||||
|
||||
const getSourceError = (error: unknown): unknown => {
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
if (
|
||||
message.includes(
|
||||
"The field at path '/source' was declared as a non null type, but the code involved in retrieving data has wrongly returned a null value",
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return error;
|
||||
};
|
||||
|
||||
export const MigrationSelectMangas = () => {
|
||||
const { t } = useLingui();
|
||||
const sourceId = MigrationManager.useSourceId();
|
||||
const selectedMangas = MigrationManager.useEntries();
|
||||
|
||||
const [gridLayout, setGridLayout] = useLocalStorage('migrateGridLayout', GridLayout.List);
|
||||
|
||||
const {
|
||||
data: migratableSourceData,
|
||||
loading: isSourceLoading,
|
||||
error: sourceError,
|
||||
refetch: refetchSource,
|
||||
} = requestManager.useGetSource<GetSourceMigratableQuery, GetSourceMigratableQueryVariables>(
|
||||
GET_SOURCE_MIGRATABLE,
|
||||
sourceId ?? '',
|
||||
{ skip: !sourceId, notifyOnNetworkStatusChange: true },
|
||||
);
|
||||
|
||||
const {
|
||||
data: migratableSourceMangasData,
|
||||
loading: areMangasLoading,
|
||||
error: mangasError,
|
||||
refetch: refetchMangas,
|
||||
} = requestManager.useGetMigratableSourceMangas(sourceId!, {
|
||||
skip: !sourceId,
|
||||
notifyOnNetworkStatusChange: true,
|
||||
});
|
||||
|
||||
const mangas = migratableSourceMangasData?.mangas.nodes ?? STABLE_EMPTY_ARRAY;
|
||||
const mangaIds = useMemo(() => Mangas.getIds(mangas), [mangas]);
|
||||
|
||||
const { selectedItemIds, handleSelection, handleSelectAll, areAllItemsSelected, areNoItemsSelected } =
|
||||
useSelectableCollection<number, string>(mangas.length, {
|
||||
itemIds: mangaIds,
|
||||
currentKey: 'default',
|
||||
initialState: useMemo(
|
||||
() => ({
|
||||
default: Object.keys(selectedMangas).map(Number),
|
||||
}),
|
||||
[selectedMangas],
|
||||
),
|
||||
});
|
||||
|
||||
const sourceName = migratableSourceData?.source?.displayName ?? sourceId ?? t`Migrate`;
|
||||
useAppTitleAndAction(
|
||||
sourceName,
|
||||
<>
|
||||
<GridLayouts gridLayout={gridLayout} onChange={setGridLayout} />
|
||||
<SelectableCollectionSelectMode
|
||||
isActive
|
||||
isCancelable={false}
|
||||
areAllItemsSelected={areAllItemsSelected}
|
||||
areNoItemsSelected={areNoItemsSelected}
|
||||
onSelectAll={(selectAll) => handleSelectAll(selectAll, [...new Set([...selectedItemIds, ...mangaIds])])}
|
||||
onModeChange={(checked) => {
|
||||
handleSelectAll(checked, [...new Set([...selectedItemIds, ...mangaIds])]);
|
||||
}}
|
||||
/>
|
||||
</>,
|
||||
[gridLayout, setGridLayout, areAllItemsSelected, areNoItemsSelected, handleSelectAll, mangaIds],
|
||||
);
|
||||
|
||||
const handleContinue = () => {
|
||||
const selected = mangas.filter((manga) => selectedItemIds.includes(manga.id));
|
||||
|
||||
const [entry] = selected;
|
||||
const isSingleManga = selected.length === 1;
|
||||
|
||||
if (isSingleManga && entry) {
|
||||
ReactRouter.navigate(
|
||||
AppRoutes.migrate.childRoutes.singleMangaSearch.path(entry.sourceId, entry.id, entry.title),
|
||||
{
|
||||
state: { mangaTitle: entry.title },
|
||||
},
|
||||
);
|
||||
|
||||
MigrationManager.reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
MigrationManager.selectMangas(selected);
|
||||
};
|
||||
|
||||
const isLoading = isSourceLoading || areMangasLoading;
|
||||
if (isLoading) {
|
||||
return <LoadingPlaceholder />;
|
||||
}
|
||||
|
||||
const hasError = getSourceError(sourceError) || mangasError;
|
||||
if (hasError) {
|
||||
const error = getSourceError(sourceError) ?? mangasError;
|
||||
|
||||
return (
|
||||
<EmptyViewAbsoluteCentered
|
||||
message={t`Unable to load data`}
|
||||
messageExtra={getErrorMessage(error)}
|
||||
retry={() => {
|
||||
if (getSourceError(sourceError)) {
|
||||
refetchSource().catch(defaultPromiseErrorHandler('MigrationSelectMangas::refetchSource'));
|
||||
}
|
||||
if (mangasError) {
|
||||
refetchMangas().catch(defaultPromiseErrorHandler('MigrationSelectMangas::refetchMangas'));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseMangaGrid
|
||||
mode="migrate.select"
|
||||
hasNextPage={false}
|
||||
loadMore={noOp}
|
||||
isLoading={areMangasLoading}
|
||||
mangas={mangas}
|
||||
gridLayout={gridLayout}
|
||||
isSelectModeActive
|
||||
selectedMangaIds={selectedItemIds}
|
||||
handleSelection={handleSelection}
|
||||
/>
|
||||
<MigrationContinueButton onClick={handleContinue} isDisabled={!selectedItemIds.length} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
161
src/features/migration/screens/MigrationSelectSource.tsx
Normal file
161
src/features/migration/screens/MigrationSelectSource.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* 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 { useMemo } from 'react';
|
||||
import List from '@mui/material/List';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import SortByAlphaIcon from '@mui/icons-material/SortByAlpha';
|
||||
import TagIcon from '@mui/icons-material/Tag';
|
||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import { LoadingPlaceholder } from '@/base/components/feedback/LoadingPlaceholder.tsx';
|
||||
import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewAbsoluteCentered.tsx';
|
||||
import type { TMigratableSource } from '@/features/migration/components/MigrationCard.tsx';
|
||||
import { MigrationCard } from '@/features/migration/components/MigrationCard.tsx';
|
||||
import { StyledGroupItemWrapper } from '@/base/components/virtuoso/StyledGroupItemWrapper.tsx';
|
||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||
import type { SortSettings, TMigratableSourcesResult } from '@/features/migration/Migration.types.ts';
|
||||
import { SortBy, SortOrder } from '@/features/migration/Migration.types.ts';
|
||||
import { sortByToTranslation, sortOrderToTranslation } from '@/features/migration/Migration.constants.ts';
|
||||
import {
|
||||
createUpdateMetadataServerSettings,
|
||||
useMetadataServerSettings,
|
||||
} from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||
import { makeToast } from '@/base/utils/Toast.ts';
|
||||
import { useNavBarContext } from '@/features/navigation-bar/NavbarContext.tsx';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
|
||||
const getMigratableSources = (
|
||||
mangas: TMigratableSourcesResult | undefined,
|
||||
{ sortBy, sortOrder }: SortSettings,
|
||||
): TMigratableSource[] => {
|
||||
if (!mangas) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sourceBySourceId: Record<string, TMigratableSource> = {};
|
||||
|
||||
mangas.forEach(({ sourceId, source }) => {
|
||||
const uniqueSource = sourceBySourceId[sourceId] ?? {
|
||||
...{ id: sourceId, name: sourceId, lang: 'unknown', iconUrl: null, mangaCount: 0, ...source },
|
||||
};
|
||||
|
||||
sourceBySourceId[sourceId] = {
|
||||
...uniqueSource,
|
||||
mangaCount: uniqueSource.mangaCount + 1,
|
||||
};
|
||||
});
|
||||
|
||||
const sourcesSortedBy = Object.values(sourceBySourceId).toSorted((a, b) => {
|
||||
switch (sortBy) {
|
||||
case SortBy.SOURCE_NAME:
|
||||
return a.name.localeCompare(b.name);
|
||||
case SortBy.MANGA_COUNT:
|
||||
return a.mangaCount - b.mangaCount;
|
||||
default:
|
||||
throw new Error(`Unexpected "sortBy" "${sortBy}"`);
|
||||
}
|
||||
});
|
||||
|
||||
switch (sortOrder) {
|
||||
case SortOrder.ASC:
|
||||
return sourcesSortedBy;
|
||||
case SortOrder.DESC:
|
||||
return sourcesSortedBy.toReversed();
|
||||
default:
|
||||
throw new Error(`Unexpected "sortOrder" "${sortOrder}"`);
|
||||
}
|
||||
};
|
||||
|
||||
export const MigrationSelectSource = ({ tabsMenuHeight }: { tabsMenuHeight: number }) => {
|
||||
const { t } = useLingui();
|
||||
const { appBarHeight } = useNavBarContext();
|
||||
|
||||
const {
|
||||
settings: { migrateSortSettings },
|
||||
} = useMetadataServerSettings();
|
||||
const updateMetadataServerSettings = createUpdateMetadataServerSettings<'migrateSortSettings'>((e) =>
|
||||
makeToast(t`Failed to save changes`, 'error', getErrorMessage(e)),
|
||||
);
|
||||
const { sortBy, sortOrder } = migrateSortSettings;
|
||||
|
||||
const { data, loading, error, refetch } = requestManager.useGetMigratableSources({
|
||||
notifyOnNetworkStatusChange: true,
|
||||
});
|
||||
const migratableSources = useMemo(
|
||||
() => getMigratableSources(data?.mangas.nodes, migrateSortSettings),
|
||||
[data?.mangas.nodes, migrateSortSettings],
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <LoadingPlaceholder />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<EmptyViewAbsoluteCentered
|
||||
message={t`Unable to load data`}
|
||||
messageExtra={getErrorMessage(error)}
|
||||
retry={() => refetch().catch(defaultPromiseErrorHandler('Migration::refetch'))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Stack
|
||||
sx={{
|
||||
position: 'sticky',
|
||||
top: `${appBarHeight + tabsMenuHeight}px`,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'end',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
p: 1,
|
||||
backgroundColor: 'background.default',
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<CustomTooltip title={t(sortByToTranslation[sortBy])}>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={() =>
|
||||
updateMetadataServerSettings('migrateSortSettings', { sortBy: (sortBy + 1) % 2, sortOrder })
|
||||
}
|
||||
>
|
||||
{sortBy ? <TagIcon /> : <SortByAlphaIcon />}
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
<CustomTooltip title={t(sortOrderToTranslation[sortOrder])}>
|
||||
<IconButton
|
||||
color="inherit"
|
||||
onClick={() =>
|
||||
updateMetadataServerSettings('migrateSortSettings', {
|
||||
sortBy,
|
||||
sortOrder: (sortOrder + 1) % 2,
|
||||
})
|
||||
}
|
||||
>
|
||||
{sortOrder ? <ArrowDownwardIcon /> : <ArrowUpwardIcon />}
|
||||
</IconButton>
|
||||
</CustomTooltip>
|
||||
</Stack>
|
||||
<List sx={{ p: 0 }}>
|
||||
{migratableSources.map((migratableSource) => (
|
||||
<StyledGroupItemWrapper key={migratableSource.id}>
|
||||
<MigrationCard {...migratableSource} />
|
||||
</StyledGroupItemWrapper>
|
||||
))}
|
||||
</List>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -401,7 +401,7 @@ export const ServerSettings = () => {
|
||||
),
|
||||
actions: {
|
||||
confirm: {
|
||||
title: t`I understand`,
|
||||
title: t`Understood`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -30,10 +30,33 @@ import {
|
||||
} from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||
import { makeToast } from '@/base/utils/Toast.ts';
|
||||
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
||||
import type { SourceBaseFieldsFragment } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { requestManager } from '@/lib/requests/RequestManager.ts';
|
||||
import type { DocumentNode, Unmasked } from '@apollo/client';
|
||||
import { SOURCE_BASE_FIELDS } from '@/lib/graphql/source/SourceFragments.ts';
|
||||
|
||||
export class Sources {
|
||||
static readonly LOCAL_SOURCE_ID = '0';
|
||||
|
||||
static getIds(sources: SourceIdInfo[]): SourceIdInfo['id'][] {
|
||||
return sources.map((source) => source.id);
|
||||
}
|
||||
|
||||
static getFromCache<T = SourceBaseFieldsFragment>(
|
||||
id: SourceIdInfo['id'],
|
||||
fragment: DocumentNode = SOURCE_BASE_FIELDS,
|
||||
fragmentName: string = 'SOURCE_BASE_FIELDS',
|
||||
): Unmasked<T> | null {
|
||||
return requestManager.graphQLClient.client.cache.readFragment<T>({
|
||||
id: requestManager.graphQLClient.client.cache.identify({
|
||||
__typename: 'SourceType',
|
||||
id,
|
||||
}),
|
||||
fragment,
|
||||
fragmentName,
|
||||
});
|
||||
}
|
||||
|
||||
static isLocalSource(source: SourceIdInfo): boolean {
|
||||
return source.id === Sources.LOCAL_SOURCE_ID;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user