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

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

View File

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

View 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()
}
/>
</>
);
};

View 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} />
);

View 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);
}
}}
/>
</>
);
};

View File

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

View 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} />
</>
);
};

View 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>
</>
);
};