Add option to abort migration entry

This commit is contained in:
schroda
2026-05-22 14:13:56 +02:00
parent afb2c0cc0f
commit a876879d0e
11 changed files with 259 additions and 77 deletions

View File

@@ -11,6 +11,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- (**Migration**) Add a search option to ignore outdated matches - (**Migration**) Add a search option to ignore outdated matches
- (**Migration**) Add a search option to ignore matches with missing chapters - (**Migration**) Add a search option to ignore matches with missing chapters
- (**Migration**) Add "local source" as a possible destination source - (**Migration**) Add "local source" as a possible destination source
- (**Migration**) Add option to abort entries that are searching or are waiting to get migrated
### Changed ### Changed

View File

@@ -8,8 +8,8 @@
import type { MessageDescriptor } from '@lingui/core'; import type { MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro'; import { msg } from '@lingui/core/macro';
import { MigrationEntryStatus, MigrationPhase, SortBy, SortOrder } from '@/features/migration/Migration.types.ts';
import type { MigrationState } from '@/features/migration/Migration.types.ts'; import type { MigrationState } from '@/features/migration/Migration.types.ts';
import { MigrationEntryStatus, MigrationPhase, SortBy, SortOrder } from '@/features/migration/Migration.types.ts';
export const sortByToTranslation: Record<SortBy, MessageDescriptor> = { export const sortByToTranslation: Record<SortBy, MessageDescriptor> = {
[SortBy.SOURCE_NAME]: msg`By source name`, [SortBy.SOURCE_NAME]: msg`By source name`,
@@ -48,21 +48,24 @@ export const DEFAULT_MIGRATION_STATE: MigrationState = {
}; };
export const ENTRY_STATUS_TRANSLATION: Record<MigrationEntryStatus, MessageDescriptor> = { export const ENTRY_STATUS_TRANSLATION: Record<MigrationEntryStatus, MessageDescriptor> = {
[MigrationEntryStatus.PENDING]: msg`Pending…`, [MigrationEntryStatus.SEARCH_PENDING]: msg`Pending…`,
[MigrationEntryStatus.SEARCHING]: msg`Searching…`, [MigrationEntryStatus.SEARCHING]: msg`Searching…`,
[MigrationEntryStatus.SEARCH_COMPLETE]: msg`Match found`, [MigrationEntryStatus.SEARCH_COMPLETE]: msg`Match found`,
[MigrationEntryStatus.SEARCH_FAILED]: msg`Search failed`, [MigrationEntryStatus.SEARCH_FAILED]: msg`Search failed`,
[MigrationEntryStatus.NO_MATCH]: msg`No match found`, [MigrationEntryStatus.NO_MATCH]: msg`No match found`,
[MigrationEntryStatus.OUTDATED]: msg`Only outdated matches found`, [MigrationEntryStatus.OUTDATED]: msg`Only outdated matches found`,
[MigrationEntryStatus.MIGRATION_PENDING]: msg`Pending…`,
[MigrationEntryStatus.MIGRATING]: msg`Migrating…`, [MigrationEntryStatus.MIGRATING]: msg`Migrating…`,
[MigrationEntryStatus.MIGRATION_COMPLETE]: msg`Successfully migrated`, [MigrationEntryStatus.MIGRATION_COMPLETE]: msg`Successfully migrated`,
[MigrationEntryStatus.MIGRATION_FAILED]: msg`Migration failed`, [MigrationEntryStatus.MIGRATION_FAILED]: msg`Migration failed`,
[MigrationEntryStatus.EXCLUDED]: msg`Excluded`, [MigrationEntryStatus.EXCLUDED]: msg`Excluded`,
[MigrationEntryStatus.ABORTED]: msg`Aborted`,
}; };
export const MIGRATE_SEARCH_ENTRY_GROUPS = [ export const MIGRATE_SEARCH_ENTRY_GROUPS = [
MigrationEntryStatus.SEARCHING, MigrationEntryStatus.SEARCHING,
MigrationEntryStatus.SEARCH_FAILED, MigrationEntryStatus.SEARCH_FAILED,
MigrationEntryStatus.ABORTED,
MigrationEntryStatus.NO_MATCH, MigrationEntryStatus.NO_MATCH,
MigrationEntryStatus.OUTDATED, MigrationEntryStatus.OUTDATED,
MigrationEntryStatus.SEARCH_COMPLETE, MigrationEntryStatus.SEARCH_COMPLETE,
@@ -74,6 +77,7 @@ export const MIGRATE_SEARCH_ENTRY_GROUP_EXPAND_DEFAULT_STATE: Record<
> = { > = {
[MigrationEntryStatus.SEARCHING]: true, [MigrationEntryStatus.SEARCHING]: true,
[MigrationEntryStatus.SEARCH_FAILED]: false, [MigrationEntryStatus.SEARCH_FAILED]: false,
[MigrationEntryStatus.ABORTED]: false,
[MigrationEntryStatus.NO_MATCH]: false, [MigrationEntryStatus.NO_MATCH]: false,
[MigrationEntryStatus.OUTDATED]: false, [MigrationEntryStatus.OUTDATED]: false,
[MigrationEntryStatus.SEARCH_COMPLETE]: false, [MigrationEntryStatus.SEARCH_COMPLETE]: false,
@@ -82,6 +86,7 @@ export const MIGRATE_SEARCH_ENTRY_GROUP_EXPAND_DEFAULT_STATE: Record<
export const MIGRATE_EXECUTE_ENTRY_GROUPS = [ export const MIGRATE_EXECUTE_ENTRY_GROUPS = [
MigrationEntryStatus.MIGRATING, MigrationEntryStatus.MIGRATING,
MigrationEntryStatus.MIGRATION_FAILED, MigrationEntryStatus.MIGRATION_FAILED,
MigrationEntryStatus.ABORTED,
MigrationEntryStatus.NO_MATCH, MigrationEntryStatus.NO_MATCH,
MigrationEntryStatus.OUTDATED, MigrationEntryStatus.OUTDATED,
MigrationEntryStatus.EXCLUDED, MigrationEntryStatus.EXCLUDED,
@@ -94,6 +99,7 @@ export const MIGRATE_EXECUTE_ENTRY_GROUP_EXPAND_DEFAULT_STATE: Record<
> = { > = {
[MigrationEntryStatus.MIGRATING]: true, [MigrationEntryStatus.MIGRATING]: true,
[MigrationEntryStatus.MIGRATION_FAILED]: false, [MigrationEntryStatus.MIGRATION_FAILED]: false,
[MigrationEntryStatus.ABORTED]: false,
[MigrationEntryStatus.NO_MATCH]: false, [MigrationEntryStatus.NO_MATCH]: false,
[MigrationEntryStatus.OUTDATED]: false, [MigrationEntryStatus.OUTDATED]: false,
[MigrationEntryStatus.EXCLUDED]: false, [MigrationEntryStatus.EXCLUDED]: false,

View File

@@ -87,16 +87,18 @@ export enum MigrationPhase {
} }
export enum MigrationEntryStatus { export enum MigrationEntryStatus {
PENDING = 'pending', SEARCH_PENDING = 'search_pending',
SEARCHING = 'searching', SEARCHING = 'searching',
SEARCH_COMPLETE = 'search_complete', SEARCH_COMPLETE = 'search_complete',
SEARCH_FAILED = 'search_failed', SEARCH_FAILED = 'search_failed',
OUTDATED = 'outdated', OUTDATED = 'outdated',
NO_MATCH = 'no_match', NO_MATCH = 'no_match',
MIGRATION_PENDING = 'migration_pending',
MIGRATING = 'migrating', MIGRATING = 'migrating',
MIGRATION_COMPLETE = 'migration_complete', MIGRATION_COMPLETE = 'migration_complete',
MIGRATION_FAILED = 'migration_failed', MIGRATION_FAILED = 'migration_failed',
EXCLUDED = 'excluded', EXCLUDED = 'excluded',
ABORTED = 'aborted',
} }
export interface MigrationMatch export interface MigrationMatch

View File

@@ -10,16 +10,35 @@ import type { MigratableEntry, TMigrationEntry } from '@/features/migration/Migr
import { MigrationEntryStatus } from '@/features/migration/Migration.types.ts'; import { MigrationEntryStatus } from '@/features/migration/Migration.types.ts';
export class MigrationEntries { export class MigrationEntries {
public static isAbortable(entry: TMigrationEntry): boolean {
return (
MigrationEntries.isSearching(entry) ||
MigrationEntries.hasStatus(entry, MigrationEntryStatus.MIGRATION_PENDING)
);
}
public static isSearching(entry: TMigrationEntry): boolean { public static isSearching(entry: TMigrationEntry): boolean {
return [MigrationEntryStatus.PENDING, MigrationEntryStatus.SEARCHING].includes(entry.status); return MigrationEntries.hasStatus(entry, MigrationEntryStatus.SEARCH_PENDING, MigrationEntryStatus.SEARCHING);
}
public static isMigrating(entry: TMigrationEntry): boolean {
return MigrationEntries.hasStatus(
entry,
MigrationEntryStatus.MIGRATION_PENDING,
MigrationEntryStatus.MIGRATING,
);
} }
public static getExcluded(entries: TMigrationEntry[]): TMigrationEntry[] { public static getExcluded(entries: TMigrationEntry[]): TMigrationEntry[] {
return entries.filter((entry) => entry.isExcluded); return entries.filter((entry) => entry.isExcluded);
} }
public static hasStatus(entry: TMigrationEntry, ...statuses: MigrationEntryStatus[]): boolean {
return statuses.includes(entry.status);
}
public static getHaveStatus(entries: TMigrationEntry[], ...statuses: MigrationEntryStatus[]): TMigrationEntry[] { public static getHaveStatus(entries: TMigrationEntry[], ...statuses: MigrationEntryStatus[]): TMigrationEntry[] {
return entries.filter((entry) => statuses.includes(entry.status)); return entries.filter((entry) => MigrationEntries.hasStatus(entry, ...statuses));
} }
public static getHaveStatusSorted( public static getHaveStatusSorted(
@@ -55,6 +74,7 @@ export class MigrationEntries {
MigrationEntries.hasStatus( MigrationEntries.hasStatus(
entry, entry,
MigrationEntryStatus.SEARCH_COMPLETE, MigrationEntryStatus.SEARCH_COMPLETE,
MigrationEntryStatus.MIGRATION_PENDING,
MigrationEntryStatus.MIGRATING, MigrationEntryStatus.MIGRATING,
) && ) &&
!entry.isExcluded && !entry.isExcluded &&

View File

@@ -100,7 +100,7 @@ export class MigrationManager {
private static mangaProcessQueue = pLimit(MAX_MANGAS_IN_PARALLEL); private static mangaProcessQueue = pLimit(MAX_MANGAS_IN_PARALLEL);
private static parallelSourcesQueue: LimitFunction | undefined; private static parallelSourcesQueue: LimitFunction | undefined;
private static queueBySource = new Map<SourceIdInfo['id'], LimitFunction>(); private static queueBySource = new Map<SourceIdInfo['id'], LimitFunction>();
private static searchAbortControllerByManga = new Map<MangaIdInfo['id'], AbortController>(); private static abortControllerByManga = new Map<MangaIdInfo['id'], AbortController>();
private static abortAndResetAbortController(reason: unknown): void { private static abortAndResetAbortController(reason: unknown): void {
MigrationManager.abortController?.abort(reason); MigrationManager.abortController?.abort(reason);
@@ -344,7 +344,7 @@ export class MigrationManager {
mangaThumbnailUrl: manga.thumbnailUrl, mangaThumbnailUrl: manga.thumbnailUrl,
sourceId: manga.sourceId, sourceId: manga.sourceId,
sourceTitle: manga.source?.displayName, sourceTitle: manga.source?.displayName,
status: MigrationEntryStatus.PENDING, status: MigrationEntryStatus.SEARCH_PENDING,
searchMatches: [], searchMatches: [],
manualMatches: [], manualMatches: [],
selectedMatchMangaId: null, selectedMatchMangaId: null,
@@ -384,10 +384,16 @@ export class MigrationManager {
private static async search(entries: TMigrationEntry[], options: MigrationBulkSearchSettings): Promise<void> { private static async search(entries: TMigrationEntry[], options: MigrationBulkSearchSettings): Promise<void> {
const { signal } = MigrationManager.abortAndCreateAbortController('search'); const { signal } = MigrationManager.abortAndCreateAbortController('search');
MigrationManager.updateState((draft) =>
entries.forEach((entry) => {
draft.entries[entry.mangaId].status = MigrationEntryStatus.SEARCH_PENDING;
}),
);
const searchPromises = entries.map((entry) => const searchPromises = entries.map((entry) =>
MigrationManager.mangaProcessQueue(async () => { MigrationManager.mangaProcessQueue(async () => {
if (signal.aborted) { if (signal.aborted) {
return; throw new Error(signal.reason);
} }
await MigrationManager.searchForManga(entry.mangaId, entry.mangaTitle, signal, options); await MigrationManager.searchForManga(entry.mangaId, entry.mangaTitle, signal, options);
@@ -447,13 +453,19 @@ export class MigrationManager {
): Promise<void> { ): Promise<void> {
const { signal } = MigrationManager.abortAndCreateAbortController('migrate'); const { signal } = MigrationManager.abortAndCreateAbortController('migrate');
MigrationManager.updateState((draft) => {
entries.forEach((entry) => {
draft.entries[entry.mangaId].status = MigrationEntryStatus.MIGRATION_PENDING;
});
});
const entriesBySource = Object.groupBy(entries, (entry) => entry.selectedMatchSourceId); const entriesBySource = Object.groupBy(entries, (entry) => entry.selectedMatchSourceId);
const migrationPromises = Object.values(entriesBySource).map((sourceEntries = []) => const migrationPromises = Object.values(entriesBySource).map((sourceEntries = []) =>
MigrationManager.mangaProcessQueue(async () => { MigrationManager.mangaProcessQueue(async () => {
for (const entry of sourceEntries) { for (const entry of sourceEntries) {
if (signal.aborted) { if (signal.aborted) {
return; throw new Error(signal.reason);
} }
// oxlint-disable-next-line no-await-in-loop // oxlint-disable-next-line no-await-in-loop
@@ -530,7 +542,7 @@ export class MigrationManager {
MigrationManager.updateState((draft) => { MigrationManager.updateState((draft) => {
migratableEntries.forEach((entry) => { migratableEntries.forEach((entry) => {
draft.entries[entry.mangaId].status = MigrationEntryStatus.SEARCH_COMPLETE; draft.entries[entry.mangaId].status = MigrationEntryStatus.MIGRATION_PENDING;
}); });
}); });
@@ -543,13 +555,13 @@ export class MigrationManager {
const pendingEntries = MigrationEntries.getHaveStatus( const pendingEntries = MigrationEntries.getHaveStatus(
Object.values(entries), Object.values(entries),
MigrationEntryStatus.PENDING, MigrationEntryStatus.SEARCH_PENDING,
MigrationEntryStatus.SEARCHING, MigrationEntryStatus.SEARCHING,
); );
MigrationManager.updateState((draft) => { MigrationManager.updateState((draft) => {
pendingEntries.forEach((entry) => { pendingEntries.forEach((entry) => {
draft.entries[entry.mangaId].status = MigrationEntryStatus.PENDING; draft.entries[entry.mangaId].status = MigrationEntryStatus.SEARCH_PENDING;
}); });
}); });
@@ -581,6 +593,28 @@ export class MigrationManager {
}); });
} }
static abortEntry(mangaId: MangaIdInfo['id']): void {
const { entries, phase } = MigrationManager.getState();
const entry = entries[mangaId];
assertIsDefined(entry);
MigrationManager.abortControllerByManga.get(mangaId)?.abort('User aborted');
MigrationManager.updateState((draft) => {
const draftEntry = draft.entries[mangaId];
assertIsDefined(draftEntry);
draftEntry.status = MigrationEntryStatus.ABORTED;
if (phase === MigrationPhase.SEARCHING) {
draft.searchProgress.completed += 1;
} else {
draft.migrationProgress.completed += 1;
}
});
}
static selectMatch( static selectMatch(
mangaId: MangaIdInfo['id'], mangaId: MangaIdInfo['id'],
targetMangaId: MangaIdInfo['id'], targetMangaId: MangaIdInfo['id'],
@@ -628,7 +662,7 @@ export class MigrationManager {
if (isSearching) { if (isSearching) {
draft.searchProgress.completed += 1; draft.searchProgress.completed += 1;
draft.searchProgress.success += 1; draft.searchProgress.success += 1;
MigrationManager.searchAbortControllerByManga.get(mangaId)?.abort('Manual match selected'); MigrationManager.abortControllerByManga.get(mangaId)?.abort('Manual match selected');
} }
} }
}); });
@@ -792,6 +826,7 @@ export class MigrationManager {
() => () =>
requestManager.refreshManga(match.id, { requestManager.refreshManga(match.id, {
awaitRefetchQueries: true, awaitRefetchQueries: true,
context: { fetchOptions: { signal } },
}).response, }).response,
); );
@@ -836,13 +871,13 @@ export class MigrationManager {
const searchController = new AbortController(); const searchController = new AbortController();
const signal = AbortSignal.any([mainSignal, searchController.signal]); const signal = AbortSignal.any([mainSignal, searchController.signal]);
MigrationManager.searchAbortControllerByManga.get(mangaId)?.abort('search'); if (!entry || MigrationEntries.hasStatus(entry, MigrationEntryStatus.ABORTED)) {
MigrationManager.searchAbortControllerByManga.set(mangaId, searchController);
if (!entry) {
return; return;
} }
MigrationManager.abortControllerByManga.get(mangaId)?.abort('search');
MigrationManager.abortControllerByManga.set(mangaId, searchController);
MigrationManager.updateState((draft) => { MigrationManager.updateState((draft) => {
draft.entries[mangaId].status = MigrationEntryStatus.SEARCHING; draft.entries[mangaId].status = MigrationEntryStatus.SEARCHING;
}); });
@@ -853,7 +888,7 @@ export class MigrationManager {
.map((destSourceId) => .map((destSourceId) =>
MigrationManager.getParallelSourceQueue()(async () => { MigrationManager.getParallelSourceQueue()(async () => {
if (signal.aborted) { if (signal.aborted) {
return null; throw new Error(signal.reason);
} }
if ( if (
@@ -1017,35 +1052,48 @@ export class MigrationManager {
MigrationManager.updateState((draft) => { MigrationManager.updateState((draft) => {
const draftEntry = draft.entries[mangaId]; const draftEntry = draft.entries[mangaId];
draftEntry.status = MigrationEntryStatus.SEARCH_FAILED; if (!MigrationEntries.hasStatus(draftEntry, MigrationEntryStatus.ABORTED)) {
draftEntry.error = getErrorMessage(error); draftEntry.status = MigrationEntryStatus.SEARCH_FAILED;
draft.searchProgress.completed += 1; draftEntry.error = getErrorMessage(error);
draft.searchProgress.failed += 1; draft.searchProgress.completed += 1;
draft.searchProgress.failed += 1;
}
}); });
} finally { } finally {
MigrationManager.searchAbortControllerByManga.delete(mangaId); MigrationManager.abortControllerByManga.delete(mangaId);
} }
} }
private static async migrateSingleEntry( private static async migrateSingleEntry(
mangaId: MangaIdInfo['id'], mangaId: MangaIdInfo['id'],
options: Omit<MigrateOptions, 'mangaIdToMigrateTo'>, options: Omit<MigrateOptions, 'mangaIdToMigrateTo'>,
signal: AbortSignal, mainSignal: AbortSignal,
): Promise<void> { ): Promise<void> {
const state = MigrationManager.getState(); const state = MigrationManager.getState();
const entry = state.entries[mangaId]; const entry = state.entries[mangaId];
if (!entry || !entry.selectedMatchSourceId || entry.selectedMatchMangaId == null) { const migrateController = new AbortController();
const signal = AbortSignal.any([mainSignal, migrateController.signal]);
if (
!entry ||
!entry.selectedMatchSourceId ||
entry.selectedMatchMangaId == null ||
MigrationEntries.hasStatus(entry, MigrationEntryStatus.ABORTED)
) {
return; return;
} }
MigrationManager.abortControllerByManga.get(mangaId)?.abort('migrate');
MigrationManager.abortControllerByManga.set(mangaId, migrateController);
MigrationManager.updateState((draft) => { MigrationManager.updateState((draft) => {
draft.entries[mangaId].status = MigrationEntryStatus.MIGRATING; draft.entries[mangaId].status = MigrationEntryStatus.MIGRATING;
}); });
try { try {
if (signal.aborted) { if (signal.aborted) {
return; throw new Error(signal.reason);
} }
await MigrationManager.getParallelSourceQueue()(() => { await MigrationManager.getParallelSourceQueue()(() => {
@@ -1053,7 +1101,7 @@ export class MigrationManager {
return MigrationManager.getOrCreateSourceQueue(entry.selectedMatchSourceId)(async () => { return MigrationManager.getOrCreateSourceQueue(entry.selectedMatchSourceId)(async () => {
if (signal.aborted) { if (signal.aborted) {
return; throw new Error(signal.reason);
} }
assertIsDefined(entry.selectedMatchMangaId); assertIsDefined(entry.selectedMatchMangaId);
@@ -1063,21 +1111,33 @@ export class MigrationManager {
}); });
MigrationManager.updateState((draft) => { MigrationManager.updateState((draft) => {
if (!MigrationEntries.isMigrating(entry)) {
return;
}
draft.entries[mangaId].status = MigrationEntryStatus.MIGRATION_COMPLETE; draft.entries[mangaId].status = MigrationEntryStatus.MIGRATION_COMPLETE;
draft.migrationProgress.success += 1; draft.migrationProgress.success += 1;
draft.migrationProgress.completed += 1; draft.migrationProgress.completed += 1;
}); });
} catch (error) { } catch (error) {
if (signal.aborted) { if (mainSignal.aborted) {
return; return;
} }
MigrationManager.updateState((draft) => { MigrationManager.updateState((draft) => {
draft.entries[mangaId].status = MigrationEntryStatus.MIGRATION_FAILED; const draftEntry = draft.entries[mangaId];
draft.entries[mangaId].error = error instanceof Error ? error.message : String(error);
draft.migrationProgress.failed += 1; assertIsDefined(draftEntry);
draft.migrationProgress.completed += 1;
if (!MigrationEntries.hasStatus(draftEntry, MigrationEntryStatus.ABORTED)) {
draftEntry.status = MigrationEntryStatus.MIGRATION_FAILED;
draftEntry.error = getErrorMessage(error);
draft.migrationProgress.failed += 1;
draft.migrationProgress.completed += 1;
}
}); });
} finally {
MigrationManager.abortControllerByManga.delete(mangaId);
} }
} }

View File

@@ -24,12 +24,14 @@ export const MigrationEntryGroup = memo(
entries, entries,
color, color,
isMigrating = false, isMigrating = false,
isAborted = false,
}: { }: {
status: MigrationEntryStatus; status: MigrationEntryStatus;
title: string; title: string;
entries: TMigrationEntry[]; entries: TMigrationEntry[];
color: ButtonProps['color']; color: ButtonProps['color'];
isMigrating?: boolean; isMigrating?: boolean;
isAborted?: boolean;
}) => { }) => {
const isExpanded = MigrationManager.useGroupExpandState(status); const isExpanded = MigrationManager.useGroupExpandState(status);
@@ -60,7 +62,12 @@ export const MigrationEntryGroup = memo(
<Collapse in={isExpanded} unmountOnExit> <Collapse in={isExpanded} unmountOnExit>
<Stack sx={{ gap: 1 }}> <Stack sx={{ gap: 1 }}>
{entries.map((entry) => ( {entries.map((entry) => (
<MigrationEntry key={entry.mangaId} entry={entry} isMigrating={isMigrating} /> <MigrationEntry
key={entry.mangaId}
entry={entry}
isMigrating={isMigrating}
isAborted={isAborted}
/>
))} ))}
</Stack> </Stack>
</Collapse> </Collapse>

View File

@@ -25,6 +25,7 @@ import Collapse from '@mui/material/Collapse';
import Divider from '@mui/material/Divider'; import Divider from '@mui/material/Divider';
import { MigrationEntryStatusIndicator } from '@/features/migration/components/migration-entry/MigrationEntryStatusIndicator.tsx'; import { MigrationEntryStatusIndicator } from '@/features/migration/components/migration-entry/MigrationEntryStatusIndicator.tsx';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { MigrationEntries } from '@/features/migration/MigrationEntries.ts';
const MigrationEntryMobile = memo( const MigrationEntryMobile = memo(
({ ({
@@ -35,6 +36,7 @@ const MigrationEntryMobile = memo(
isExpanded, isExpanded,
setIsExpanded, setIsExpanded,
isMigrating, isMigrating,
isAborted,
}: { }: {
entry: TMigrationEntry; entry: TMigrationEntry;
destinationEntry: MigrationMatch | undefined; destinationEntry: MigrationMatch | undefined;
@@ -42,6 +44,7 @@ const MigrationEntryMobile = memo(
isExpanded: boolean; isExpanded: boolean;
setIsExpanded: (expanded: boolean) => void; setIsExpanded: (expanded: boolean) => void;
isMigrating: boolean; isMigrating: boolean;
isAborted: boolean;
}) => { }) => {
const { t } = useLingui(); const { t } = useLingui();
@@ -89,17 +92,17 @@ const MigrationEntryMobile = memo(
</Stack> </Stack>
</Collapse> </Collapse>
{!isMigrating && ( <MigrationEntrySearchExcludeActions
<MigrationEntrySearchExcludeActions hasSelectedMatch={!!destinationEntry}
hasSelectedMatch={!!destinationEntry} otherResultsCount={otherSearchMatches.length}
otherResultsCount={otherSearchMatches.length} isExpanded={isExpanded}
isExpanded={isExpanded} setIsExpanded={setIsExpanded}
setIsExpanded={setIsExpanded} isExcluded={isExcluded}
isExcluded={isExcluded} mangaId={mangaId}
mangaId={mangaId} mangaTitle={mangaTitle}
mangaTitle={mangaTitle} isAbortable={!isAborted && MigrationEntries.isAbortable(entry)}
/> isMigrating={isMigrating}
)} />
</> </>
); );
}, },
@@ -114,6 +117,7 @@ export const MigrationEntryDesktop = memo(
isExpanded, isExpanded,
setIsExpanded, setIsExpanded,
isMigrating, isMigrating,
isAborted,
}: { }: {
entry: TMigrationEntry; entry: TMigrationEntry;
destinationEntry: MigrationMatch | undefined; destinationEntry: MigrationMatch | undefined;
@@ -121,6 +125,7 @@ export const MigrationEntryDesktop = memo(
isExpanded: boolean; isExpanded: boolean;
setIsExpanded: (expanded: boolean) => void; setIsExpanded: (expanded: boolean) => void;
isMigrating: boolean; isMigrating: boolean;
isAborted: boolean;
}) => { }) => {
const { t } = useLingui(); const { t } = useLingui();
@@ -151,17 +156,17 @@ export const MigrationEntryDesktop = memo(
/> />
</Box> </Box>
{!isMigrating && ( <MigrationEntrySearchExcludeActions
<MigrationEntrySearchExcludeActions hasSelectedMatch={!!destinationEntry}
hasSelectedMatch={!!destinationEntry} otherResultsCount={otherSearchMatches.length}
otherResultsCount={otherSearchMatches.length} isExpanded={isExpanded}
isExpanded={isExpanded} setIsExpanded={setIsExpanded}
setIsExpanded={setIsExpanded} isExcluded={entry.isExcluded}
isExcluded={entry.isExcluded} mangaId={entry.mangaId}
mangaId={entry.mangaId} mangaTitle={entry.mangaTitle}
mangaTitle={entry.mangaTitle} isAbortable={!isAborted && MigrationEntries.isAbortable(entry)}
/> isMigrating={isMigrating}
)} />
</Box> </Box>
</Box> </Box>
@@ -190,7 +195,15 @@ export const MigrationEntryDesktop = memo(
); );
export const MigrationEntry = memo( export const MigrationEntry = memo(
({ entry: propEntry, isMigrating }: { entry: TMigrationEntry; isMigrating: boolean }) => { ({
entry: propEntry,
isMigrating,
isAborted,
}: {
entry: TMigrationEntry;
isMigrating: boolean;
isAborted: boolean;
}) => {
const isTabletWidth = MediaQuery.useIsTabletWidth(); const isTabletWidth = MediaQuery.useIsTabletWidth();
const entry = useMemo(() => MigrationManager.getUpToDateMigrationEntry(propEntry), [propEntry]); const entry = useMemo(() => MigrationManager.getUpToDateMigrationEntry(propEntry), [propEntry]);
@@ -255,6 +268,7 @@ export const MigrationEntry = memo(
} }
otherSearchMatches={otherMatches} otherSearchMatches={otherMatches}
isMigrating={isMigrating} isMigrating={isMigrating}
isAborted={isAborted}
/> />
</Paper> </Paper>
); );

View File

@@ -22,6 +22,7 @@ import ExpandLessIcon from '@mui/icons-material/ExpandLess';
import ButtonGroup from '@mui/material/ButtonGroup'; import ButtonGroup from '@mui/material/ButtonGroup';
import RemoveCircleOutlineOutlinedIcon from '@mui/icons-material/RemoveCircleOutlineOutlined'; import RemoveCircleOutlineOutlinedIcon from '@mui/icons-material/RemoveCircleOutlineOutlined';
import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined'; import AddCircleOutlineOutlinedIcon from '@mui/icons-material/AddCircleOutlineOutlined';
import CancelOutlinedIcon from '@mui/icons-material/CancelOutlined';
export const MigrationEntrySearchExcludeActions = ({ export const MigrationEntrySearchExcludeActions = ({
hasSelectedMatch, hasSelectedMatch,
@@ -31,6 +32,8 @@ export const MigrationEntrySearchExcludeActions = ({
isExcluded, isExcluded,
mangaId, mangaId,
mangaTitle, mangaTitle,
isAbortable,
isMigrating,
}: { }: {
hasSelectedMatch: boolean; hasSelectedMatch: boolean;
otherResultsCount: number; otherResultsCount: number;
@@ -39,6 +42,8 @@ export const MigrationEntrySearchExcludeActions = ({
isExcluded: boolean; isExcluded: boolean;
mangaId: MangaIdInfo['id']; mangaId: MangaIdInfo['id'];
mangaTitle: string; mangaTitle: string;
isAbortable: boolean;
isMigrating: boolean;
}) => { }) => {
const { t } = useLingui(); const { t } = useLingui();
const isTabletWidth = MediaQuery.useIsTabletWidth(); const isTabletWidth = MediaQuery.useIsTabletWidth();
@@ -46,7 +51,7 @@ export const MigrationEntrySearchExcludeActions = ({
if (isTabletWidth) { if (isTabletWidth) {
return ( return (
<ButtonGroup variant="contained"> <ButtonGroup variant="contained">
{!!otherResultsCount && ( {!isMigrating && !!otherResultsCount && (
<Button <Button
sx={{ flexGrow: 1 }} sx={{ flexGrow: 1 }}
startIcon={isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />} startIcon={isExpanded ? <ExpandLessIcon /> : <ExpandMoreIcon />}
@@ -58,7 +63,7 @@ export const MigrationEntrySearchExcludeActions = ({
})} })}
</Button> </Button>
)} )}
{!isExpanded && ( {!isMigrating && !isExpanded && (
<CustomTooltip title={t`Manual search`}> <CustomTooltip title={t`Manual search`}>
<CustomButtonIcon <CustomButtonIcon
sx={{ sx={{
@@ -72,7 +77,7 @@ export const MigrationEntrySearchExcludeActions = ({
</CustomButtonIcon> </CustomButtonIcon>
</CustomTooltip> </CustomTooltip>
)} )}
{hasSelectedMatch && ( {!isMigrating && hasSelectedMatch && (
<CustomTooltip title={isExcluded ? t`Include` : t`Exclude`}> <CustomTooltip title={isExcluded ? t`Include` : t`Exclude`}>
<CustomButtonIcon <CustomButtonIcon
sx={{ sx={{
@@ -88,13 +93,32 @@ export const MigrationEntrySearchExcludeActions = ({
</CustomButtonIcon> </CustomButtonIcon>
</CustomTooltip> </CustomTooltip>
)} )}
{isAbortable && (
<CustomTooltip title={t`Abort`}>
<CustomButtonIcon
sx={{
flexGrow: Number(!otherResultsCount),
}}
onClick={() => MigrationManager.abortEntry(mangaId)}
>
<CancelOutlinedIcon />
</CustomButtonIcon>
</CustomTooltip>
)}
</ButtonGroup> </ButtonGroup>
); );
} }
return ( return (
<Stack sx={{ gap: 1, justifyContent: 'center' }}> <Stack sx={{ gap: 1, justifyContent: 'center' }}>
{hasSelectedMatch && ( {isAbortable && (
<CustomTooltip title={t`Abort`} placement="auto">
<IconButton onClick={() => MigrationManager.abortEntry(mangaId)}>
<CancelOutlinedIcon />
</IconButton>
</CustomTooltip>
)}
{!isMigrating && hasSelectedMatch && (
<CustomTooltip title={isExcluded ? t`Include` : t`Exclude`} placement="auto"> <CustomTooltip title={isExcluded ? t`Include` : t`Exclude`} placement="auto">
<IconButton <IconButton
onClick={() => onClick={() =>
@@ -105,15 +129,17 @@ export const MigrationEntrySearchExcludeActions = ({
</IconButton> </IconButton>
</CustomTooltip> </CustomTooltip>
)} )}
<CustomTooltip title={t`Manual search`} placement="auto"> {!isMigrating && (
<IconButton <CustomTooltip title={t`Manual search`} placement="auto">
onClick={() => { <IconButton
MigrationManager.openManualSearch(mangaId, mangaTitle); onClick={() => {
}} MigrationManager.openManualSearch(mangaId, mangaTitle);
> }}
<SearchIcon /> >
</IconButton> <SearchIcon />
</CustomTooltip> </IconButton>
</CustomTooltip>
)}
</Stack> </Stack>
); );
}; };

View File

@@ -11,7 +11,7 @@ import { MigrationProgressBar } from '@/features/migration/components/MigrationP
import { useLingui } from '@lingui/react/macro'; import { useLingui } from '@lingui/react/macro';
import { useAppTitleAndAction } from '@/features/navigation-bar/hooks/useAppTitleAndAction.ts'; import { useAppTitleAndAction } from '@/features/navigation-bar/hooks/useAppTitleAndAction.ts';
import { MigrationManager } from '@/features/migration/MigrationManager.ts'; import { MigrationManager } from '@/features/migration/MigrationManager.ts';
import { MigrationEntryStatus } from '@/features/migration/Migration.types.ts'; import { MigrationEntryStatus, MigrationPhase } from '@/features/migration/Migration.types.ts';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { DEFAULT_FULL_FAB_HEIGHT } from '@/base/components/buttons/StyledFab.tsx'; import { DEFAULT_FULL_FAB_HEIGHT } from '@/base/components/buttons/StyledFab.tsx';
import { MigrationContinueButton } from '@/features/migration/components/MigrationContinueButton.tsx'; import { MigrationContinueButton } from '@/features/migration/components/MigrationContinueButton.tsx';
@@ -21,6 +21,8 @@ import { MigrationEntries } from '@/features/migration/MigrationEntries.ts';
export const MigrationExecute = () => { export const MigrationExecute = () => {
const { t } = useLingui(); const { t } = useLingui();
const phase = MigrationManager.usePhase();
const entries = MigrationManager.useEntries(); const entries = MigrationManager.useEntries();
const progress = MigrationManager.useMigrationProgress(); const progress = MigrationManager.useMigrationProgress();
@@ -40,13 +42,15 @@ export const MigrationExecute = () => {
[MigrationManager.isPhaseComplete()], [MigrationManager.isPhaseComplete()],
); );
const isMigrating = phase === MigrationPhase.MIGRATING;
const entryList = useMemo(() => Object.values(entries), [entries]); const entryList = useMemo(() => Object.values(entries), [entries]);
const migratingEntries = useMemo( const migratingEntries = useMemo(
() => () =>
MigrationEntries.getActiveEntriesSorted( MigrationEntries.getActiveEntriesSorted(
entryList, entryList,
MigrationEntryStatus.MIGRATING, MigrationEntryStatus.MIGRATING,
MigrationEntryStatus.SEARCH_COMPLETE, MigrationEntryStatus.MIGRATION_PENDING,
MigrationEntryStatus.MIGRATING, MigrationEntryStatus.MIGRATING,
), ),
[entryList], [entryList],
@@ -59,6 +63,10 @@ export const MigrationExecute = () => {
() => MigrationEntries.getHaveStatusSorted(entryList, MigrationEntryStatus.MIGRATION_FAILED), () => MigrationEntries.getHaveStatusSorted(entryList, MigrationEntryStatus.MIGRATION_FAILED),
[entryList], [entryList],
); );
const abortedEntries = useMemo(
() => MigrationEntries.getHaveStatusSorted(entryList, MigrationEntryStatus.ABORTED),
[entryList],
);
const excludedEntries = useMemo(() => MigrationEntries.getExcluded(entryList), [entryList]); const excludedEntries = useMemo(() => MigrationEntries.getExcluded(entryList), [entryList]);
const noMatchEntries = useMemo( const noMatchEntries = useMemo(
() => MigrationEntries.getHaveStatusSorted(entryList, MigrationEntryStatus.NO_MATCH), () => MigrationEntries.getHaveStatusSorted(entryList, MigrationEntryStatus.NO_MATCH),
@@ -87,7 +95,8 @@ export const MigrationExecute = () => {
other: '# migrating entries', other: '# migrating entries',
})} })}
entries={migratingEntries} entries={migratingEntries}
isMigrating isMigrating={isMigrating}
isAborted={MigrationManager.getState().isAborted}
color="info" color="info"
/> />
<MigrationEntryGroup <MigrationEntryGroup
@@ -97,9 +106,21 @@ export const MigrationExecute = () => {
other: '# failed entries', other: '# failed entries',
})} })}
entries={failedEntries} entries={failedEntries}
isMigrating isMigrating={isMigrating}
isAborted={MigrationManager.getState().isAborted}
color="error" color="error"
/> />
<MigrationEntryGroup
status={MigrationEntryStatus.ABORTED}
title={plural(abortedEntries.length, {
one: '1 aborted entry',
other: '# aborted entries',
})}
entries={abortedEntries}
color="warning"
isMigrating={isMigrating}
isAborted={MigrationManager.getState().isAborted}
/>
<MigrationEntryGroup <MigrationEntryGroup
status={MigrationEntryStatus.NO_MATCH} status={MigrationEntryStatus.NO_MATCH}
title={plural(noMatchEntries.length, { title={plural(noMatchEntries.length, {
@@ -108,7 +129,8 @@ export const MigrationExecute = () => {
})} })}
entries={noMatchEntries} entries={noMatchEntries}
color="warning" color="warning"
isMigrating isMigrating={isMigrating}
isAborted={MigrationManager.getState().isAborted}
/> />
<MigrationEntryGroup <MigrationEntryGroup
status={MigrationEntryStatus.OUTDATED} status={MigrationEntryStatus.OUTDATED}
@@ -117,6 +139,7 @@ export const MigrationExecute = () => {
other: '# entries with only outdated matches', other: '# entries with only outdated matches',
})} })}
entries={outdatedEntries} entries={outdatedEntries}
isAborted={MigrationManager.getState().isAborted}
color="warning" color="warning"
/> />
<MigrationEntryGroup <MigrationEntryGroup
@@ -127,7 +150,8 @@ export const MigrationExecute = () => {
})} })}
entries={excludedEntries} entries={excludedEntries}
color="info" color="info"
isMigrating isMigrating={isMigrating}
isAborted={MigrationManager.getState().isAborted}
/> />
<MigrationEntryGroup <MigrationEntryGroup
status={MigrationEntryStatus.MIGRATION_COMPLETE} status={MigrationEntryStatus.MIGRATION_COMPLETE}
@@ -137,7 +161,8 @@ export const MigrationExecute = () => {
})} })}
entries={migratedEntries} entries={migratedEntries}
color="success" color="success"
isMigrating isMigrating={isMigrating}
isAborted={MigrationManager.getState().isAborted}
/> />
</Stack> </Stack>

View File

@@ -37,7 +37,7 @@ export const MigrationSearch = () => {
MigrationEntries.getActiveEntriesSorted( MigrationEntries.getActiveEntriesSorted(
entryList, entryList,
MigrationEntryStatus.SEARCHING, MigrationEntryStatus.SEARCHING,
MigrationEntryStatus.PENDING, MigrationEntryStatus.SEARCH_PENDING,
MigrationEntryStatus.SEARCHING, MigrationEntryStatus.SEARCHING,
), ),
[entryList], [entryList],
@@ -54,6 +54,10 @@ export const MigrationSearch = () => {
() => MigrationEntries.getHaveStatusSorted(entryList, MigrationEntryStatus.OUTDATED), () => MigrationEntries.getHaveStatusSorted(entryList, MigrationEntryStatus.OUTDATED),
[entryList], [entryList],
); );
const abortedEntries = useMemo(
() => MigrationEntries.getHaveStatusSorted(entryList, MigrationEntryStatus.ABORTED),
[entryList],
);
const matchedEntries = useMemo( const matchedEntries = useMemo(
() => MigrationEntries.getHaveStatusSorted(entryList, MigrationEntryStatus.SEARCH_COMPLETE), () => MigrationEntries.getHaveStatusSorted(entryList, MigrationEntryStatus.SEARCH_COMPLETE),
[entryList], [entryList],
@@ -108,6 +112,15 @@ export const MigrationSearch = () => {
entries={outdatedEntries} entries={outdatedEntries}
color="warning" color="warning"
/> />
<MigrationEntryGroup
status={MigrationEntryStatus.ABORTED}
title={plural(abortedEntries.length, {
one: '1 aborted entry',
other: '# aborted entries',
})}
entries={abortedEntries}
color="warning"
/>
<MigrationEntryGroup <MigrationEntryGroup
status={MigrationEntryStatus.SEARCH_COMPLETE} status={MigrationEntryStatus.SEARCH_COMPLETE}
title={plural(matchedEntries.length, { title={plural(matchedEntries.length, {

View File

@@ -57,6 +57,12 @@ msgstr "{0, plural, one {# Source} other {# Sources}}"
msgid "{0, plural, one {# Tracker} other {# Tracker}}" msgid "{0, plural, one {# Tracker} other {# Tracker}}"
msgstr "{0, plural, one {# Tracker} other {# Tracker}}" msgstr "{0, plural, one {# Tracker} other {# Tracker}}"
#. placeholder {0}: abortedEntries.length
#: src/features/migration/screens/MigrationExecute.tsx
#: src/features/migration/screens/MigrationSearch.tsx
msgid "{0, plural, one {1 aborted entry} other {# aborted entries}}"
msgstr "{0, plural, one {1 aborted entry} other {# aborted entries}}"
#. placeholder {0}: noMatchEntries.length #. placeholder {0}: noMatchEntries.length
#: src/features/migration/screens/MigrationExecute.tsx #: src/features/migration/screens/MigrationExecute.tsx
#: src/features/migration/screens/MigrationSearch.tsx #: src/features/migration/screens/MigrationSearch.tsx
@@ -325,6 +331,7 @@ msgstr "A preview focused web frontend built with svelte"
msgid "A-Z" msgid "A-Z"
msgstr "A-Z" msgstr "A-Z"
#: src/features/migration/components/migration-entry/MigrationEntrySearchExcludeActions.tsx
#: src/features/migration/screens/MigrationExecute.tsx #: src/features/migration/screens/MigrationExecute.tsx
msgid "Abort" msgid "Abort"
msgstr "Abort" msgstr "Abort"
@@ -333,6 +340,7 @@ msgstr "Abort"
msgid "Abort migration" msgid "Abort migration"
msgstr "Abort migration" msgstr "Abort migration"
#: src/features/migration/Migration.constants.ts
#: src/features/migration/screens/MigrationExecute.tsx #: src/features/migration/screens/MigrationExecute.tsx
msgid "Aborted" msgid "Aborted"
msgstr "Aborted" msgstr "Aborted"