diff --git a/src/features/migration/Migration.constants.ts b/src/features/migration/Migration.constants.ts index 2b120f79..3a2721fb 100644 --- a/src/features/migration/Migration.constants.ts +++ b/src/features/migration/Migration.constants.ts @@ -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 }, diff --git a/src/features/migration/Migration.types.ts b/src/features/migration/Migration.types.ts index 996a520d..2d5b516c 100644 --- a/src/features/migration/Migration.types.ts +++ b/src/features/migration/Migration.types.ts @@ -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; destinationSourceIds: SourceIdInfo['id'][]; + searchOptions: MigrationBulkSearchSettings | null; migrateOptions: Omit | null; searchProgress: MigrationProgress; migrationProgress: MigrationProgress; diff --git a/src/features/migration/MigrationManager.ts b/src/features/migration/MigrationManager.ts index 888bbdae..e67184e9 100644 --- a/src/features/migration/MigrationManager.ts +++ b/src/features/migration/MigrationManager.ts @@ -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 { + static async startSearch( + destinationSourceIds: SourceIdInfo['id'][], + options: MigrationBulkSearchSettings, + ): Promise { 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 { + private static async search(entries: TMigrationEntry[], options: MigrationBulkSearchSettings): Promise { 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 { - 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 { 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 { + 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 { - 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), ); } } diff --git a/src/features/migration/components/MigrationBulkSearchOptionsDialog.tsx b/src/features/migration/components/MigrationBulkSearchOptionsDialog.tsx new file mode 100644 index 00000000..ad18e394 --- /dev/null +++ b/src/features/migration/components/MigrationBulkSearchOptionsDialog.tsx @@ -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) => { + const { t } = useLingui(); + + const [selectHighestChapterNumberSource, setSelectHighestChapterNumberSource] = useState(false); + + return ( + + {t`Search options`} + + + + + {t`These options are slow and dangerous and may lead to restrictions from sources`} + + + + {t`Match based on chapter number`} + {t`If enabled, chooses the match furthest ahead.\nOtherwise, picks the first match by source priority.`} + + } + sx={{ + alignItems: 'start', + }} + checked={selectHighestChapterNumberSource} + onChange={(_, checked) => setSelectHighestChapterNumberSource(checked)} + /> + + + + + + + ); +}; diff --git a/src/features/migration/screens/MigrationSelectDestinationSources.tsx b/src/features/migration/screens/MigrationSelectDestinationSources.tsx index 5eb9c4c5..d4298f42 100644 --- a/src/features/migration/screens/MigrationSelectDestinationSources.tsx +++ b/src/features/migration/screens/MigrationSelectDestinationSources.tsx @@ -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`} diff --git a/src/i18n/locales/en.po b/src/i18n/locales/en.po index 6ed9168b..544dcacc 100644 --- a/src/i18n/locales/en.po +++ b/src/i18n/locales/en.po @@ -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:\" (e.g. \"id:13\")\n" "Limit search to your lists via \"my:\" (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"