diff --git a/public/locales/en.json b/public/locales/en.json index 9eba40c0..02bc4b10 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -59,6 +59,8 @@ }, "label": { "action": "Remove bookmark", + "confirmation_one": "You are about to remove one bookmark", + "confirmation_other": "You are about to remove {{count}} bookmarks", "error_one": "Could not remove the bookmark", "error_other": "Could not remove the bookmarks", "success_one": "Chapter bookmark removed", @@ -75,6 +77,8 @@ "action": "Download", "ahead": "$t(download.settings.download_ahead.title) ({{count}})", "all": "$t(extension.language.all)", + "confirmation_one": "You are about to download one chapter", + "confirmation_other": "You are about to download {{count}} chapters.\nSuwayomi is not a mass downloader and too many downloads can get you banned from sources and/or cause performance issues.", "error_one": "Could not add the download", "error_other": "Could not add downloads", "next": "$t(reader.button.next_chapter)", @@ -92,6 +96,8 @@ }, "label": { "action": "Delete", + "confirmation_one": "You are about to delete one download", + "confirmation_other": "You are about to delete {{count}} downloads", "error_one": "Could not delete the chapter", "error_other": "Could not delete chapters", "success_one": "Chapter deleted", @@ -113,6 +119,8 @@ "current": "Mark as read", "previous": "Mark previous as read" }, + "confirmation_one": "You are about to mark one chapter as read", + "confirmation_other": "You are about to mark {{count}} chapters as read", "error_one": "Could not mark the chapter as read", "error_other": "Could not mark chapters as read", "success_one": "Chapter marked as read", @@ -125,6 +133,8 @@ }, "label": { "action": "Mark as unread", + "confirmation_one": "You are about to mark one chapter as unread", + "confirmation_other": "You are about to mark {{count}} chapters as unread", "error_one": "Could not mark the chapter as unread", "error_other": "Could not mark chapters as unread", "success_one": "Chapter marked as unread", @@ -720,6 +730,8 @@ }, "label": { "action": "Change categories", + "confirmation_one": "You are about to change the category of one entry", + "confirmation_other": "You are about to change the category of {{count}} entries", "error_one": "Could not change the categories of the manga", "error_other": "Could not change the categories of the manga", "success_one": "Changed categories of manga", @@ -748,6 +760,8 @@ }, "label": { "action": "Remove from the library", + "confirmation_one": "You are about to remove one entry from your library", + "confirmation_other": "You are about to remove {{count}} entries from your library", "error_one": "Could not remove manga from the library", "error_other": "Could not remove manga from the library", "success_one": "Removed manga from the library", diff --git a/src/modules/chapter/Chapter.constants.ts b/src/modules/chapter/Chapter.constants.ts index ac306d02..e5b3e951 100644 --- a/src/modules/chapter/Chapter.constants.ts +++ b/src/modules/chapter/Chapter.constants.ts @@ -28,12 +28,25 @@ export const CHAPTER_SORT_OPTIONS_TO_TRANSLATION_KEY: Record = { + download: { always: false, bulkAction: false, bulkActionCountForce: 300 }, + delete: { always: true, bulkAction: true }, + bookmark: { always: false, bulkAction: false }, + unbookmark: { always: false, bulkAction: true }, + mark_as_read: { always: false, bulkAction: true }, + mark_as_unread: { always: false, bulkAction: true }, +}; + export const CHAPTER_ACTION_TO_TRANSLATION: { [key in ChapterAction]: { action: { single: TranslationKey; selected: TranslationKey; }; + confirmation?: TranslationKey; success: TranslationKey; error: TranslationKey; }; @@ -43,6 +56,7 @@ export const CHAPTER_ACTION_TO_TRANSLATION: { single: 'chapter.action.download.add.label.action', selected: 'chapter.action.download.add.button.selected', }, + confirmation: 'chapter.action.download.add.label.confirmation', success: 'chapter.action.download.add.label.success', error: 'chapter.action.download.add.label.error', }, @@ -51,6 +65,7 @@ export const CHAPTER_ACTION_TO_TRANSLATION: { single: 'chapter.action.download.delete.label.action', selected: 'chapter.action.download.delete.button.selected', }, + confirmation: 'chapter.action.download.delete.label.confirmation', success: 'chapter.action.download.delete.label.success', error: 'chapter.action.download.delete.label.error', }, @@ -67,6 +82,7 @@ export const CHAPTER_ACTION_TO_TRANSLATION: { single: 'chapter.action.bookmark.remove.label.action', selected: 'chapter.action.bookmark.remove.button.selected', }, + confirmation: 'chapter.action.bookmark.remove.label.confirmation', success: 'chapter.action.bookmark.remove.label.success', error: 'chapter.action.bookmark.remove.label.error', }, @@ -75,6 +91,7 @@ export const CHAPTER_ACTION_TO_TRANSLATION: { single: 'chapter.action.mark_as_read.add.label.action.current', selected: 'chapter.action.mark_as_read.add.button.selected', }, + confirmation: 'chapter.action.mark_as_read.add.label.confirmation', success: 'chapter.action.mark_as_read.add.label.success', error: 'chapter.action.mark_as_read.add.label.error', }, @@ -83,6 +100,7 @@ export const CHAPTER_ACTION_TO_TRANSLATION: { single: 'chapter.action.mark_as_read.remove.label.action', selected: 'chapter.action.mark_as_read.remove.button.selected', }, + confirmation: 'chapter.action.mark_as_read.remove.label.confirmation', success: 'chapter.action.mark_as_read.remove.label.success', error: 'chapter.action.mark_as_read.remove.label.error', }, diff --git a/src/modules/chapter/services/Chapters.ts b/src/modules/chapter/services/Chapters.ts index 66bab4ac..e63cf493 100644 --- a/src/modules/chapter/services/Chapters.ts +++ b/src/modules/chapter/services/Chapters.ts @@ -26,7 +26,10 @@ import { AppRoutes } from '@/modules/core/AppRoute.constants.ts'; import { getErrorMessage } from '@/lib/HelperFunctions.ts'; import { DOWNLOAD_TYPE_FIELDS } from '@/lib/graphql/fragments/DownloadFragments.ts'; import { epochToDate, getDateString } from '@/util/DateHelper.ts'; -import { CHAPTER_ACTION_TO_TRANSLATION } from '@/modules/chapter/Chapter.constants.ts'; +import { + CHAPTER_ACTION_TO_CONFIRMATION_REQUIRED, + CHAPTER_ACTION_TO_TRANSLATION, +} from '@/modules/chapter/Chapter.constants.ts'; import { ChapterAction, ChapterBookmarkInfo, @@ -38,6 +41,8 @@ import { ChapterScanlatorInfo, ChapterSourceOrderInfo, } from '@/modules/chapter/Chapter.types.ts'; +import { assertIsDefined } from '@/Asserts.ts'; +import { awaitConfirmation } from '@/modules/core/utils/AwaitableDialog.tsx'; export class Chapters { static getIds(chapters: { id: number }[]): number[] { @@ -183,19 +188,21 @@ export class Chapters { .filter((matchingChapters): matchingChapters is [Chapter, Chapter] => matchingChapters !== null); } - static async download(chapterIds: number[]): Promise { + static async download(chapterIds: number[], disableConfirmation?: boolean): Promise { return Chapters.executeAction( 'download', chapterIds.length, () => requestManager.addChaptersToDownloadQueue(chapterIds).response, + disableConfirmation, ); } - static async delete(chapterIds: number[]): Promise { + static async delete(chapterIds: number[], disableConfirmation?: boolean): Promise { return Chapters.executeAction( 'delete', chapterIds.length, () => requestManager.deleteDownloadedChapters(chapterIds).response, + disableConfirmation, ); } @@ -203,6 +210,7 @@ export class Chapters { chapters: (ChapterIdInfo & ChapterDownloadInfo & ChapterBookmarkInfo)[], wasManuallyMarkedAsRead: boolean = false, trackProgressMangaId?: MangaIdInfo['id'], + disableConfirmation?: boolean, ): Promise { const { deleteChaptersManuallyMarkedRead, deleteChaptersWithBookmark, updateProgressManualMarkRead } = await getMetadataServerSettings(); @@ -221,14 +229,16 @@ export class Chapters { trackProgressMangaId: updateProgressManualMarkRead && wasManuallyMarkedAsRead ? trackProgressMangaId : undefined, }).response, + disableConfirmation, ); } - static async markAsUnread(chapterIds: number[]): Promise { + static async markAsUnread(chapterIds: number[], disableConfirmation?: boolean): Promise { return Chapters.executeAction( 'mark_as_unread', chapterIds.length, () => requestManager.updateChapters(chapterIds, { isRead: false }).response, + disableConfirmation, ); } @@ -252,8 +262,33 @@ export class Chapters { action: ChapterAction, itemCount: number, fnToExecute: () => Promise, + disableConfirmation?: boolean, ): Promise { + const { always, bulkAction, bulkActionCountForce } = CHAPTER_ACTION_TO_CONFIRMATION_REQUIRED[action]; + const requiresConfirmation = + (!disableConfirmation && (always || (bulkAction && itemCount > 1))) || + (bulkActionCountForce && itemCount >= bulkActionCountForce); + const confirmationMessage = CHAPTER_ACTION_TO_TRANSLATION[action].confirmation; + try { + if (requiresConfirmation) { + assertIsDefined(confirmationMessage); + + try { + await awaitConfirmation({ + title: translate('global.label.are_you_sure'), + message: translate(confirmationMessage, { count: itemCount }), + actions: { + confirm: { + title: translate('global.button.ok'), + }, + }, + }); + } catch (_) { + return; + } + } + await fnToExecute(); makeToast(translate(CHAPTER_ACTION_TO_TRANSLATION[action].success, { count: itemCount }), 'success'); } catch (e) { diff --git a/src/modules/manga/Manga.constants.ts b/src/modules/manga/Manga.constants.ts index ee04dfe2..dc3a00cc 100644 --- a/src/modules/manga/Manga.constants.ts +++ b/src/modules/manga/Manga.constants.ts @@ -9,6 +9,10 @@ import { MangaStatus } from '@/lib/graphql/generated/graphql.ts'; import { TranslationKey } from '@/Base.types.ts'; import { MangaAction, MangaIdInfo, MangaType } from '@/modules/manga/Manga.types.ts'; +import { + CHAPTER_ACTION_TO_CONFIRMATION_REQUIRED, + CHAPTER_ACTION_TO_TRANSLATION, +} from '@/modules/chapter/Chapter.constants.ts'; export const FALLBACK_MANGA: MangaIdInfo = { id: -1 }; @@ -26,53 +30,35 @@ export const MANGA_STATUS_TO_TRANSLATION: Record = [MangaStatus.Unknown]: 'manga.status.unknown', }; +export const MANGA_ACTION_TO_CONFIRMATION_REQUIRED: Record< + MangaAction, + { always: boolean; bulkAction: boolean; bulkActionCountForce?: number } +> = { + ...CHAPTER_ACTION_TO_CONFIRMATION_REQUIRED, + remove_from_library: { always: true, bulkAction: true }, + change_categories: { always: false, bulkAction: false }, + migrate: { always: false, bulkAction: false }, + track: { always: false, bulkAction: false }, +}; + export const MANGA_ACTION_TO_TRANSLATION: { [key in MangaAction]: { action: { single: TranslationKey; selected: TranslationKey; }; + confirmation?: TranslationKey; success: TranslationKey; error: TranslationKey; }; } = { - download: { - action: { - single: 'chapter.action.download.add.label.action', - selected: 'chapter.action.download.add.button.selected', - }, - success: 'chapter.action.download.add.label.success', - error: 'chapter.action.download.add.label.error', - }, - delete: { - action: { - single: 'chapter.action.download.delete.label.action', - selected: 'chapter.action.download.delete.button.selected', - }, - success: 'chapter.action.download.delete.label.success', - error: 'chapter.action.download.delete.label.error', - }, - mark_as_read: { - action: { - single: 'chapter.action.mark_as_read.add.label.action.current', - selected: 'chapter.action.mark_as_read.add.button.selected', - }, - success: 'chapter.action.mark_as_read.add.label.success', - error: 'chapter.action.mark_as_read.add.label.error', - }, - mark_as_unread: { - action: { - single: 'chapter.action.mark_as_read.remove.label.action', - selected: 'chapter.action.mark_as_read.remove.button.selected', - }, - success: 'chapter.action.mark_as_read.remove.label.success', - error: 'chapter.action.mark_as_read.remove.label.error', - }, + ...CHAPTER_ACTION_TO_TRANSLATION, remove_from_library: { action: { single: 'manga.action.library.remove.label.action', selected: 'manga.action.library.remove.button.selected', }, + confirmation: 'manga.action.library.remove.label.confirmation', success: 'manga.action.library.remove.label.success', error: 'manga.action.library.remove.label.error', }, @@ -81,6 +67,7 @@ export const MANGA_ACTION_TO_TRANSLATION: { single: 'manga.action.category.label.action', selected: 'manga.action.category.button.selected', }, + confirmation: 'manga.action.category.label.confirmation', success: 'manga.action.category.label.success', error: 'manga.action.category.label.error', }, diff --git a/src/modules/manga/hooks/useManageMangaLibraryState.tsx b/src/modules/manga/hooks/useManageMangaLibraryState.tsx index 8a930e3d..395e4f88 100644 --- a/src/modules/manga/hooks/useManageMangaLibraryState.tsx +++ b/src/modules/manga/hooks/useManageMangaLibraryState.tsx @@ -63,7 +63,7 @@ export const useManageMangaLibraryState = ( }); } - await Mangas.removeFromLibrary([manga.id]); + await Mangas.removeFromLibrary([manga.id], true); setIsInLibrary(false); }, [manga.id, confirmRemoval]); diff --git a/src/modules/manga/services/Mangas.ts b/src/modules/manga/services/Mangas.ts index e99a2533..9ad647df 100644 --- a/src/modules/manga/services/Mangas.ts +++ b/src/modules/manga/services/Mangas.ts @@ -43,11 +43,14 @@ import { MigrateMode, } from '@/modules/manga/Manga.types.ts'; import { + MANGA_ACTION_TO_CONFIRMATION_REQUIRED, MANGA_ACTION_TO_TRANSLATION, MANGA_TAGS_BY_MANGA_TYPE, SOURCES_BY_MANGA_TYPE, } from '@/modules/manga/Manga.constants.ts'; import { getErrorMessage } from '@/lib/HelperFunctions.ts'; +import { awaitConfirmation } from '@/modules/core/utils/AwaitableDialog.tsx'; +import { assertIsDefined } from '@/Asserts.ts'; type MangaToMigrate = NonNullable; type MangaToMigrateTo = NonNullable['manga']; @@ -194,6 +197,7 @@ export class Mangas { static async downloadChapters( mangaIds: number[], { size, onlyUnread, downloadAhead = false }: DownloadChaptersOptions = {}, + disableConfirmation?: boolean, ): Promise { const [chaptersToConsider, unReadDownloadedChapters] = await Promise.all([ Mangas.getChapterIdsWithState(mangaIds, { @@ -250,25 +254,34 @@ export class Mangas { return Promise.resolve(); } - return Chapters.download(Chapters.getIds(chapterIdsToDownload)); + return Chapters.download(Chapters.getIds(chapterIdsToDownload), disableConfirmation); } - static async deleteChapters(mangaIds: number[]): Promise { + static async deleteChapters(mangaIds: number[], disableConfirmation?: boolean): Promise { const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isDownloaded: true }); - return Chapters.delete(Chapters.getIds(chapters)); + return Chapters.delete(Chapters.getIds(chapters), disableConfirmation); } - static async markAsRead(mangaIds: number[], wasManuallyMarkedAsRead: boolean = false): Promise { + static async markAsRead( + mangaIds: number[], + wasManuallyMarkedAsRead: boolean = false, + disableConfirmation?: boolean, + ): Promise { const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isRead: false }); - return Chapters.markAsRead(chapters, wasManuallyMarkedAsRead, mangaIds.length === 1 ? mangaIds[0] : undefined); + return Chapters.markAsRead( + chapters, + wasManuallyMarkedAsRead, + mangaIds.length === 1 ? mangaIds[0] : undefined, + disableConfirmation, + ); } - static async markAsUnread(mangaIds: number[]): Promise { + static async markAsUnread(mangaIds: number[], disableConfirmation?: boolean): Promise { const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isRead: true }); - return Chapters.markAsUnread(Chapters.getIds(chapters)); + return Chapters.markAsUnread(Chapters.getIds(chapters), disableConfirmation); } - static async removeFromLibrary(mangaIds: number[]): Promise { + static async removeFromLibrary(mangaIds: number[], disableConfirmation?: boolean): Promise { const { removeMangaFromCategories } = await getMetadataServerSettings(); return Mangas.executeAction( 'remove_from_library', @@ -278,14 +291,20 @@ export class Mangas { updateMangas: { inLibrary: false }, updateMangasCategories: removeMangaFromCategories ? { clearCategories: true } : undefined, }).response, + disableConfirmation, ); } - static async changeCategories(mangaIds: number[], patch: UpdateMangaCategoriesPatchInput): Promise { + static async changeCategories( + mangaIds: number[], + patch: UpdateMangaCategoriesPatchInput, + disableConfirmation?: boolean, + ): Promise { return Mangas.executeAction( 'change_categories', mangaIds.length, () => requestManager.updateMangasCategories(mangaIds, patch).response, + disableConfirmation, ); } @@ -418,88 +437,119 @@ export class Mangas { migrateTracking, deleteChapters, }: 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, - }).response, - requestManager.getMangaToMigrateToFetch(mangaIdToMigrateTo, { - migrateChapters, - migrateCategories, - migrateTracking, - apolloOptions: { errorPolicy: 'all' }, - }).response, - getMetadataServerSettings(), - ]); + return Mangas.executeAction( + 'migrate', + 1, + async () => { + const [{ data: mangaToMigrateData }, { data: mangaToMigrateToData }, { removeMangaFromCategories }] = + await Promise.all([ + requestManager.getMangaToMigrate(mangaId, { + migrateChapters, + migrateCategories, + migrateTracking, + deleteChapters, + }).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 performMigrationAction = async ( - migrateAction: keyof MigrateFuncReturn, - ...actions: [boolean | undefined, MigrateFuncReturn][] - ) => - Promise.all( - actions - .filter(([performAction]) => performAction) - .map(([, action]) => action[migrateAction]()) - .flat(), - ); - - const performMigrationActions = async (...actions: [boolean | undefined, MigrateFuncReturn][]) => { - const migrationActions: TupleUnion = ['copy', 'cleanup']; - - 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 - // eslint-disable-next-line no-await-in-loop - await performMigrationAction(migrationAction, ...actions); + if (!mangaToMigrateData.manga || !mangaToMigrateToData?.fetchManga?.manga) { + throw new Error('Mangas::migrate: missing manga data'); } - }; - await performMigrationActions( - [ - migrateChapters, - Mangas.migrateChapters(mode, mangaToMigrateData.manga, mangaToMigrateToData, !!deleteChapters), - ], - [ - migrateTracking, - Mangas.migrateTracking(mode, mangaToMigrateData.manga, mangaToMigrateToData.fetchManga.manga), - ], - [ - true, - Mangas.migrateManga( - mode, - mangaToMigrateData.manga, - mangaToMigrateToData.fetchManga.manga, - !!migrateCategories, - removeMangaFromCategories, - ), - ], - ); - }); + if (migrateChapters && !mangaToMigrateData.manga.chapters) { + throw new Error('Mangas::migrate: missing chapters data'); + } + + if (!mangaToMigrateToData.fetchChapters?.chapters) { + mangaToMigrateToData.fetchChapters = { chapters: [] }; + } + + const performMigrationAction = async ( + migrateAction: keyof MigrateFuncReturn, + ...actions: [boolean | undefined, MigrateFuncReturn][] + ) => + Promise.all( + actions + .filter(([performAction]) => performAction) + .map(([, action]) => action[migrateAction]()) + .flat(), + ); + + const performMigrationActions = async (...actions: [boolean | undefined, MigrateFuncReturn][]) => { + const migrationActions: TupleUnion = ['copy', 'cleanup']; + + 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 + // eslint-disable-next-line no-await-in-loop + await performMigrationAction(migrationAction, ...actions); + } + }; + + await performMigrationActions( + [ + migrateChapters, + Mangas.migrateChapters(mode, mangaToMigrateData.manga, mangaToMigrateToData, !!deleteChapters), + ], + [ + migrateTracking, + Mangas.migrateTracking(mode, mangaToMigrateData.manga, mangaToMigrateToData.fetchManga.manga), + ], + [ + true, + Mangas.migrateManga( + mode, + mangaToMigrateData.manga, + mangaToMigrateToData.fetchManga.manga, + !!migrateCategories, + removeMangaFromCategories, + ), + ], + ); + }, + disableConfirmation, + ); } private static async executeAction( action: MangaAction, itemCount: number, fnToExecute: () => Promise, + disableConfirmation?: boolean, ): Promise { + const { always, bulkAction, bulkActionCountForce } = MANGA_ACTION_TO_CONFIRMATION_REQUIRED[action]; + const requiresConfirmation = + !disableConfirmation && + (always || (bulkAction && itemCount > 1) || (bulkActionCountForce && itemCount >= bulkActionCountForce)); + const confirmationMessage = MANGA_ACTION_TO_TRANSLATION[action].confirmation; + try { + if (requiresConfirmation) { + assertIsDefined(confirmationMessage); + + try { + await awaitConfirmation({ + title: translate('global.label.are_you_sure'), + message: translate(confirmationMessage, { count: itemCount }), + actions: { + confirm: { + title: translate('global.button.ok'), + }, + }, + }); + } catch (_) { + return; + } + } + await fnToExecute(); makeToast(translate(MANGA_ACTION_TO_TRANSLATION[action].success, { count: itemCount }), 'success'); } catch (e) { @@ -524,22 +574,28 @@ export class Mangas { size, ...migrateOptions }: PerformActionOptions, + disableConfirmation?: boolean, ): Promise { switch (action) { case 'download': - return Mangas.downloadChapters(mangaIds, { downloadAhead, onlyUnread, size }); + return Mangas.downloadChapters(mangaIds, { downloadAhead, onlyUnread, size }, disableConfirmation); case 'delete': - return Mangas.deleteChapters(mangaIds); + return Mangas.deleteChapters(mangaIds, disableConfirmation); case 'mark_as_read': - return Mangas.markAsRead(mangaIds, wasManuallyMarkedAsRead!); + return Mangas.markAsRead(mangaIds, wasManuallyMarkedAsRead!, disableConfirmation); case 'mark_as_unread': - return Mangas.markAsUnread(mangaIds); + return Mangas.markAsUnread(mangaIds, disableConfirmation); case 'remove_from_library': - return Mangas.removeFromLibrary(mangaIds); + return Mangas.removeFromLibrary(mangaIds, disableConfirmation); case 'change_categories': - return Mangas.changeCategories(mangaIds, changeCategoriesPatch!); + return Mangas.changeCategories(mangaIds, changeCategoriesPatch!, disableConfirmation); case 'migrate': { - return Mangas.migrate(mangaIds[0], mangaIdToMigrateTo!, migrateOptions as unknown as MigrateOptions); + return Mangas.migrate( + mangaIds[0], + mangaIdToMigrateTo!, + migrateOptions as unknown as MigrateOptions, + disableConfirmation, + ); } default: throw new Error(`Mangas::performAction: unknown action "${action}"`);