Support library batch selection migration

Closes #1089
This commit is contained in:
schroda
2026-04-23 15:18:35 +02:00
parent 172c522d01
commit 527769ec5e
17 changed files with 172 additions and 144 deletions

View File

@@ -34,7 +34,7 @@ export const MAX_SOURCES_IN_PARALLEL = 6;
export const DEFAULT_MIGRATION_STATE: MigrationState = {
phase: MigrationPhase.IDLE,
sourceId: null,
sourceIds: null,
entries: {},
destinationSourceIds: [],
migrateOptions: null,

View File

@@ -111,7 +111,7 @@ export type MigrationProgress = { total: number; completed: number; success: num
export interface MigrationState {
phase: MigrationPhase;
sourceId: SourceIdInfo['id'] | null;
sourceIds: SourceIdInfo['id'][] | null;
entries: Record<MangaIdInfo['id'], TMigrationEntry>;
destinationSourceIds: SourceIdInfo['id'][];
migrateOptions: Omit<MigrateOptions, 'mangaIdToMigrateTo'> | null;
@@ -123,3 +123,7 @@ export interface MigrationState {
}
export interface SourceItem extends SourceIdInfo, SourceNameInfo, SourceLanguageInfo, SourceIconInfo, SourceMetaInfo {}
export type TMigratableSource = NonNullable<GetMigratableSourcesQuery['mangas']['nodes'][number]['source']> & {
mangaCount: number;
};

View File

@@ -14,11 +14,11 @@ import { devtools, persist } from 'zustand/middleware';
import {
type MigratableEntry,
type MigrateOptions,
type TMigrationEntry,
MigrationEntryStatus,
MigrationPhase,
type MigrationMatch,
MigrationPhase,
type MigrationState,
type TMigrationEntry,
} from '@/features/migration/Migration.types.ts';
import {
DEFAULT_MIGRATION_STATE,
@@ -153,6 +153,14 @@ export class MigrationManager {
}
}
private static ensureIsInValidPhase(phases: MigrationPhase[]): void {
const { phase } = MigrationManager.getState();
if (!phases.includes(phase)) {
throw new Error(`Illegal migration phase "${phase}". Expected: ${phases}`);
}
}
static async goToPreviousPhase(): Promise<boolean> {
switch (MigrationManager.getState().phase) {
case MigrationPhase.IDLE:
@@ -265,16 +273,34 @@ export class MigrationManager {
return updatedEntry;
}
static selectSource(sourceId: SourceIdInfo['id']): void {
static selectSources(sourceIds: SourceIdInfo['id'][]): void {
MigrationManager.ensureIsInValidPhase([MigrationPhase.IDLE]);
MigrationManager.updateState((draft) => {
draft.phase = MigrationPhase.SELECT_MANGAS;
draft.sourceId = sourceId;
draft.sourceIds = sourceIds;
draft.entries = {};
});
ReactRouter.navigate(AppRoutes.migrate.path);
}
static selectMangas(mangas: MangaMigrationFieldsFragment[]): void {
MigrationManager.ensureIsInValidPhase([MigrationPhase.SELECT_MANGAS]);
const isSingleManga = mangas.length === 1;
if (isSingleManga) {
const [manga] = mangas;
ReactRouter.navigate(
AppRoutes.migrate.childRoutes.singleMangaSearch.path(manga.sourceId, manga.id, manga.title),
{
state: { mangaTitle: manga.title },
},
);
MigrationManager.reset();
return;
}
MigrationManager.updateState((draft) => {
draft.phase = MigrationPhase.SELECTING_SOURCES;
draft.entries = Object.fromEntries(
@@ -305,6 +331,8 @@ export class MigrationManager {
}
static async startSearch(destinationSourceIds: SourceIdInfo['id'][]): Promise<void> {
MigrationManager.ensureIsInValidPhase([MigrationPhase.SELECTING_SOURCES]);
MigrationManager.updateState((draft) => {
draft.destinationSourceIds = destinationSourceIds;
});
@@ -369,6 +397,8 @@ export class MigrationManager {
}
static async startMigration(options: Omit<MigrateOptions, 'mangaIdToMigrateTo'>): Promise<void> {
MigrationManager.ensureIsInValidPhase([MigrationPhase.SEARCHING]);
const migratableEntries = MigrationManager.getMigratableEntries();
await Confirmation.show({
@@ -551,25 +581,49 @@ export class MigrationManager {
return RESUMABLE_PHASES.includes(MigrationManager.getState().phase);
}
private static getHigherPrioritySourceIds(sourceId: SourceIdInfo['id']): SourceIdInfo['id'][] {
const { destinationSourceIds } = MigrationManager.getState();
private static getDestinationSourceIds(mangaSourceId: SourceIdInfo['id']): MigrationState['destinationSourceIds'] {
const { sourceIds, destinationSourceIds } = MigrationManager.getState();
const sourceIdPriority = destinationSourceIds.indexOf(sourceId);
// Prevent (most likely) unintentionally matching a manga to itself.
const isMultiSourceMigration = (sourceIds?.length ?? -1) > 1;
if (isMultiSourceMigration) {
const mangaSourceIdIndex = destinationSourceIds.indexOf(mangaSourceId);
return [
...destinationSourceIds.slice(0, mangaSourceIdIndex),
...destinationSourceIds.slice(mangaSourceIdIndex + 1),
mangaSourceId,
];
}
return destinationSourceIds;
}
private static getHigherPrioritySourceIds(
mangaSourceId: SourceIdInfo['id'],
destSourceId: SourceIdInfo['id'],
): SourceIdInfo['id'][] {
const destinationSourceIds = MigrationManager.getDestinationSourceIds(mangaSourceId);
const sourceIdPriority = destinationSourceIds.indexOf(destSourceId);
return destinationSourceIds.slice(0, Math.max(0, sourceIdPriority - 1));
}
private static isHigherPrioritySourceUnsettled(mangaId: MangaIdInfo['id'], sourceId: SourceIdInfo['id']): boolean {
private static isHigherPrioritySourceUnsettled(
mangaId: MangaIdInfo['id'],
destSourceId: SourceIdInfo['id'],
): boolean {
const { entries } = MigrationManager.getState();
const entry = entries[mangaId];
assertIsDefined(entry);
return MigrationManager.getHigherPrioritySourceIds(sourceId).some(
return MigrationManager.getHigherPrioritySourceIds(entry.sourceId, destSourceId).some(
(higherPrioritySourceId) => entry.destSourceIdToSearchState[higherPrioritySourceId] == null,
);
}
private static hasHigherSourcePriorityMatch(mangaId: MangaIdInfo['id'], sourceId: SourceIdInfo['id']): boolean {
private static hasHigherSourcePriorityMatch(mangaId: MangaIdInfo['id'], destSourceId: SourceIdInfo['id']): boolean {
const { entries } = MigrationManager.getState();
const entry = entries[mangaId];
@@ -577,7 +631,7 @@ export class MigrationManager {
return false;
}
return MigrationManager.getHigherPrioritySourceIds(sourceId).some(
return MigrationManager.getHigherPrioritySourceIds(entry.sourceId, destSourceId).some(
(higherPrioritySourceId) => entry.destSourceIdToSearchState[higherPrioritySourceId],
);
}
@@ -658,7 +712,7 @@ export class MigrationManager {
});
try {
const searchPromises = state.destinationSourceIds.map((destSourceId) =>
const searchPromises = MigrationManager.getDestinationSourceIds(entry.sourceId).map((destSourceId) =>
MigrationManager.getParallelSourceQueue()(async () => {
if (signal.aborted) {
return null;
@@ -893,8 +947,8 @@ export class MigrationManager {
return useMigrationStore((state) => state.phase);
}
static useSourceId(): SourceIdInfo['id'] | null {
return useMigrationStore((state) => state.sourceId);
static useSourceIds(): MigrationState['sourceIds'] {
return useMigrationStore((state) => state.sourceIds);
}
static useEntries(): Record<number, TMigrationEntry> {

View File

@@ -13,26 +13,29 @@ import Chip from '@mui/material/Chip';
import Typography from '@mui/material/Typography';
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 { MigrationManager } from '@/features/migration/MigrationManager.ts';
import { ListCardAvatar } from '@/base/components/lists/cards/ListCardAvatar.tsx';
import { ListCardContent } from '@/base/components/lists/cards/ListCardContent.tsx';
import { Sources } from '@/features/source/services/Sources';
import type { TMigratableSource } from '@/features/migration/Migration.types.ts';
import { ReactRouter } from '@/lib/react-router/ReactRouter.ts';
import { AppRoutes } from '@/base/AppRoute.constants.ts';
export type TMigratableSource = NonNullable<GetMigratableSourcesQuery['mangas']['nodes'][number]['source']> & {
mangaCount: number;
};
// TODO - cleanup source/extension components
export const MigrationCard = ({ id, name, lang, iconUrl, mangaCount }: TMigratableSource) => {
export const MigrationCard = (source: TMigratableSource) => {
const { id, name, lang, iconUrl, mangaCount } = source;
const { t } = useLingui();
const isLocalSource = Number(id) === 0;
const sourceName = isLocalSource ? t`Local source` : name;
const sourceName = Sources.isLocalSource(source) ? t`Local source` : name;
return (
<Card>
<CardActionArea onClick={() => MigrationManager.selectSource(id)}>
<CardActionArea
onClick={() => {
MigrationManager.selectSources([id]);
ReactRouter.navigate(AppRoutes.migrate.path);
}}
>
<ListCardContent sx={{ justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', gap: 1 }}>
<ListCardAvatar

View File

@@ -20,7 +20,7 @@ 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 { MigrationState, 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';
@@ -91,13 +91,13 @@ export const MigrationSourceList = ({
handleSelection,
selectedSourceIds,
handlePriorityChange,
currentSourceId,
currentSourceIds,
}: {
sources: SourceItem[];
handleSelection: SelectableCollectionReturnType<SourceIdInfo['id'], 'default'>['handleSelection'];
handlePriorityChange: (oldIndex: number, newIndex: number) => void;
selectedSourceIds: SourceIdInfo['id'][];
currentSourceId: SourceIdInfo['id'] | null;
currentSourceIds: MigrationState['sourceIds'];
}) => {
const { t } = useLingui();
const dndSensors = DndKitUtil.useSensorsForDevice();
@@ -207,7 +207,7 @@ export const MigrationSourceList = ({
<SourceCard
source={source}
onToggle={handleToggle}
isCurrentSource={source.id === currentSourceId}
isCurrentSource={!!currentSourceIds?.includes(source.id)}
isSelected={isSelected}
/>
);
@@ -232,7 +232,7 @@ export const MigrationSourceList = ({
<SourceCard
source={dndActiveSource!}
onToggle={noOp}
isCurrentSource={dndActiveSource?.id === currentSourceId}
isCurrentSource={!!dndActiveSource && !!currentSourceIds?.includes(dndActiveSource?.id)}
isSelected
isDragging
/>

View File

@@ -68,7 +68,7 @@ export const MigrationExecute = () => {
})}
entries={migratingEntries}
isMigrating
color="error"
color="info"
/>
<MigrationEntryGroup
status={MigrationEntryStatus.MIGRATION_FAILED}

View File

@@ -71,7 +71,7 @@ export const MigrationSearch = () => {
other: '# searching',
})}
entries={searchingEntries}
color="error"
color="info"
/>
<MigrationEntryGroup
status={MigrationEntryStatus.SEARCH_FAILED}

View File

@@ -31,7 +31,7 @@ import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
export const MigrationSelectDestinationSources = () => {
const { t } = useLingui();
const currentSourceId = MigrationManager.useSourceId();
const currentSourceIds = MigrationManager.useSourceIds();
const {
settings: { browseLanguages, showNsfw },
@@ -156,7 +156,7 @@ export const MigrationSelectDestinationSources = () => {
selectedSourceIds={selectedItemIds}
handleSelection={handleSelection}
handlePriorityChange={handlePriorityChange}
currentSourceId={currentSourceId}
currentSourceIds={currentSourceIds}
/>
<Fab
variant="extended"

View File

@@ -26,8 +26,6 @@ import { SelectableCollectionSelectMode } from '@/base/collection/components/Sel
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);
@@ -45,9 +43,11 @@ const getSourceError = (error: unknown): unknown => {
export const MigrationSelectMangas = () => {
const { t } = useLingui();
const sourceId = MigrationManager.useSourceId();
const sourceIds = MigrationManager.useSourceIds();
const selectedMangas = MigrationManager.useEntries();
const sourceId = sourceIds?.[0];
const [gridLayout, setGridLayout] = useLocalStorage('migrateGridLayout', GridLayout.List);
const {
@@ -58,7 +58,7 @@ export const MigrationSelectMangas = () => {
} = requestManager.useGetSource<GetSourceMigratableQuery, GetSourceMigratableQueryVariables>(
GET_SOURCE_MIGRATABLE,
sourceId ?? '',
{ skip: !sourceId, notifyOnNetworkStatusChange: true },
{ skip: !sourceIds, notifyOnNetworkStatusChange: true },
);
const {
@@ -67,7 +67,7 @@ export const MigrationSelectMangas = () => {
error: mangasError,
refetch: refetchMangas,
} = requestManager.useGetMigratableSourceMangas(sourceId!, {
skip: !sourceId,
skip: !sourceIds,
notifyOnNetworkStatusChange: true,
});
@@ -86,7 +86,7 @@ export const MigrationSelectMangas = () => {
),
});
const sourceName = migratableSourceData?.source?.displayName ?? sourceId ?? t`Migrate`;
const sourceName = migratableSourceData?.source?.displayName ?? sourceIds ?? t`Migrate`;
useAppTitleAndAction(
sourceName,
<>
@@ -108,22 +108,6 @@ export const MigrationSelectMangas = () => {
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);
};

View File

@@ -19,11 +19,14 @@ 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 type {
SortSettings,
TMigratableSource,
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 {