Add bulk migration

This commit is contained in:
schroda
2026-03-13 22:41:28 +01:00
parent c4c2a616f6
commit f6302fe3aa
75 changed files with 4207 additions and 669 deletions

View File

@@ -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;
}

View File

@@ -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' }}
>

View File

@@ -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>

View File

@@ -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' }}
>

View File

@@ -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>
);
});