diff --git a/src/features/manga/Manga.types.ts b/src/features/manga/Manga.types.ts index 31c5415c..0d2e2e2a 100644 --- a/src/features/manga/Manga.types.ts +++ b/src/features/manga/Manga.types.ts @@ -50,8 +50,6 @@ export type MangaTitleInfo = Pick; export type MangaDescriptionInfo = Pick; export type MangaStatusInfo = Pick; -export type MigrateMode = 'copy' | 'migrate'; - type MangaCardSpecificProps = MangaCardBaseProps & MangaThumbnailInfo; export interface MangaCardProps { diff --git a/src/features/manga/services/Mangas.ts b/src/features/manga/services/Mangas.ts index 47b64d3a..96f9da5e 100644 --- a/src/features/manga/services/Mangas.ts +++ b/src/features/manga/services/Mangas.ts @@ -15,10 +15,7 @@ import type { GetMangasBaseQuery, GetMangasBaseQueryVariables, GetMangasChapterIdsWithStateQuery, - GetMangaToMigrateQuery, - GetMangaToMigrateToFetchMutation, MangaBaseFieldsFragment, - SetChapterMetasItemInput, UpdateMangaCategoriesPatchInput, } from '@/lib/graphql/generated/graphql.ts'; import { Chapters } from '@/features/chapter/services/Chapters.ts'; @@ -26,7 +23,7 @@ import { makeToast } from '@/base/utils/Toast.ts'; import { getMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts'; import { GET_MANGAS_BASE } from '@/lib/graphql/manga/MangaQuery.ts'; import { MANGA_BASE_FIELDS } from '@/lib/graphql/manga/MangaFragments.ts'; -import type { MetadataMigrationSettings } from '@/features/migration/Migration.types.ts'; +import type { MigrateOptions } from '@/features/migration/Migration.types.ts'; import type { MangaAction, MangaArtistInfo, @@ -40,7 +37,6 @@ import type { MangaThumbnailInfo, MangaTitleInfo, MangaUnreadInfo, - MigrateMode, } from '@/features/manga/Manga.types.ts'; import { MangaType } from '@/features/manga/Manga.types.ts'; import { @@ -53,10 +49,7 @@ import { getErrorMessage } from '@/lib/HelperFunctions.ts'; import { assertIsDefined } from '@/base/Asserts.ts'; import { Confirmation } from '@/base/AppAwaitableComponent.ts'; import { UrlUtil } from '@/lib/UrlUtil.ts'; -import { ALL_APP_METADATA_KEY_PREFIXES } from '@/features/metadata/Metadata.constants.ts'; - -type MangaToMigrate = NonNullable; -type MangaToMigrateTo = NonNullable['manga']; +import { MangaMigration } from '@/features/migration/MangaMigration.ts'; type DownloadChaptersOptions = { size?: number; @@ -65,10 +58,6 @@ type DownloadChaptersOptions = { }; type MarkAsReadOptions = { wasManuallyMarkedAsRead: boolean }; type ChangeCategoriesOptions = { changeCategoriesPatch: UpdateMangaCategoriesPatchInput }; -type MigrateOptions = { - mangaIdToMigrateTo: number; - mode: MigrateMode; -} & Partial; type MarkAsReadActionOption = MarkAsReadOptions & PropertiesNever & @@ -101,12 +90,6 @@ type PerformActionOptions = Action extends 'mark_as_ ? DownloadActionOption : DefaultActionOption; -type MigrateAction = { copy: () => Promise[]; cleanup: () => Promise[] }; -type MigrateActionCreator = () => MigrateAction; - -const performMigrationAction = async (migrateAction: keyof MigrateAction, ...actions: MigrateAction[]) => - Promise.all(actions.flatMap((action) => action[migrateAction]())); - const ARTIST_AUTHOR_SEPARATOR_REGEX = /\s*[,|、]\s*/; export class Mangas { @@ -315,276 +298,16 @@ export class Mangas { ); } - private static migrateChapters( - mode: MigrateMode, - mangaToMigrate: GetMangaToMigrateQuery['manga'], - mangaToMigrateToInfo: GetMangaToMigrateToFetchMutation, - deleteChapters: boolean, - migrateMetadata: boolean, - ): MigrateAction { - if (!mangaToMigrate.chapters || !mangaToMigrateToInfo.fetchChapters?.chapters) { - throw new Error('Chapters are missing'); - } - - const chaptersToMigrate = mangaToMigrate.chapters.nodes; - - const chaptersToMigrateTo = mangaToMigrateToInfo.fetchChapters?.chapters; - const migratableChapters = Chapters.getMatchingChapterNumberChapters(chaptersToMigrate, chaptersToMigrateTo); - - const readChapters: number[] = []; - const bookmarkedChapters: number[] = []; - - const chapterPreDeleteIds: number[] = []; - const chapterUpdateItems: SetChapterMetasItemInput[] = []; - - migratableChapters.forEach(([chapterToMigrate, chapterToMigrateTo]) => { - const { isRead, isBookmarked, meta } = chapterToMigrate; - - if (isRead) { - readChapters.push(chapterToMigrateTo.id); - } - - if (isBookmarked) { - bookmarkedChapters.push(chapterToMigrateTo.id); - } - - if (migrateMetadata && meta?.length) { - chapterPreDeleteIds.push(chapterToMigrateTo.id); - chapterUpdateItems.push({ - chapterIds: [chapterToMigrateTo.id], - metas: meta.map(({ key, value }) => ({ key, value })), - }); - } - }); - - return { - copy: () => - [ - readChapters.length && requestManager.updateChapters(readChapters, { isRead: true }).response, - bookmarkedChapters.length && - requestManager.updateChapters(bookmarkedChapters, { isBookmarked: true }).response, - chapterUpdateItems.length && - requestManager.updateChapterMeta({ - preUpdateDeleteInput: { - items: [ - { - chapterIds: chapterPreDeleteIds, - prefixes: ALL_APP_METADATA_KEY_PREFIXES, - }, - ], - }, - updateInput: { items: chapterUpdateItems }, - }).response, - ].filter((promise) => !!promise), - cleanup: () => - mode === 'migrate' - ? [ - deleteChapters - ? requestManager.deleteDownloadedChapters( - Chapters.getIds(Chapters.getDownloaded(mangaToMigrate.chapters?.nodes ?? [])), - ).response - : Promise.resolve(), - ] - : [], - }; - } - - private static migrateTracking( - mode: MigrateMode, - mangaToMigrate: MangaToMigrate, - mangaToMigrateTo: MangaToMigrateTo, - ): MigrateAction { - if (!mangaToMigrate.trackRecords) { - throw new Error('TrackRecords of manga to migrate are missing'); - } - - if (!mangaToMigrateTo.trackRecords) { - throw new Error('TrackRecords of manga to migrate to are missing'); - } - - const trackBindingsToAdd = mangaToMigrate.trackRecords.nodes.filter((trackRecordToMigrate) => - mangaToMigrateTo.trackRecords?.nodes.every( - (trackRecord) => trackRecordToMigrate.remoteId !== trackRecord.remoteId, - ), - ); - - return { - copy: () => - trackBindingsToAdd.map( - (trackRecord) => - requestManager.bindTracker( - mangaToMigrateTo.id, - trackRecord.trackerId, - trackRecord.remoteId, - trackRecord.private, - ).response, - ), - cleanup: () => - mode === 'migrate' - ? (mangaToMigrate.trackRecords?.nodes.map( - (trackRecord) => requestManager.unbindTracker(trackRecord.id).response, - ) ?? []) - : [], - }; - } - - private static migrateManga( - mode: MigrateMode, - mangaToMigrateFrom: MangaToMigrate, - mangaToMigrateTo: MangaToMigrateTo, - migrateCategories: boolean, - removeMangaFromCategories: boolean, - migrateMetadata: boolean, - ): MigrateAction { - if (migrateCategories && !mangaToMigrateFrom?.categories) { - throw new Error('Categories are missing'); - } - - const mangaMeta = migrateMetadata ? mangaToMigrateFrom.meta : undefined; - - return { - copy: () => - [ - requestManager.updateManga(mangaToMigrateTo.id, { - updateManga: { inLibrary: true }, - updateMangaCategories: migrateCategories - ? { - addToCategories: mangaToMigrateFrom.categories?.nodes.map((category) => category.id), - } - : undefined, - }).response, - mangaMeta?.length && - requestManager.updateMangaMeta({ - preUpdateDeleteInput: { - items: [ - { - mangaIds: [mangaToMigrateTo.id], - prefixes: ALL_APP_METADATA_KEY_PREFIXES, - }, - ], - }, - updateInput: { - items: [ - { - mangaIds: [mangaToMigrateTo.id], - metas: mangaMeta.map(({ key, value }) => ({ key, value })), - }, - ], - }, - }).response, - ].filter((promise) => !!promise), - cleanup: () => - mode === 'migrate' - ? [ - requestManager.updateManga(mangaToMigrateFrom.id, { - updateManga: { inLibrary: false }, - updateMangaCategories: removeMangaFromCategories ? { clearCategories: true } : undefined, - }).response, - ] - : [], - }; - } - static async migrate( mangaId: MangaIdInfo['id'], mangaIdToMigrateTo: number, - { - mode, - migrateChapters, - migrateCategories, - migrateTracking, - deleteChapters, - migrateMetadata, - }: Omit, + options: Omit, disableConfirmation?: boolean, ): Promise { return Mangas.executeAction( 'migrate', 1, - async () => { - const [{ data: mangaToMigrateData }, { data: mangaToMigrateToData }, { removeMangaFromCategories }] = - await Promise.all([ - requestManager.getMangaToMigrate(mangaId, { - migrateChapters, - migrateCategories, - migrateTracking, - deleteChapters, - migrateMetadata, - }).response, - requestManager.getMangaToMigrateToFetch(mangaIdToMigrateTo, { - migrateChapters, - migrateCategories, - migrateTracking, - apolloOptions: { errorPolicy: 'all' }, - }).response, - getMetadataServerSettings(), - ]); - - if (!mangaToMigrateData?.manga || !mangaToMigrateToData?.fetchManga?.manga) { - throw new Error('Mangas::migrate: missing manga data'); - } - - if (migrateChapters && !mangaToMigrateData.manga.chapters) { - throw new Error('Mangas::migrate: missing chapters data'); - } - - if (!mangaToMigrateToData.fetchChapters?.chapters) { - mangaToMigrateToData.fetchChapters = { chapters: [] }; - } - - const performMigrationActions = async ( - ...actionCreators: [boolean | undefined, MigrateActionCreator][] - ) => { - const migrationActions: TupleUnion = ['copy', 'cleanup']; - - const actions = actionCreators - .filter(([performAction]) => performAction) - .map(([, actionCreator]) => actionCreator()); - - for (const migrationAction of migrationActions) { - // the migration actions (copy, cleanup) are supposed to be run sequentially to ensure that the cleanup - // only happens in case the copy succeeded - - // oxlint-disable-next-line no-await-in-loop - await performMigrationAction(migrationAction, ...actions); - } - }; - - await performMigrationActions( - [ - migrateChapters, - () => - Mangas.migrateChapters( - mode, - mangaToMigrateData.manga, - mangaToMigrateToData, - !!deleteChapters, - !!migrateMetadata, - ), - ], - [ - migrateTracking, - () => - Mangas.migrateTracking( - mode, - mangaToMigrateData.manga, - mangaToMigrateToData.fetchManga!.manga, - ), - ], - [ - true, - () => - Mangas.migrateManga( - mode, - mangaToMigrateData.manga, - mangaToMigrateToData.fetchManga!.manga, - !!migrateCategories, - removeMangaFromCategories, - !!migrateMetadata, - ), - ], - ); - }, + () => MangaMigration.migrate(mangaId, mangaIdToMigrateTo, options), disableConfirmation, ); } diff --git a/src/features/migration/MangaMigration.ts b/src/features/migration/MangaMigration.ts new file mode 100644 index 00000000..4fb786c9 --- /dev/null +++ b/src/features/migration/MangaMigration.ts @@ -0,0 +1,294 @@ +/* + * 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 { requestManager } from '@/lib/requests/RequestManager.ts'; +import type { + GetMangaToMigrateQuery, + GetMangaToMigrateToFetchMutation, + SetChapterMetasItemInput, +} from '@/lib/graphql/generated/graphql.ts'; +import type { MangaIdInfo } from '@/features/manga/Manga.types.ts'; +import { Chapters } from '@/features/chapter/services/Chapters.ts'; +import { getMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts'; +import { ALL_APP_METADATA_KEY_PREFIXES } from '@/features/metadata/Metadata.constants.ts'; +import type { MigrateMode, MigrateOptions } from '@/features/migration/Migration.types.ts'; + +type MangaToMigrate = NonNullable; +type MangaToMigrateTo = NonNullable['manga']; + +type MigrateAction = { copy: () => Promise[]; cleanup: () => Promise[] }; +type MigrateActionCreator = () => MigrateAction; + +const performMigrationAction = async (migrateAction: keyof MigrateAction, ...actions: MigrateAction[]) => + Promise.all(actions.flatMap((action) => action[migrateAction]())); + +export class MangaMigration { + static async migrate( + mangaId: MangaIdInfo['id'], + mangaIdToMigrateTo: number, + { + mode, + migrateChapters, + migrateCategories, + migrateTracking, + deleteChapters, + migrateMetadata, + }: Omit, + ): Promise { + const [{ data: mangaToMigrateData }, { data: mangaToMigrateToData }, { removeMangaFromCategories }] = + await Promise.all([ + requestManager.getMangaToMigrate(mangaId, { + migrateChapters, + migrateCategories, + migrateTracking, + deleteChapters, + migrateMetadata, + }).response, + requestManager.getMangaToMigrateToFetch(mangaIdToMigrateTo, { + migrateChapters, + migrateCategories, + migrateTracking, + apolloOptions: { errorPolicy: 'all' }, + }).response, + getMetadataServerSettings(), + ]); + + if (!mangaToMigrateData?.manga || !mangaToMigrateToData?.fetchManga?.manga) { + throw new Error('MangaMigration::migrate: missing manga data'); + } + + if (migrateChapters && !mangaToMigrateData.manga.chapters) { + throw new Error('MangaMigration::migrate: missing chapters data'); + } + + if (!mangaToMigrateToData.fetchChapters?.chapters) { + mangaToMigrateToData.fetchChapters = { chapters: [] }; + } + + const performMigrationActions = async (...actionCreators: [boolean | undefined, MigrateActionCreator][]) => { + const migrationActions: TupleUnion = ['copy', 'cleanup']; + + const actions = actionCreators + .filter(([performAction]) => performAction) + .map(([, actionCreator]) => actionCreator()); + + for (const migrationAction of migrationActions) { + // the migration actions (copy, cleanup) are supposed to be run sequentially to ensure that the cleanup + // only happens in case the copy succeeded + + // oxlint-disable-next-line no-await-in-loop + await performMigrationAction(migrationAction, ...actions); + } + }; + + await performMigrationActions( + [ + migrateChapters, + () => + MangaMigration.migrateChapters( + mode, + mangaToMigrateData.manga, + mangaToMigrateToData, + !!deleteChapters, + !!migrateMetadata, + ), + ], + [ + migrateTracking, + () => + MangaMigration.migrateTracking( + mode, + mangaToMigrateData.manga, + mangaToMigrateToData.fetchManga!.manga, + ), + ], + [ + true, + () => + MangaMigration.migrateManga( + mode, + mangaToMigrateData.manga, + mangaToMigrateToData.fetchManga!.manga, + !!migrateCategories, + removeMangaFromCategories, + !!migrateMetadata, + ), + ], + ); + } + + private static migrateChapters( + mode: MigrateMode, + mangaToMigrate: GetMangaToMigrateQuery['manga'], + mangaToMigrateToInfo: GetMangaToMigrateToFetchMutation, + deleteChapters: boolean, + migrateMetadata: boolean, + ): MigrateAction { + if (!mangaToMigrate.chapters || !mangaToMigrateToInfo.fetchChapters?.chapters) { + throw new Error('Chapters are missing'); + } + + const chaptersToMigrate = mangaToMigrate.chapters.nodes; + + const chaptersToMigrateTo = mangaToMigrateToInfo.fetchChapters?.chapters; + const migratableChapters = Chapters.getMatchingChapterNumberChapters(chaptersToMigrate, chaptersToMigrateTo); + + const readChapters: number[] = []; + const bookmarkedChapters: number[] = []; + + const chapterPreDeleteIds: number[] = []; + const chapterUpdateItems: SetChapterMetasItemInput[] = []; + + migratableChapters.forEach(([chapterToMigrate, chapterToMigrateTo]) => { + const { isRead, isBookmarked, meta } = chapterToMigrate; + + if (isRead) { + readChapters.push(chapterToMigrateTo.id); + } + + if (isBookmarked) { + bookmarkedChapters.push(chapterToMigrateTo.id); + } + + if (migrateMetadata && meta?.length) { + chapterPreDeleteIds.push(chapterToMigrateTo.id); + chapterUpdateItems.push({ + chapterIds: [chapterToMigrateTo.id], + metas: meta.map(({ key, value }) => ({ key, value })), + }); + } + }); + + return { + copy: () => + [ + readChapters.length && requestManager.updateChapters(readChapters, { isRead: true }).response, + bookmarkedChapters.length && + requestManager.updateChapters(bookmarkedChapters, { isBookmarked: true }).response, + chapterUpdateItems.length && + requestManager.updateChapterMeta({ + preUpdateDeleteInput: { + items: [ + { + chapterIds: chapterPreDeleteIds, + prefixes: ALL_APP_METADATA_KEY_PREFIXES, + }, + ], + }, + updateInput: { items: chapterUpdateItems }, + }).response, + ].filter((promise) => !!promise), + cleanup: () => + mode === 'migrate' + ? [ + deleteChapters + ? requestManager.deleteDownloadedChapters( + Chapters.getIds(Chapters.getDownloaded(mangaToMigrate.chapters?.nodes ?? [])), + ).response + : Promise.resolve(), + ] + : [], + }; + } + + private static migrateTracking( + mode: MigrateMode, + mangaToMigrate: MangaToMigrate, + mangaToMigrateTo: MangaToMigrateTo, + ): MigrateAction { + if (!mangaToMigrate.trackRecords) { + throw new Error('TrackRecords of manga to migrate are missing'); + } + + if (!mangaToMigrateTo.trackRecords) { + throw new Error('TrackRecords of manga to migrate to are missing'); + } + + const trackBindingsToAdd = mangaToMigrate.trackRecords.nodes.filter((trackRecordToMigrate) => + mangaToMigrateTo.trackRecords?.nodes.every( + (trackRecord) => trackRecordToMigrate.remoteId !== trackRecord.remoteId, + ), + ); + + return { + copy: () => + trackBindingsToAdd.map( + (trackRecord) => + requestManager.bindTracker( + mangaToMigrateTo.id, + trackRecord.trackerId, + trackRecord.remoteId, + trackRecord.private, + ).response, + ), + cleanup: () => + mode === 'migrate' + ? (mangaToMigrate.trackRecords?.nodes.map( + (trackRecord) => requestManager.unbindTracker(trackRecord.id).response, + ) ?? []) + : [], + }; + } + + private static migrateManga( + mode: MigrateMode, + mangaToMigrateFrom: MangaToMigrate, + mangaToMigrateTo: MangaToMigrateTo, + migrateCategories: boolean, + removeMangaFromCategories: boolean, + migrateMetadata: boolean, + ): MigrateAction { + if (migrateCategories && !mangaToMigrateFrom?.categories) { + throw new Error('Categories are missing'); + } + + const mangaMeta = migrateMetadata ? mangaToMigrateFrom.meta : undefined; + + return { + copy: () => + [ + requestManager.updateManga(mangaToMigrateTo.id, { + updateManga: { inLibrary: true }, + updateMangaCategories: migrateCategories + ? { + addToCategories: mangaToMigrateFrom.categories?.nodes.map((category) => category.id), + } + : undefined, + }).response, + mangaMeta?.length && + requestManager.updateMangaMeta({ + preUpdateDeleteInput: { + items: [ + { + mangaIds: [mangaToMigrateTo.id], + prefixes: ALL_APP_METADATA_KEY_PREFIXES, + }, + ], + }, + updateInput: { + items: [ + { + mangaIds: [mangaToMigrateTo.id], + metas: mangaMeta.map(({ key, value }) => ({ key, value })), + }, + ], + }, + }).response, + ].filter((promise) => !!promise), + cleanup: () => + mode === 'migrate' + ? [ + requestManager.updateManga(mangaToMigrateFrom.id, { + updateManga: { inLibrary: false }, + updateMangaCategories: removeMangaFromCategories ? { clearCategories: true } : undefined, + }).response, + ] + : [], + }; + } +} diff --git a/src/features/migration/Migration.types.ts b/src/features/migration/Migration.types.ts index 2fc638f5..4b70a0f3 100644 --- a/src/features/migration/Migration.types.ts +++ b/src/features/migration/Migration.types.ts @@ -25,6 +25,13 @@ export interface SortSettings { export type TMigratableSourcesResult = GetMigratableSourcesQuery['mangas']['nodes']; +export type MigrateMode = 'copy' | 'migrate'; + +export type MigrateOptions = { + mangaIdToMigrateTo: number; + mode: MigrateMode; +} & Partial; + export type MetadataMigrationSettings = { migrateChapters: boolean; migrateCategories: boolean; diff --git a/src/features/migration/components/MigrateDialog.tsx b/src/features/migration/components/MigrateDialog.tsx index 7a1aed12..bc69689f 100644 --- a/src/features/migration/components/MigrateDialog.tsx +++ b/src/features/migration/components/MigrateDialog.tsx @@ -24,8 +24,7 @@ import { useMetadataServerSettings, } from '@/features/settings/services/ServerSettingsMetadata.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; -import type { MetadataMigrationSettings } from '@/features/migration/Migration.types.ts'; -import type { MigrateMode } from '@/features/manga/Manga.types.ts'; +import type { MetadataMigrationSettings, MigrateMode } from '@/features/migration/Migration.types.ts'; import { AppRoutes } from '@/base/AppRoute.constants.ts'; export const MigrateDialog = ({ mangaIdToMigrateTo, onClose }: { mangaIdToMigrateTo: number; onClose: () => void }) => {