Optionally select bulk migration match based on highest chapter number
This commit is contained in:
@@ -37,6 +37,7 @@ export const DEFAULT_MIGRATION_STATE: MigrationState = {
|
||||
sourceIds: null,
|
||||
entries: {},
|
||||
destinationSourceIds: [],
|
||||
searchOptions: null,
|
||||
migrateOptions: null,
|
||||
searchProgress: { total: 0, completed: 0, success: 0, failed: 0 },
|
||||
migrationProgress: { total: 0, completed: 0, success: 0, failed: 0 },
|
||||
|
||||
@@ -58,6 +58,10 @@ export type MetadataMigrationSettings = {
|
||||
migrateSortSettings: SortSettings;
|
||||
};
|
||||
|
||||
export interface MigrationBulkSearchSettings {
|
||||
selectHighestChapterNumberSource: boolean;
|
||||
}
|
||||
|
||||
export enum MigrationPhase {
|
||||
IDLE = 'idle',
|
||||
SELECT_SOURCE = 'select_source',
|
||||
@@ -114,6 +118,7 @@ export interface MigrationState {
|
||||
sourceIds: SourceIdInfo['id'][] | null;
|
||||
entries: Record<MangaIdInfo['id'], TMigrationEntry>;
|
||||
destinationSourceIds: SourceIdInfo['id'][];
|
||||
searchOptions: MigrationBulkSearchSettings | null;
|
||||
migrateOptions: Omit<MigrateOptions, 'mangaIdToMigrateTo'> | null;
|
||||
searchProgress: MigrationProgress;
|
||||
migrationProgress: MigrationProgress;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { devtools, persist } from 'zustand/middleware';
|
||||
import {
|
||||
type MigratableEntry,
|
||||
type MigrateOptions,
|
||||
type MigrationBulkSearchSettings,
|
||||
MigrationEntryStatus,
|
||||
type MigrationMatch,
|
||||
MigrationPhase,
|
||||
@@ -330,27 +331,28 @@ export class MigrationManager {
|
||||
});
|
||||
}
|
||||
|
||||
static async startSearch(destinationSourceIds: SourceIdInfo['id'][]): Promise<void> {
|
||||
static async startSearch(
|
||||
destinationSourceIds: SourceIdInfo['id'][],
|
||||
options: MigrationBulkSearchSettings,
|
||||
): Promise<void> {
|
||||
MigrationManager.ensureIsInValidPhase([MigrationPhase.SELECTING_SOURCES]);
|
||||
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.destinationSourceIds = destinationSourceIds;
|
||||
});
|
||||
|
||||
const state = MigrationManager.getState();
|
||||
const entryIds = Object.keys(state.entries).map(Number);
|
||||
|
||||
MigrationManager.updateState((draft) => {
|
||||
draft.phase = MigrationPhase.SEARCHING;
|
||||
draft.destinationSourceIds = destinationSourceIds;
|
||||
draft.searchOptions = options;
|
||||
draft.searchProgress = { total: entryIds.length, completed: 0, success: 0, failed: 0 };
|
||||
draft.startedAt = Date.now();
|
||||
draft.groupExpandState = MIGRATE_SEARCH_ENTRY_GROUP_EXPAND_DEFAULT_STATE;
|
||||
});
|
||||
|
||||
await MigrationManager.search(Object.values(state.entries));
|
||||
await MigrationManager.search(Object.values(state.entries), options);
|
||||
}
|
||||
|
||||
private static async search(entries: TMigrationEntry[]): Promise<void> {
|
||||
private static async search(entries: TMigrationEntry[], options: MigrationBulkSearchSettings): Promise<void> {
|
||||
const { signal } = MigrationManager.abortAndCreateAbortController('search');
|
||||
|
||||
const searchPromises = entries.map((entry) =>
|
||||
@@ -359,7 +361,7 @@ export class MigrationManager {
|
||||
return;
|
||||
}
|
||||
|
||||
await MigrationManager.searchForManga(entry.mangaId, entry.mangaTitle, signal);
|
||||
await MigrationManager.searchForManga(entry.mangaId, entry.mangaTitle, signal, options);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -481,16 +483,16 @@ export class MigrationManager {
|
||||
}
|
||||
|
||||
static async resume(): Promise<void> {
|
||||
const state = MigrationManager.getState();
|
||||
const { phase, migrateOptions, searchOptions, entries } = MigrationManager.getState();
|
||||
|
||||
const isResumeablePhase = RESUMABLE_PHASES.includes(state.phase);
|
||||
const isResumeablePhase = RESUMABLE_PHASES.includes(phase);
|
||||
if (!isResumeablePhase) {
|
||||
return;
|
||||
}
|
||||
|
||||
const resumeMigrationPhase = state.phase === MigrationPhase.MIGRATING && state.migrateOptions;
|
||||
const resumeMigrationPhase = phase === MigrationPhase.MIGRATING && migrateOptions;
|
||||
if (resumeMigrationPhase) {
|
||||
assertIsDefined(state.migrateOptions);
|
||||
assertIsDefined(migrateOptions);
|
||||
|
||||
const migratableEntries = MigrationManager.getMigratableEntries();
|
||||
|
||||
@@ -500,12 +502,14 @@ export class MigrationManager {
|
||||
});
|
||||
});
|
||||
|
||||
await MigrationManager.migrate(migratableEntries, state.migrateOptions);
|
||||
await MigrationManager.migrate(migratableEntries, migrateOptions);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingEntries = Object.values(state.entries).filter((entry) =>
|
||||
assertIsDefined(searchOptions);
|
||||
|
||||
const pendingEntries = Object.values(entries).filter((entry) =>
|
||||
[MigrationEntryStatus.PENDING, MigrationEntryStatus.SEARCHING].includes(entry.status),
|
||||
);
|
||||
|
||||
@@ -515,7 +519,7 @@ export class MigrationManager {
|
||||
});
|
||||
});
|
||||
|
||||
await MigrationManager.search(pendingEntries);
|
||||
await MigrationManager.search(pendingEntries, searchOptions);
|
||||
}
|
||||
|
||||
static reset(): void {
|
||||
@@ -658,6 +662,7 @@ export class MigrationManager {
|
||||
mangaTitle: string,
|
||||
sourceId: SourceIdInfo['id'],
|
||||
signal: AbortSignal,
|
||||
{ selectHighestChapterNumberSource }: MigrationBulkSearchSettings,
|
||||
): Promise<MangaMigrationFieldsFragment[]> {
|
||||
if (signal.aborted) {
|
||||
throw new Error(signal.reason);
|
||||
@@ -668,7 +673,7 @@ export class MigrationManager {
|
||||
throw new Error(signal.reason);
|
||||
}
|
||||
|
||||
if (MigrationManager.hasHigherSourcePriorityMatch(mangaId, sourceId)) {
|
||||
if (!selectHighestChapterNumberSource && MigrationManager.hasHigherSourcePriorityMatch(mangaId, sourceId)) {
|
||||
throw new Error('Entry already has a selected match from a higher priority source');
|
||||
}
|
||||
|
||||
@@ -723,7 +728,10 @@ export class MigrationManager {
|
||||
mangaId: MangaIdInfo['id'],
|
||||
mangaTitle: string,
|
||||
mainSignal: AbortSignal,
|
||||
options: MigrationBulkSearchSettings,
|
||||
): Promise<void> {
|
||||
const { selectHighestChapterNumberSource } = options;
|
||||
|
||||
const state = MigrationManager.getState();
|
||||
const entry = state.entries[mangaId];
|
||||
|
||||
@@ -745,7 +753,10 @@ export class MigrationManager {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (MigrationManager.hasHigherSourcePriorityMatch(mangaId, destSourceId)) {
|
||||
if (
|
||||
!selectHighestChapterNumberSource &&
|
||||
MigrationManager.hasHigherSourcePriorityMatch(mangaId, destSourceId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -756,6 +767,7 @@ export class MigrationManager {
|
||||
mangaTitle,
|
||||
destSourceId,
|
||||
signal,
|
||||
options,
|
||||
);
|
||||
} catch (e) {
|
||||
MigrationManager.updateState((draft) => {
|
||||
@@ -780,6 +792,9 @@ export class MigrationManager {
|
||||
|
||||
MigrationManager.updateState((draft) => {
|
||||
const draftEntry = draft.entries[mangaId];
|
||||
const draftMatchEntry = draftEntry.selectedMatchMangaId
|
||||
? draft.entries[draftEntry.selectedMatchMangaId]
|
||||
: null;
|
||||
|
||||
const matches = foundMatches.map((manga) => ({
|
||||
id: manga.id,
|
||||
@@ -793,26 +808,53 @@ export class MigrationManager {
|
||||
}));
|
||||
|
||||
draftEntry.destSourceIdToSearchState[destSourceId] = true;
|
||||
|
||||
draftEntry.searchMatches = [...draftEntry.searchMatches, ...matches];
|
||||
|
||||
if (!MigrationManager.hasHigherSourcePriorityMatch(mangaId, destSourceId)) {
|
||||
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];
|
||||
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];
|
||||
|
||||
assertIsDefined(bestMatch);
|
||||
assertIsDefined(bestMatch);
|
||||
|
||||
const entryLatestChapterNumber = draftEntry.latestChapterNumber ?? Number.MIN_SAFE_INTEGER;
|
||||
const selectedMatchLatestChapterNumber =
|
||||
draftMatchEntry?.latestChapterNumber ?? Number.MIN_SAFE_INTEGER;
|
||||
|
||||
const hasNewerChapterVsEntry = latestChapterNumber > entryLatestChapterNumber;
|
||||
const hasNewerChapterVsSelectedMatch = latestChapterNumber > selectedMatchLatestChapterNumber;
|
||||
const hasNewerChapter = hasNewerChapterVsEntry && hasNewerChapterVsSelectedMatch;
|
||||
|
||||
const hasSameLatestChapterAsSelectedMatch =
|
||||
!!draftMatchEntry && selectedMatchLatestChapterNumber === latestChapterNumber;
|
||||
|
||||
const hasHigherSourcePriority = !MigrationManager.hasHigherSourcePriorityMatch(
|
||||
mangaId,
|
||||
destSourceId,
|
||||
);
|
||||
|
||||
const isPreferredSourcePriorityMatch =
|
||||
hasHigherSourcePriority && !selectHighestChapterNumberSource;
|
||||
const isPreferredChapterNumberMatch =
|
||||
selectHighestChapterNumberSource &&
|
||||
(hasNewerChapter ||
|
||||
(hasHigherSourcePriority && hasSameLatestChapterAsSelectedMatch) ||
|
||||
!draftMatchEntry);
|
||||
|
||||
const isPreferredMatch = isPreferredSourcePriorityMatch || isPreferredChapterNumberMatch;
|
||||
if (isPreferredMatch) {
|
||||
draftEntry.selectedMatchMangaId = bestMatch.id;
|
||||
draftEntry.selectedMatchSourceId = destSourceId;
|
||||
}
|
||||
|
||||
if (!MigrationManager.isHigherPrioritySourceUnsettled(mangaId, destSourceId)) {
|
||||
if (
|
||||
!selectHighestChapterNumberSource &&
|
||||
!MigrationManager.isHigherPrioritySourceUnsettled(mangaId, destSourceId)
|
||||
) {
|
||||
searchController.abort(`Found best match in source "${destSourceId}"`);
|
||||
}
|
||||
});
|
||||
@@ -908,7 +950,8 @@ export class MigrationManager {
|
||||
}
|
||||
|
||||
static async retryEntry(id: MangaIdInfo['id']): Promise<void> {
|
||||
const entry = MigrationManager.getState().entries[id];
|
||||
const { entries, migrateOptions, searchOptions } = MigrationManager.getState();
|
||||
const entry = entries[id];
|
||||
|
||||
if (!entry) {
|
||||
return;
|
||||
@@ -923,8 +966,10 @@ export class MigrationManager {
|
||||
draft.searchProgress.failed -= 1;
|
||||
});
|
||||
|
||||
assertIsDefined(searchOptions);
|
||||
|
||||
await MigrationManager.mangaProcessQueue(() =>
|
||||
MigrationManager.searchForManga(id, entry.mangaTitle, signal),
|
||||
MigrationManager.searchForManga(id, entry.mangaTitle, signal, searchOptions),
|
||||
);
|
||||
|
||||
return;
|
||||
@@ -936,11 +981,10 @@ export class MigrationManager {
|
||||
draft.migrationProgress.failed -= 1;
|
||||
});
|
||||
|
||||
const migrationOptions = MigrationManager.getState().migrateOptions;
|
||||
assertIsDefined(migrationOptions);
|
||||
assertIsDefined(migrateOptions);
|
||||
|
||||
await MigrationManager.mangaProcessQueue(() =>
|
||||
MigrationManager.migrateSingleEntry(id, migrationOptions, signal),
|
||||
MigrationManager.migrateSingleEntry(id, migrateOptions, signal),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 Dialog from '@mui/material/Dialog';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import Button from '@mui/material/Button';
|
||||
import Stack from '@mui/material/Stack';
|
||||
import { useLingui } from '@lingui/react/macro';
|
||||
import { CheckboxInput } from '@/base/components/inputs/CheckboxInput.tsx';
|
||||
import type { AwaitableComponentProps } from 'awaitable-component';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import WarningIcon from '@mui/icons-material/Warning';
|
||||
import { useState } from 'react';
|
||||
import type { MigrationBulkSearchSettings } from '@/features/migration/Migration.types.ts';
|
||||
|
||||
export const MigrationBulkSearchOptionsDialog = ({
|
||||
isVisible,
|
||||
onDismiss,
|
||||
onSubmit,
|
||||
onExitComplete,
|
||||
}: AwaitableComponentProps<MigrationBulkSearchSettings>) => {
|
||||
const { t } = useLingui();
|
||||
|
||||
const [selectHighestChapterNumberSource, setSelectHighestChapterNumberSource] = useState(false);
|
||||
|
||||
return (
|
||||
<Dialog open={isVisible} fullWidth onClose={onDismiss} onTransitionExited={onExitComplete}>
|
||||
<DialogTitle>{t`Search options`}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Stack
|
||||
direction="row"
|
||||
sx={{
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<WarningIcon color="warning" />
|
||||
<Typography
|
||||
variant="body1"
|
||||
sx={{
|
||||
marginLeft: '10px',
|
||||
marginTop: '5px',
|
||||
whiteSpace: 'pre-line',
|
||||
}}
|
||||
color="error"
|
||||
>
|
||||
{t`These options are slow and dangerous and may lead to restrictions from sources`}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<CheckboxInput
|
||||
label={
|
||||
<Stack
|
||||
sx={{
|
||||
// Padding comes from the MUI Checkbox component
|
||||
pt: '9px',
|
||||
}}
|
||||
>
|
||||
<Typography>{t`Match based on chapter number`}</Typography>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="textSecondary"
|
||||
>{t`If enabled, chooses the match furthest ahead.\nOtherwise, picks the first match by source priority.`}</Typography>
|
||||
</Stack>
|
||||
}
|
||||
sx={{
|
||||
alignItems: 'start',
|
||||
}}
|
||||
checked={selectHighestChapterNumberSource}
|
||||
onChange={(_, checked) => setSelectHighestChapterNumberSource(checked)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onDismiss}>{t`Cancel`}</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() =>
|
||||
onSubmit({
|
||||
selectHighestChapterNumberSource,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t`Search`}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -28,6 +28,8 @@ import { Sources } from '@/features/source/services/Sources.ts';
|
||||
import { useMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||
import ToggleOnIcon from '@mui/icons-material/ToggleOn';
|
||||
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
|
||||
import { MigrationBulkSearchOptionsDialog } from '@/features/migration/components/MigrationBulkSearchOptionsDialog.tsx';
|
||||
import { AwaitableComponent } from 'awaitable-component';
|
||||
|
||||
export const MigrationSelectDestinationSources = () => {
|
||||
const { t } = useLingui();
|
||||
@@ -168,7 +170,15 @@ export const MigrationSelectDestinationSources = () => {
|
||||
bottom: (theme) => theme.spacing(2),
|
||||
right: (theme) => theme.spacing(2),
|
||||
}}
|
||||
onClick={() => MigrationManager.startSearch(selectedItemIds)}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const searchOptions = await AwaitableComponent.show(MigrationBulkSearchOptionsDialog);
|
||||
|
||||
await MigrationManager.startSearch(selectedItemIds, searchOptions);
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t`Start Search`}
|
||||
</Fab>
|
||||
|
||||
@@ -670,6 +670,7 @@ msgstr "Call timeout"
|
||||
#: src/features/category/components/CategoriesInclusionSetting.tsx
|
||||
#: src/features/category/components/CategorySelect.tsx
|
||||
#: src/features/category/components/CreateOrEditCategoryDialog.tsx
|
||||
#: src/features/migration/components/MigrationBulkSearchOptionsDialog.tsx
|
||||
#: src/features/migration/components/MigrationOptionsDialog.tsx
|
||||
#: src/features/reader/hotkeys/settings/components/RecordHotkey.tsx
|
||||
#: src/features/settings/components/globalUpdate/GlobalUpdateSettingsEntries.tsx
|
||||
@@ -1802,7 +1803,7 @@ msgstr "How many chapters should get downloaded while reading."
|
||||
msgid "How much time FlareSolverr has to handle the request"
|
||||
msgstr "How much time FlareSolverr has to handle the request"
|
||||
|
||||
#: src/features/migration/components/MigrationOptionsDialog.tsx
|
||||
#: src/features/migration/components/MigrationBulkSearchOptionsDialog.tsx
|
||||
msgid ""
|
||||
"If enabled, chooses the match furthest ahead.\n"
|
||||
"Otherwise, picks the first match by source priority."
|
||||
@@ -2250,7 +2251,7 @@ msgstr "Mark selected as read"
|
||||
msgid "Mark selected as unread"
|
||||
msgstr "Mark selected as unread"
|
||||
|
||||
#: src/features/migration/components/MigrationOptionsDialog.tsx
|
||||
#: src/features/migration/components/MigrationBulkSearchOptionsDialog.tsx
|
||||
msgid "Match based on chapter number"
|
||||
msgstr "Match based on chapter number"
|
||||
|
||||
@@ -2930,6 +2931,7 @@ msgid "Scroll forward"
|
||||
msgstr "Scroll forward"
|
||||
|
||||
#: src/base/components/AppbarSearch.tsx
|
||||
#: src/features/migration/components/MigrationBulkSearchOptionsDialog.tsx
|
||||
msgid "Search"
|
||||
msgstr "Search"
|
||||
|
||||
@@ -2954,6 +2956,10 @@ msgstr ""
|
||||
"Search for a ID via \"id:<ID>\" (e.g. \"id:13\")\n"
|
||||
"Limit search to your lists via \"my:<Title>\" (e.g. \"my:One Piece\")"
|
||||
|
||||
#: src/features/migration/components/MigrationBulkSearchOptionsDialog.tsx
|
||||
msgid "Search options"
|
||||
msgstr "Search options"
|
||||
|
||||
#: src/features/settings/components/images/Processing.tsx
|
||||
msgid "Search parameters"
|
||||
msgstr "Search parameters"
|
||||
@@ -3483,7 +3489,7 @@ msgstr "There is no next chapter"
|
||||
msgid "There is no previous chapter"
|
||||
msgstr "There is no previous chapter"
|
||||
|
||||
#: src/features/migration/components/MigrationOptionsDialog.tsx
|
||||
#: src/features/migration/components/MigrationBulkSearchOptionsDialog.tsx
|
||||
msgid "These options are slow and dangerous and may lead to restrictions from sources"
|
||||
msgstr "These options are slow and dangerous and may lead to restrictions from sources"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user