Files
suwayomi-material-you-webui/src/features/migration/MigrationManager.ts

1326 lines
51 KiB
TypeScript
Raw Normal View History

2026-03-13 22:41:28 +01:00
/*
* 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 MigrationBulkSearchSettings,
2026-03-13 22:41:28 +01:00
MigrationEntryStatus,
type MigrationMatch,
MigrationPhase,
type MigrationProgress,
2026-03-13 22:41:28 +01:00
type MigrationState,
type TMigrationEntry,
2026-03-13 22:41:28 +01:00
} 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 {
ChapterListFieldsFragment,
2026-03-13 22:41:28 +01:00
GetMigrationSourceMangasFetchMutation,
GetMigrationSourceMangasFetchMutationVariables,
GetServerSettingsQuery,
GetServerSettingsQueryVariables,
MangaMigrationFieldsFragment,
} from '@/lib/graphql/generated/graphql.ts';
2026-05-15 20:40:31 +02:00
import { FetchSourceMangaType } from '@/lib/graphql/generated/graphql-base.types.ts';
2026-03-13 22:41:28 +01:00
import { GET_SERVER_SETTINGS } from '@/lib/graphql/settings/SettingsQuery.ts';
import { MangaMigration } from '@/features/migration/MangaMigration.ts';
2026-05-15 20:40:31 +02:00
import type {
MangaArtistInfo,
MangaAuthorInfo,
MangaHighestChapterNumberInfo,
MangaIdInfo,
MangaSourceIdInfo,
MangaSourceNameInfo,
MangaThumbnailInfo,
MangaTitleInfo,
} from '@/features/manga/Manga.types.ts';
2026-03-13 22:41:28 +01:00
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';
import uniqBy from 'lodash/fp/uniqBy';
import { MigrationEntries } from '@/features/migration/MigrationEntries.ts';
import { Chapters } from '@/features/chapter/services/Chapters.ts';
import { AppSession } from '@/base/AppSession.ts';
import { ControlledPromise } from '@/lib/ControlledPromise.ts';
import { d } from 'koration';
import merge from 'lodash/fp/merge';
import mapValues from 'lodash/fp/mapValues';
2026-03-13 22:41:28 +01:00
const RESUMABLE_PHASES: readonly MigrationPhase[] = [MigrationPhase.SEARCHING, MigrationPhase.MIGRATING];
let initialResume = true;
2026-03-13 22:41:28 +01:00
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 (initialResume && (!persisted || !RESUMABLE_PHASES.includes(persisted.phase))) {
initialResume = false;
2026-03-13 22:41:28 +01:00
return currentState;
}
return merge(currentState, persisted);
2026-03-13 22:41:28 +01:00
},
},
),
),
);
const useMigrationStore = ZustandUtil.createStoreHook(migrationStore);
window.addEventListener('storage', (e) => {
const isMigrationStateUpdate = e.key === MIGRATION_LOCAL_STORAGE_KEY;
if (isMigrationStateUpdate) {
migrationStore.persist.rehydrate();
}
});
2026-03-13 22:41:28 +01:00
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<SourceIdInfo['id'], LimitFunction>();
2026-05-22 14:13:56 +02:00
private static abortControllerByManga = new Map<MangaIdInfo['id'], AbortController>();
2026-03-13 22:41:28 +01:00
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;
}
}
private static ensureIsInValidPhase(phases: MigrationPhase[]): void {
const { phase } = MigrationManager.getState();
if (!phases.includes(phase)) {
throw new Error(`Illegal migration phase "${phase}". Expected: ${phases}`);
}
}
2026-03-13 22:41:28 +01:00
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, isAborted } = MigrationManager.getState();
const searchProgress = MigrationManager.getSearchProgress();
const migrationProgress = MigrationManager.getMigrationProgress();
2026-03-13 22:41:28 +01:00
switch (phase) {
case MigrationPhase.SEARCHING:
return searchProgress.completed === searchProgress.total;
2026-03-13 22:41:28 +01:00
case MigrationPhase.MIGRATING:
return isAborted || migrationProgress.completed === migrationProgress.total;
2026-03-13 22:41:28 +01:00
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,
missingChapters: searchMatch.missingChapters,
inLibrary: cachedEntry?.inLibrary ?? searchMatch.inLibrary,
2026-03-13 22:41:28 +01:00
};
}
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,
missingChapters: entry.missingChapters,
2026-03-13 22:41:28 +01:00
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,
isManualSelection: entry.isManualSelection,
2026-03-13 22:41:28 +01:00
} satisfies TMigrationEntry;
if (isEqual(entry, updatedEntry)) {
return entry;
}
MigrationManager.updateState((draft) => {
draft.entries[entry.mangaId] = updatedEntry;
});
return updatedEntry;
}
static selectSources(sourceIds: SourceIdInfo['id'][]): void {
MigrationManager.ensureIsInValidPhase([MigrationPhase.IDLE]);
2026-03-13 22:41:28 +01:00
MigrationManager.updateState((draft) => {
draft.phase = MigrationPhase.SELECT_MANGAS;
draft.sourceIds = sourceIds;
2026-03-13 22:41:28 +01:00
draft.entries = {};
});
}
2026-05-15 20:40:31 +02:00
static selectMangas(
mangas: (MangaIdInfo &
MangaTitleInfo &
MangaArtistInfo &
MangaAuthorInfo &
MangaHighestChapterNumberInfo &
MangaThumbnailInfo &
MangaSourceIdInfo &
MangaSourceNameInfo)[],
): void {
MigrationManager.ensureIsInValidPhase([MigrationPhase.SELECT_MANGAS]);
const isSingleManga = mangas.length === 1;
if (isSingleManga) {
const [manga] = mangas;
ReactRouter.navigate(
2026-05-28 00:01:54 +02:00
AppRoutes.migrate.children.singleMangaSearch.path(manga.sourceId, manga.id, manga.title),
{
2026-05-28 00:01:54 +02:00
state: AppRoutes.migrate.children.singleMangaSearch.state({
2026-05-27 23:59:58 +02:00
title: t`Migrate "${manga.title}"`,
mode: 'migrate.select.single',
2026-05-27 23:59:58 +02:00
}),
},
);
MigrationManager.reset();
return;
}
2026-03-13 22:41:28 +01:00
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,
missingChapters: undefined,
2026-03-13 22:41:28 +01:00
mangaThumbnailUrl: manga.thumbnailUrl,
sourceId: manga.sourceId,
sourceTitle: manga.source?.displayName,
2026-05-22 14:13:56 +02:00
status: MigrationEntryStatus.SEARCH_PENDING,
2026-03-13 22:41:28 +01:00
searchMatches: [],
manualMatches: [],
selectedMatchMangaId: null,
selectedMatchSourceId: null,
destSourceIdToSearchState: {},
isExcluded: false,
areMatchesExpanded: false,
error: undefined,
isManualSelection: false,
2026-03-13 22:41:28 +01:00
},
]),
);
});
}
private static async awaitUserConfirmation(): Promise<void> {
await Confirmation.show({
title: t`Migration information`,
message: AppSession.isSecureContext()
? 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.`
: t`WebUI must be kept open. Migration can't be resumed`,
actions: {
confirm: {
title: t`Understood`,
},
},
});
}
static async startSearch(
destinationSourceIds: SourceIdInfo['id'][],
options: MigrationBulkSearchSettings,
): Promise<void> {
MigrationManager.ensureIsInValidPhase([MigrationPhase.SELECTING_SOURCES]);
await MigrationManager.awaitUserConfirmation();
2026-03-13 22:41:28 +01:00
const state = MigrationManager.getState();
MigrationManager.updateState((draft) => {
draft.phase = MigrationPhase.SEARCHING;
draft.destinationSourceIds = destinationSourceIds;
draft.searchOptions = options;
2026-03-13 22:41:28 +01:00
draft.startedAt = Date.now();
draft.groupExpandState = MIGRATE_SEARCH_ENTRY_GROUP_EXPAND_DEFAULT_STATE;
});
await MigrationManager.search(Object.values(state.entries), options);
2026-04-23 15:54:37 +02:00
}
private static async search(entries: TMigrationEntry[], options: MigrationBulkSearchSettings): Promise<void> {
2026-04-23 15:54:37 +02:00
const { signal } = MigrationManager.abortAndCreateAbortController('search');
2026-05-22 14:13:56 +02:00
MigrationManager.updateState((draft) =>
entries.forEach((entry) => {
draft.entries[entry.mangaId].status = MigrationEntryStatus.SEARCH_PENDING;
}),
);
2026-04-23 15:54:37 +02:00
const searchPromises = entries.map((entry) =>
MigrationManager.mangaProcessQueue(async () => {
if (signal.aborted) {
2026-05-22 14:13:56 +02:00
throw new Error(signal.reason);
2026-04-23 15:54:37 +02:00
}
await MigrationManager.searchForManga(entry.mangaId, entry.mangaTitle, signal, options);
2026-04-23 15:54:37 +02:00
}),
);
2026-03-13 22:41:28 +01:00
try {
2026-04-23 15:54:37 +02:00
await Promise.allSettled(searchPromises);
2026-03-13 22:41:28 +01:00
} finally {
const searchProgress = MigrationManager.getSearchProgress();
2026-03-13 22:41:28 +01:00
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.SEARCH_NO_MATCH]: !allSearchesFailed && !searchProgress.success,
[MigrationEntryStatus.SEARCH_OUTDATED]: !allSearchesFailed && !searchProgress.success,
2026-03-13 22:41:28 +01:00
[MigrationEntryStatus.SEARCH_FAILED]: allSearchesFailed,
[MigrationEntryStatus.SEARCH_COMPLETE]: !!searchProgress.success,
};
});
}
}
}
static async startMigration(options: Omit<MigrateOptions, 'mangaIdToMigrateTo'>): Promise<void> {
MigrationManager.ensureIsInValidPhase([MigrationPhase.SEARCHING]);
const migratableEntries = MigrationEntries.getMigratable(Object.values(MigrationManager.getState().entries));
2026-03-13 22:41:28 +01:00
await MigrationManager.awaitUserConfirmation();
2026-03-13 22:41:28 +01:00
MigrationManager.updateState((draft) => {
draft.phase = MigrationPhase.MIGRATING;
draft.migrateOptions = options;
draft.groupExpandState = MIGRATE_EXECUTE_ENTRY_GROUP_EXPAND_DEFAULT_STATE;
});
2026-04-23 15:54:37 +02:00
await MigrationManager.migrate(migratableEntries, options);
2026-03-13 22:41:28 +01:00
}
private static async migrate(
entries: MigratableEntry[],
options: Omit<MigrateOptions, 'mangaIdToMigrateTo'>,
): Promise<void> {
const { signal } = MigrationManager.abortAndCreateAbortController('migrate');
2026-05-22 14:13:56 +02:00
MigrationManager.updateState((draft) => {
entries.forEach((entry) => {
draft.entries[entry.mangaId].status = MigrationEntryStatus.MIGRATION_PENDING;
});
});
2026-03-13 22:41:28 +01:00
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) {
2026-05-22 14:13:56 +02:00
throw new Error(signal.reason);
2026-03-13 22:41:28 +01:00
}
// oxlint-disable-next-line no-await-in-loop
await MigrationManager.migrateSingleEntry(entry.mangaId, options, signal);
}
}),
);
2026-04-23 15:54:37 +02:00
try {
await Promise.allSettled(migrationPromises);
} finally {
const migrationProgress = MigrationManager.getMigrationProgress();
2026-04-23 15:54:37 +02:00
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,
};
});
}
}
2026-03-13 22:41:28 +01:00
if (!signal.aborted) {
MigrationManager.updateState((draft) => {
draft.lastUpdatedAt = Date.now();
});
}
}
static async stop(reason: unknown = 'stopped'): Promise<boolean> {
2026-03-13 22:41:28 +01:00
if (!(await MigrationManager.confirmAbort())) {
return false;
}
MigrationManager.abortAndResetAbortController(reason);
MigrationManager.updateState((draft) => {
draft.isAborted = true;
});
return true;
}
static async abort(reason: unknown = 'abort'): Promise<boolean> {
if (!MigrationManager.getState().isAborted && !(await MigrationManager.confirmAbort())) {
return false;
}
MigrationManager.abortAndResetAbortController(reason);
2026-03-13 22:41:28 +01:00
MigrationManager.reset();
ReactRouter.navigate(AppRoutes.browse.path(BrowseTab.MIGRATE));
return true;
}
static async awaitCompletion(): Promise<void> {
const completionPromise = new ControlledPromise();
const checkComplete = () => {
setTimeout(() => {
if (MigrationManager.getState().phase !== MigrationPhase.IDLE) {
checkComplete();
return;
}
completionPromise.resolve();
}, d(10).seconds.inWholeMilliseconds);
};
checkComplete();
return completionPromise.promise;
}
static async resume(): Promise<boolean> {
const { phase, migrateOptions, searchOptions, entries } = MigrationManager.getState();
2026-03-13 22:41:28 +01:00
if (!AppSession.isSecureContext()) {
return false;
}
if (!MigrationManager.isResumablePhase()) {
return false;
2026-03-13 22:41:28 +01:00
}
const resumeMigrationPhase = phase === MigrationPhase.MIGRATING && migrateOptions;
2026-03-13 22:41:28 +01:00
if (resumeMigrationPhase) {
assertIsDefined(migrateOptions);
2026-03-13 22:41:28 +01:00
const migratableEntries = MigrationEntries.getMigratable(Object.values(entries));
2026-03-13 22:41:28 +01:00
2026-04-23 15:54:37 +02:00
MigrationManager.updateState((draft) => {
migratableEntries.forEach((entry) => {
2026-05-22 14:13:56 +02:00
draft.entries[entry.mangaId].status = MigrationEntryStatus.MIGRATION_PENDING;
2026-04-23 15:54:37 +02:00
});
});
await MigrationManager.migrate(migratableEntries, migrateOptions);
2026-03-13 22:41:28 +01:00
return true;
2026-03-13 22:41:28 +01:00
}
assertIsDefined(searchOptions);
const pendingEntries = MigrationEntries.getHaveStatus(
Object.values(entries),
2026-05-22 14:13:56 +02:00
MigrationEntryStatus.SEARCH_PENDING,
MigrationEntryStatus.SEARCHING,
2026-03-13 22:41:28 +01:00
);
2026-04-23 15:54:37 +02:00
MigrationManager.updateState((draft) => {
pendingEntries.forEach((entry) => {
2026-05-22 14:13:56 +02:00
draft.entries[entry.mangaId].status = MigrationEntryStatus.SEARCH_PENDING;
2026-04-23 15:54:37 +02:00
});
});
await MigrationManager.search(pendingEntries, searchOptions);
return true;
2026-03-13 22:41:28 +01:00
}
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;
}
});
}
2026-05-22 14:13:56 +02:00
static abortEntry(mangaId: MangaIdInfo['id']): void {
const { entries } = MigrationManager.getState();
2026-05-22 14:13:56 +02:00
const entry = entries[mangaId];
assertIsDefined(entry);
MigrationManager.abortControllerByManga.get(mangaId)?.abort('User aborted');
MigrationManager.updateState((draft) => {
const draftEntry = draft.entries[mangaId];
assertIsDefined(draftEntry);
if (draftEntry.status === MigrationEntryStatus.SEARCHING) {
draftEntry.status = MigrationEntryStatus.SEARCH_ABORTED;
2026-05-22 14:13:56 +02:00
} else {
draftEntry.status = MigrationEntryStatus.MIGRATION_ABORTED;
2026-05-22 14:13:56 +02:00
}
});
}
2026-03-13 22:41:28 +01:00
static selectMatch(
mangaId: MangaIdInfo['id'],
targetMangaId: MangaIdInfo['id'],
targetSourceId: SourceIdInfo['id'],
): void {
MigrationManager.updateState((draft) => {
const entry = draft.entries[mangaId];
if (entry) {
if (entry.selectedMatchMangaId === null && (entry.searchMatches.length || entry.manualMatches.length)) {
entry.areMatchesExpanded = false;
}
entry.status = MigrationEntryStatus.SEARCH_COMPLETE;
entry.isManualSelection = true;
2026-03-13 22:41:28 +01:00
entry.selectedMatchMangaId = targetMangaId;
entry.selectedMatchSourceId = targetSourceId;
}
});
}
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];
}
const isSearching = MigrationEntries.isSearching(entry);
entry.status = MigrationEntryStatus.SEARCH_COMPLETE;
entry.isManualSelection = true;
entry.selectedMatchMangaId = match.id;
entry.selectedMatchSourceId = match.sourceId;
if (isSearching) {
2026-05-22 14:13:56 +02:00
MigrationManager.abortControllerByManga.get(mangaId)?.abort('Manual match selected');
}
2026-03-13 22:41:28 +01:00
}
});
}
static openManualSearch(mangaId: MangaIdInfo['id'], title: string): void {
2026-05-28 00:01:54 +02:00
ReactRouter.navigate(AppRoutes.migrate.children.manualSearch.path(mangaId, title), {
2026-05-27 23:59:58 +02:00
state: AppRoutes.migrate.children.manualSearch.state({
title: t`Manual migration search for "${title}"`,
mode: 'migrate.select.bulk',
}),
});
}
2026-03-13 22:41:28 +01:00
static getState(): MigrationState {
return migrationStore.getState();
}
static isResumablePhase(): boolean {
return RESUMABLE_PHASES.includes(MigrationManager.getState().phase);
2026-03-13 22:41:28 +01:00
}
static isActive(): boolean {
return !!MigrationManager.abortController && MigrationManager.isResumablePhase();
2026-03-13 22:41:28 +01:00
}
private static getDestinationSourceIds(mangaSourceId: SourceIdInfo['id']): MigrationState['destinationSourceIds'] {
const { sourceIds, destinationSourceIds } = MigrationManager.getState();
// Prevent (most likely) unintentionally matching a manga to itself.
const isMultiSourceMigration = (sourceIds?.length ?? -1) > 1;
if (isMultiSourceMigration) {
const mangaSourceIdIndex = destinationSourceIds.indexOf(mangaSourceId);
2026-03-13 22:41:28 +01:00
return [
...destinationSourceIds.slice(0, mangaSourceIdIndex),
...destinationSourceIds.slice(mangaSourceIdIndex + 1),
mangaSourceId,
];
}
return destinationSourceIds;
}
public static getSourcePriority(mangaSourceId: SourceIdInfo['id'], sourceId: SourceIdInfo['id']): number {
const destinationSourceIds = MigrationManager.getDestinationSourceIds(mangaSourceId);
return destinationSourceIds.indexOf(sourceId);
}
private static getHigherPrioritySourceIds(
mangaSourceId: SourceIdInfo['id'],
destSourceId: SourceIdInfo['id'],
): SourceIdInfo['id'][] {
const destinationSourceIds = MigrationManager.getDestinationSourceIds(mangaSourceId);
const sourceIdPriority = destinationSourceIds.indexOf(destSourceId);
2026-03-13 22:41:28 +01:00
return destinationSourceIds.slice(0, Math.max(0, sourceIdPriority - 1));
}
private static isHigherPrioritySourceUnsettled(
mangaId: MangaIdInfo['id'],
destSourceId: SourceIdInfo['id'],
): boolean {
2026-03-13 22:41:28 +01:00
const { entries } = MigrationManager.getState();
const entry = entries[mangaId];
assertIsDefined(entry);
return MigrationManager.getHigherPrioritySourceIds(entry.sourceId, destSourceId).some(
2026-03-13 22:41:28 +01:00
(higherPrioritySourceId) => entry.destSourceIdToSearchState[higherPrioritySourceId] == null,
);
}
private static hasHigherSourcePriorityMatch(mangaId: MangaIdInfo['id'], destSourceId: SourceIdInfo['id']): boolean {
2026-03-13 22:41:28 +01:00
const { entries } = MigrationManager.getState();
const entry = entries[mangaId];
if (!entry || entry.selectedMatchSourceId == null) {
return false;
}
return MigrationManager.getHigherPrioritySourceIds(entry.sourceId, destSourceId).some(
2026-03-13 22:41:28 +01:00
(higherPrioritySourceId) => entry.destSourceIdToSearchState[higherPrioritySourceId],
);
}
private static async findMatchesForMangaInSource(
mangaId: MangaIdInfo['id'],
mangaTitle: string,
sourceId: SourceIdInfo['id'],
signal: AbortSignal,
{ selectHighestChapterNumberSource, performAdvancedSearch }: MigrationBulkSearchSettings,
): Promise<{ manga: MangaMigrationFieldsFragment; chapters: ChapterListFieldsFragment[] | null }[]> {
2026-03-13 22:41:28 +01:00
if (signal.aborted) {
throw new Error(signal.reason);
}
if (!selectHighestChapterNumberSource && MigrationManager.hasHigherSourcePriorityMatch(mangaId, sourceId)) {
throw new Error('Entry already has a selected match from a higher priority source');
}
2026-03-13 22:41:28 +01:00
const searchQueries = performAdvancedSearch
? [
mangaTitle,
...enhancedCleanup(mangaTitle)
.split(' ')
.filter((query) => query.length >= 3),
]
: [mangaTitle];
const searchRequests = searchQueries.map((query) =>
MigrationManager.getOrCreateSourceQueue(sourceId)(() =>
requestManager.graphQLClient.client.mutate<
GetMigrationSourceMangasFetchMutation,
GetMigrationSourceMangasFetchMutationVariables
>({
mutation: GET_MIGRATION_SOURCE_MANGAS_FETCH,
variables: {
input: {
source: sourceId,
query,
page: 1,
type: FetchSourceMangaType.Search,
},
2026-03-13 22:41:28 +01:00
},
context: { fetchOptions: { signal } },
}),
),
);
2026-03-13 22:41:28 +01:00
const searchResponses = await Promise.allSettled(searchRequests);
const successfulSearchResponses = searchResponses
.filter((response) => response.status === 'fulfilled')
.map((response) => response.value);
2026-03-13 22:41:28 +01:00
if (!successfulSearchResponses.length) {
const failureReasons = searchResponses.map((response) => (response as PromiseRejectedResult).reason);
2026-03-13 22:41:28 +01:00
throw new Error(`Search failed due to:${failureReasons.join('\n\n')}`);
}
const searchResults = successfulSearchResponses.flatMap(
(response) => response?.data?.fetchSourceManga?.mangas ?? [],
);
const uniqueSearchResults = uniqBy('id', searchResults);
const matches = uniqueSearchResults.filter(
(searchMatch) =>
searchMatch.id !== mangaId && enhancedCleanup(searchMatch.title) === enhancedCleanup(mangaTitle),
);
2026-03-13 22:41:28 +01:00
const matchUpdatePromises = matches.map(async (match) => {
if (signal.aborted) {
throw new Error(signal.reason);
}
2026-03-13 22:41:28 +01:00
return (async () => {
try {
const updatedMatch = await MigrationManager.getOrCreateSourceQueue(sourceId)(
() =>
requestManager.refreshManga(match.id, {
awaitRefetchQueries: true,
2026-05-22 14:13:56 +02:00
context: { fetchOptions: { signal } },
}).response,
);
2026-03-13 22:41:28 +01:00
if (updatedMatch.data?.fetchManga?.manga) {
return {
manga: updatedMatch.data.fetchManga.manga,
chapters: updatedMatch.data.fetchChapters?.chapters ?? null,
};
}
} catch (e) {
// ignore
}
return {
manga: match,
chapters: null,
};
})();
2026-03-13 22:41:28 +01:00
});
const updatedMatches = await Promise.all(matchUpdatePromises);
return updatedMatches;
2026-03-13 22:41:28 +01:00
}
private static async searchForManga(
mangaId: MangaIdInfo['id'],
mangaTitle: string,
mainSignal: AbortSignal,
options: MigrationBulkSearchSettings,
2026-03-13 22:41:28 +01:00
): Promise<void> {
const {
selectHighestChapterNumberSource,
ignoreOutdatedMatches,
requireAdditionalChapters,
ignoreWithMissingChapters,
} = options;
2026-03-13 22:41:28 +01:00
const state = MigrationManager.getState();
const entry = state.entries[mangaId];
const searchController = new AbortController();
const signal = AbortSignal.any([mainSignal, searchController.signal]);
if (!entry || MigrationEntries.hasStatus(entry, MigrationEntryStatus.SEARCH_ABORTED)) {
2026-03-13 22:41:28 +01:00
return;
}
2026-05-22 14:13:56 +02:00
MigrationManager.abortControllerByManga.get(mangaId)?.abort('search');
MigrationManager.abortControllerByManga.set(mangaId, searchController);
2026-03-13 22:41:28 +01:00
MigrationManager.updateState((draft) => {
const draftEntry = draft.entries[mangaId];
draftEntry.status = MigrationEntryStatus.SEARCHING;
draftEntry.error = undefined;
draftEntry.searchMatches = [];
draftEntry.manualMatches = [];
draftEntry.isManualSelection = false;
draftEntry.selectedMatchMangaId = null;
draftEntry.selectedMatchSourceId = null;
draftEntry.areMatchesExpanded = false;
draftEntry.destSourceIdToSearchState = mapValues(() => false, draftEntry.destSourceIdToSearchState);
2026-03-13 22:41:28 +01:00
});
try {
const searchPromises = MigrationManager.getDestinationSourceIds(entry.sourceId)
.filter((destSourceId) => !entry.destSourceIdToSearchState[destSourceId])
.map((destSourceId) =>
MigrationManager.getParallelSourceQueue()(async () => {
if (signal.aborted) {
2026-05-22 14:13:56 +02:00
throw new Error(signal.reason);
}
2026-03-13 22:41:28 +01:00
if (
!selectHighestChapterNumberSource &&
MigrationManager.hasHigherSourcePriorityMatch(mangaId, destSourceId)
) {
return null;
}
2026-03-13 22:41:28 +01:00
const foundMatches = await (async () => {
try {
return await MigrationManager.findMatchesForMangaInSource(
mangaId,
mangaTitle,
destSourceId,
signal,
options,
);
} catch (e) {
MigrationManager.updateState((draft) => {
const draftEntry = draft.entries[mangaId];
draftEntry.destSourceIdToSearchState[destSourceId] = false;
});
throw e;
}
})();
if (!foundMatches.length) {
2026-03-13 22:41:28 +01:00
MigrationManager.updateState((draft) => {
const draftEntry = draft.entries[mangaId];
draftEntry.destSourceIdToSearchState[destSourceId] = false;
});
return null;
2026-03-13 22:41:28 +01:00
}
MigrationManager.updateState((draft) => {
const draftEntry = draft.entries[mangaId];
const draftMatchEntry = draftEntry.selectedMatchMangaId
? draft.entries[draftEntry.selectedMatchMangaId]
: null;
const newMatches = foundMatches.filter((newMatch) =>
draftEntry.searchMatches.every(
(existingMatch) => newMatch.manga.id !== existingMatch.id,
),
);
const matches = newMatches.map(({ manga, chapters }) => ({
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,
missingChapters: chapters ? Chapters.getMissingCount(chapters) : undefined,
inLibrary: manga.inLibrary,
}));
draftEntry.destSourceIdToSearchState[destSourceId] = true;
draftEntry.searchMatches = [...draftEntry.searchMatches, ...matches];
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];
2026-03-13 22:41:28 +01:00
assertIsDefined(bestMatch);
2026-03-13 22:41:28 +01:00
const entryLatestChapterNumber = draftEntry.latestChapterNumber ?? Number.MIN_SAFE_INTEGER;
const selectedMatchLatestChapterNumber =
draftMatchEntry?.latestChapterNumber ?? Number.MIN_SAFE_INTEGER;
2026-03-13 22:41:28 +01:00
const hasNewerChapterVsEntry = latestChapterNumber > entryLatestChapterNumber;
const hasNewerChapterVsSelectedMatch =
latestChapterNumber > selectedMatchLatestChapterNumber;
const hasNewerChapter = hasNewerChapterVsEntry && hasNewerChapterVsSelectedMatch;
2026-03-13 22:41:28 +01:00
2026-05-22 11:33:33 +02:00
const isOutdated = latestChapterNumber < entryLatestChapterNumber;
const hasSameLatestChapterAsSelectedMatch =
!!draftMatchEntry && selectedMatchLatestChapterNumber === latestChapterNumber;
const hasHigherSourcePriority = !MigrationManager.hasHigherSourcePriorityMatch(
mangaId,
destSourceId,
);
const ignoreOutdatedMatch = ignoreOutdatedMatches && isOutdated;
const satisfiesRequireAdditionalChapters = !requireAdditionalChapters || hasNewerChapter;
const ignoreBecauseMissingChapters =
ignoreWithMissingChapters && !!bestMatch.missingChapters;
const isPreferredSourcePriorityMatch =
hasHigherSourcePriority && !selectHighestChapterNumberSource;
const isPreferredChapterNumberMatch =
selectHighestChapterNumberSource &&
(hasNewerChapter ||
(hasHigherSourcePriority && hasSameLatestChapterAsSelectedMatch) ||
!draftMatchEntry);
const isPreferredMatch =
!draftEntry.isManualSelection &&
!ignoreOutdatedMatch &&
satisfiesRequireAdditionalChapters &&
!ignoreBecauseMissingChapters &&
(isPreferredSourcePriorityMatch || isPreferredChapterNumberMatch);
if (isPreferredMatch) {
draftEntry.selectedMatchMangaId = bestMatch.id;
draftEntry.selectedMatchSourceId = destSourceId;
}
if (
!selectHighestChapterNumberSource &&
!MigrationManager.isHigherPrioritySourceUnsettled(mangaId, destSourceId)
) {
searchController.abort(`Found best match in source "${destSourceId}"`);
}
});
}),
);
2026-03-13 22:41:28 +01:00
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 (!MigrationEntries.isSearching(draftEntry)) {
return;
}
2026-03-13 22:41:28 +01:00
if (draftEntry.searchMatches.length) {
if (draftEntry.selectedMatchMangaId != null) {
draftEntry.status = MigrationEntryStatus.SEARCH_COMPLETE;
} else {
draftEntry.status = MigrationEntryStatus.SEARCH_OUTDATED;
}
2026-03-13 22:41:28 +01:00
} else {
draftEntry.status = MigrationEntryStatus.SEARCH_NO_MATCH;
2026-03-13 22:41:28 +01:00
}
});
} catch (error) {
if (mainSignal.aborted) {
2026-03-13 22:41:28 +01:00
return;
}
MigrationManager.updateState((draft) => {
const draftEntry = draft.entries[mangaId];
if (!MigrationEntries.hasStatus(draftEntry, MigrationEntryStatus.SEARCH_ABORTED)) {
2026-05-22 14:13:56 +02:00
draftEntry.status = MigrationEntryStatus.SEARCH_FAILED;
draftEntry.error = getErrorMessage(error);
}
2026-03-13 22:41:28 +01:00
});
} finally {
2026-05-22 14:13:56 +02:00
MigrationManager.abortControllerByManga.delete(mangaId);
2026-03-13 22:41:28 +01:00
}
}
private static async migrateSingleEntry(
mangaId: MangaIdInfo['id'],
options: Omit<MigrateOptions, 'mangaIdToMigrateTo'>,
2026-05-22 14:13:56 +02:00
mainSignal: AbortSignal,
2026-03-13 22:41:28 +01:00
): Promise<void> {
const state = MigrationManager.getState();
const entry = state.entries[mangaId];
2026-05-22 14:13:56 +02:00
const migrateController = new AbortController();
const signal = AbortSignal.any([mainSignal, migrateController.signal]);
if (
!entry ||
!entry.selectedMatchSourceId ||
entry.selectedMatchMangaId == null ||
MigrationEntries.hasStatus(entry, MigrationEntryStatus.MIGRATION_ABORTED)
2026-05-22 14:13:56 +02:00
) {
2026-03-13 22:41:28 +01:00
return;
}
2026-05-22 14:13:56 +02:00
MigrationManager.abortControllerByManga.get(mangaId)?.abort('migrate');
MigrationManager.abortControllerByManga.set(mangaId, migrateController);
2026-03-13 22:41:28 +01:00
MigrationManager.updateState((draft) => {
const draftEntry = draft.entries[mangaId];
draftEntry.status = MigrationEntryStatus.MIGRATING;
draftEntry.error = undefined;
2026-03-13 22:41:28 +01:00
});
try {
if (signal.aborted) {
2026-05-22 14:13:56 +02:00
throw new Error(signal.reason);
2026-03-13 22:41:28 +01:00
}
await MigrationManager.getParallelSourceQueue()(() => {
assertIsDefined(entry.selectedMatchSourceId);
2026-03-13 22:41:28 +01:00
return MigrationManager.getOrCreateSourceQueue(entry.selectedMatchSourceId)(async () => {
if (signal.aborted) {
2026-05-22 14:13:56 +02:00
throw new Error(signal.reason);
}
2026-03-13 22:41:28 +01:00
assertIsDefined(entry.selectedMatchMangaId);
await MangaMigration.migrateByIdWithQuery(mangaId, entry.selectedMatchMangaId, options);
});
});
2026-03-13 22:41:28 +01:00
MigrationManager.updateState((draft) => {
2026-05-22 14:13:56 +02:00
if (!MigrationEntries.isMigrating(entry)) {
return;
}
2026-03-13 22:41:28 +01:00
draft.entries[mangaId].status = MigrationEntryStatus.MIGRATION_COMPLETE;
});
} catch (error) {
2026-05-22 14:13:56 +02:00
if (mainSignal.aborted) {
2026-03-13 22:41:28 +01:00
return;
}
MigrationManager.updateState((draft) => {
2026-05-22 14:13:56 +02:00
const draftEntry = draft.entries[mangaId];
assertIsDefined(draftEntry);
if (!MigrationEntries.hasStatus(draftEntry, MigrationEntryStatus.MIGRATION_ABORTED)) {
2026-05-22 14:13:56 +02:00
draftEntry.status = MigrationEntryStatus.MIGRATION_FAILED;
draftEntry.error = getErrorMessage(error);
}
2026-03-13 22:41:28 +01:00
});
2026-05-22 14:13:56 +02:00
} finally {
MigrationManager.abortControllerByManga.delete(mangaId);
2026-03-13 22:41:28 +01:00
}
}
static async retryEntry(id: MangaIdInfo['id']): Promise<void> {
const { entries, migrateOptions, searchOptions } = MigrationManager.getState();
const entry = entries[id];
2026-03-13 22:41:28 +01:00
if (!entry) {
return;
}
const { signal } = MigrationManager.getOrCreateAbortController();
2026-05-22 14:22:01 +02:00
if (MigrationEntries.hasStatus(entry, MigrationEntryStatus.SEARCH_FAILED)) {
2026-03-13 22:41:28 +01:00
MigrationManager.updateState((draft) => {
draft.entries[id].status = MigrationEntryStatus.SEARCH_PENDING;
2026-03-13 22:41:28 +01:00
});
assertIsDefined(searchOptions);
await MigrationManager.mangaProcessQueue(() =>
MigrationManager.searchForManga(id, entry.mangaTitle, signal, searchOptions),
);
2026-03-13 22:41:28 +01:00
return;
}
2026-05-22 14:22:01 +02:00
if (MigrationEntries.hasStatus(entry, MigrationEntryStatus.MIGRATION_FAILED)) {
2026-03-13 22:41:28 +01:00
MigrationManager.updateState((draft) => {
draft.entries[id].status = MigrationEntryStatus.MIGRATION_PENDING;
2026-03-13 22:41:28 +01:00
});
assertIsDefined(migrateOptions);
2026-03-13 22:41:28 +01:00
await MigrationManager.mangaProcessQueue(() =>
MigrationManager.migrateSingleEntry(id, migrateOptions, signal),
);
2026-03-13 22:41:28 +01:00
}
}
private static updateState(updater: (draft: MigrationState) => void): void {
// Only update the state if the tab is the active executor. Otherwise, updating the state will break the
// migration due to the executor tab rehydrating its state with a potentially outdated version
if (MigrationManager.isResumablePhase() && !MigrationManager.isActive()) {
return;
}
2026-03-13 22:41:28 +01:00
migrationStore.setState(updater);
}
static setEntryMatchesExpandState(id: MangaIdInfo['id'], expanded: boolean): void {
MigrationManager.updateState((draft) => {
const entry = draft.entries[id];
if (entry) {
entry.areMatchesExpanded = expanded;
}
});
}
private static getSearchProgress(state: MigrationState = MigrationManager.getState()): MigrationProgress {
const entries = Object.values(state.entries);
const success = MigrationEntries.getHaveStatus(entries, MigrationEntryStatus.SEARCH_COMPLETE).length;
const failed = MigrationEntries.getHaveStatus(entries, MigrationEntryStatus.SEARCH_FAILED).length;
const outdated = MigrationEntries.getHaveStatus(entries, MigrationEntryStatus.SEARCH_OUTDATED).length;
const noMatch = MigrationEntries.getHaveStatus(entries, MigrationEntryStatus.SEARCH_NO_MATCH).length;
const aborted = MigrationEntries.getHaveStatus(entries, MigrationEntryStatus.SEARCH_ABORTED).length;
return {
total: entries.length,
completed: success + failed + outdated + noMatch + aborted,
success,
failed,
};
}
private static getMigrationProgress(state: MigrationState = MigrationManager.getState()): MigrationProgress {
const entries = Object.values(state.entries);
const total = MigrationEntries.getHaveStatus(
entries,
MigrationEntryStatus.MIGRATION_PENDING,
MigrationEntryStatus.MIGRATING,
MigrationEntryStatus.MIGRATION_ABORTED,
MigrationEntryStatus.MIGRATION_FAILED,
MigrationEntryStatus.MIGRATION_COMPLETE,
).length;
const success = MigrationEntries.getHaveStatus(entries, MigrationEntryStatus.MIGRATION_COMPLETE).length;
const failed = MigrationEntries.getHaveStatus(entries, MigrationEntryStatus.MIGRATION_FAILED).length;
const aborted = MigrationEntries.getHaveStatus(entries, MigrationEntryStatus.MIGRATION_ABORTED).length;
return {
total,
completed: success + failed + aborted,
success,
failed,
};
}
2026-03-13 22:41:28 +01:00
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 useSourceIds(): MigrationState['sourceIds'] {
return useMigrationStore((state) => state.sourceIds);
2026-03-13 22:41:28 +01:00
}
static useEntries(): Record<number, TMigrationEntry> {
return useMigrationStore((state) => state.entries);
}
static useSearchProgress(): MigrationProgress {
return useMigrationStore((state) => MigrationManager.getSearchProgress(state));
2026-03-13 22:41:28 +01:00
}
static useMigrationProgress(): MigrationProgress {
return useMigrationStore((state) => MigrationManager.getMigrationProgress(state));
2026-03-13 22:41:28 +01:00
}
static useIsActive(): boolean {
// Listen to phase changes
MigrationManager.usePhase();
return MigrationManager.isActive();
}
}