Extract manga migration logic
This commit is contained in:
@@ -50,8 +50,6 @@ export type MangaTitleInfo = Pick<MangaTypeGql, 'title'>;
|
|||||||
export type MangaDescriptionInfo = Pick<MangaTypeGql, 'description'>;
|
export type MangaDescriptionInfo = Pick<MangaTypeGql, 'description'>;
|
||||||
export type MangaStatusInfo = Pick<MangaTypeGql, 'status'>;
|
export type MangaStatusInfo = Pick<MangaTypeGql, 'status'>;
|
||||||
|
|
||||||
export type MigrateMode = 'copy' | 'migrate';
|
|
||||||
|
|
||||||
type MangaCardSpecificProps = MangaCardBaseProps & MangaThumbnailInfo;
|
type MangaCardSpecificProps = MangaCardBaseProps & MangaThumbnailInfo;
|
||||||
|
|
||||||
export interface MangaCardProps {
|
export interface MangaCardProps {
|
||||||
|
|||||||
@@ -15,10 +15,7 @@ import type {
|
|||||||
GetMangasBaseQuery,
|
GetMangasBaseQuery,
|
||||||
GetMangasBaseQueryVariables,
|
GetMangasBaseQueryVariables,
|
||||||
GetMangasChapterIdsWithStateQuery,
|
GetMangasChapterIdsWithStateQuery,
|
||||||
GetMangaToMigrateQuery,
|
|
||||||
GetMangaToMigrateToFetchMutation,
|
|
||||||
MangaBaseFieldsFragment,
|
MangaBaseFieldsFragment,
|
||||||
SetChapterMetasItemInput,
|
|
||||||
UpdateMangaCategoriesPatchInput,
|
UpdateMangaCategoriesPatchInput,
|
||||||
} from '@/lib/graphql/generated/graphql.ts';
|
} from '@/lib/graphql/generated/graphql.ts';
|
||||||
import { Chapters } from '@/features/chapter/services/Chapters.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 { getMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||||
import { GET_MANGAS_BASE } from '@/lib/graphql/manga/MangaQuery.ts';
|
import { GET_MANGAS_BASE } from '@/lib/graphql/manga/MangaQuery.ts';
|
||||||
import { MANGA_BASE_FIELDS } from '@/lib/graphql/manga/MangaFragments.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 {
|
import type {
|
||||||
MangaAction,
|
MangaAction,
|
||||||
MangaArtistInfo,
|
MangaArtistInfo,
|
||||||
@@ -40,7 +37,6 @@ import type {
|
|||||||
MangaThumbnailInfo,
|
MangaThumbnailInfo,
|
||||||
MangaTitleInfo,
|
MangaTitleInfo,
|
||||||
MangaUnreadInfo,
|
MangaUnreadInfo,
|
||||||
MigrateMode,
|
|
||||||
} from '@/features/manga/Manga.types.ts';
|
} from '@/features/manga/Manga.types.ts';
|
||||||
import { MangaType } from '@/features/manga/Manga.types.ts';
|
import { MangaType } from '@/features/manga/Manga.types.ts';
|
||||||
import {
|
import {
|
||||||
@@ -53,10 +49,7 @@ import { getErrorMessage } from '@/lib/HelperFunctions.ts';
|
|||||||
import { assertIsDefined } from '@/base/Asserts.ts';
|
import { assertIsDefined } from '@/base/Asserts.ts';
|
||||||
import { Confirmation } from '@/base/AppAwaitableComponent.ts';
|
import { Confirmation } from '@/base/AppAwaitableComponent.ts';
|
||||||
import { UrlUtil } from '@/lib/UrlUtil.ts';
|
import { UrlUtil } from '@/lib/UrlUtil.ts';
|
||||||
import { ALL_APP_METADATA_KEY_PREFIXES } from '@/features/metadata/Metadata.constants.ts';
|
import { MangaMigration } from '@/features/migration/MangaMigration.ts';
|
||||||
|
|
||||||
type MangaToMigrate = NonNullable<GetMangaToMigrateQuery['manga']>;
|
|
||||||
type MangaToMigrateTo = NonNullable<GetMangaToMigrateToFetchMutation['fetchManga']>['manga'];
|
|
||||||
|
|
||||||
type DownloadChaptersOptions = {
|
type DownloadChaptersOptions = {
|
||||||
size?: number;
|
size?: number;
|
||||||
@@ -65,10 +58,6 @@ type DownloadChaptersOptions = {
|
|||||||
};
|
};
|
||||||
type MarkAsReadOptions = { wasManuallyMarkedAsRead: boolean };
|
type MarkAsReadOptions = { wasManuallyMarkedAsRead: boolean };
|
||||||
type ChangeCategoriesOptions = { changeCategoriesPatch: UpdateMangaCategoriesPatchInput };
|
type ChangeCategoriesOptions = { changeCategoriesPatch: UpdateMangaCategoriesPatchInput };
|
||||||
type MigrateOptions = {
|
|
||||||
mangaIdToMigrateTo: number;
|
|
||||||
mode: MigrateMode;
|
|
||||||
} & Partial<MetadataMigrationSettings>;
|
|
||||||
|
|
||||||
type MarkAsReadActionOption = MarkAsReadOptions &
|
type MarkAsReadActionOption = MarkAsReadOptions &
|
||||||
PropertiesNever<ChangeCategoriesOptions> &
|
PropertiesNever<ChangeCategoriesOptions> &
|
||||||
@@ -101,12 +90,6 @@ type PerformActionOptions<Action extends MangaAction> = Action extends 'mark_as_
|
|||||||
? DownloadActionOption
|
? DownloadActionOption
|
||||||
: DefaultActionOption;
|
: DefaultActionOption;
|
||||||
|
|
||||||
type MigrateAction = { copy: () => Promise<unknown>[]; cleanup: () => Promise<unknown>[] };
|
|
||||||
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*/;
|
const ARTIST_AUTHOR_SEPARATOR_REGEX = /\s*[,|、]\s*/;
|
||||||
|
|
||||||
export class Mangas {
|
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(
|
static async migrate(
|
||||||
mangaId: MangaIdInfo['id'],
|
mangaId: MangaIdInfo['id'],
|
||||||
mangaIdToMigrateTo: number,
|
mangaIdToMigrateTo: number,
|
||||||
{
|
options: Omit<MigrateOptions, 'mangaIdToMigrateTo'>,
|
||||||
mode,
|
|
||||||
migrateChapters,
|
|
||||||
migrateCategories,
|
|
||||||
migrateTracking,
|
|
||||||
deleteChapters,
|
|
||||||
migrateMetadata,
|
|
||||||
}: Omit<MigrateOptions, 'mangaIdToMigrateTo'>,
|
|
||||||
disableConfirmation?: boolean,
|
disableConfirmation?: boolean,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return Mangas.executeAction(
|
return Mangas.executeAction(
|
||||||
'migrate',
|
'migrate',
|
||||||
1,
|
1,
|
||||||
async () => {
|
() => MangaMigration.migrate(mangaId, mangaIdToMigrateTo, options),
|
||||||
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<keyof MigrateAction> = ['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,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
disableConfirmation,
|
disableConfirmation,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
294
src/features/migration/MangaMigration.ts
Normal file
294
src/features/migration/MangaMigration.ts
Normal file
@@ -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<GetMangaToMigrateQuery['manga']>;
|
||||||
|
type MangaToMigrateTo = NonNullable<GetMangaToMigrateToFetchMutation['fetchManga']>['manga'];
|
||||||
|
|
||||||
|
type MigrateAction = { copy: () => Promise<unknown>[]; cleanup: () => Promise<unknown>[] };
|
||||||
|
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<MigrateOptions, 'mangaIdToMigrateTo'>,
|
||||||
|
): Promise<void> {
|
||||||
|
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<keyof MigrateAction> = ['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,
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,13 @@ export interface SortSettings {
|
|||||||
|
|
||||||
export type TMigratableSourcesResult = GetMigratableSourcesQuery['mangas']['nodes'];
|
export type TMigratableSourcesResult = GetMigratableSourcesQuery['mangas']['nodes'];
|
||||||
|
|
||||||
|
export type MigrateMode = 'copy' | 'migrate';
|
||||||
|
|
||||||
|
export type MigrateOptions = {
|
||||||
|
mangaIdToMigrateTo: number;
|
||||||
|
mode: MigrateMode;
|
||||||
|
} & Partial<MetadataMigrationSettings>;
|
||||||
|
|
||||||
export type MetadataMigrationSettings = {
|
export type MetadataMigrationSettings = {
|
||||||
migrateChapters: boolean;
|
migrateChapters: boolean;
|
||||||
migrateCategories: boolean;
|
migrateCategories: boolean;
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ import {
|
|||||||
useMetadataServerSettings,
|
useMetadataServerSettings,
|
||||||
} from '@/features/settings/services/ServerSettingsMetadata.ts';
|
} from '@/features/settings/services/ServerSettingsMetadata.ts';
|
||||||
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
|
||||||
import type { MetadataMigrationSettings } from '@/features/migration/Migration.types.ts';
|
import type { MetadataMigrationSettings, MigrateMode } from '@/features/migration/Migration.types.ts';
|
||||||
import type { MigrateMode } from '@/features/manga/Manga.types.ts';
|
|
||||||
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
import { AppRoutes } from '@/base/AppRoute.constants.ts';
|
||||||
|
|
||||||
export const MigrateDialog = ({ mangaIdToMigrateTo, onClose }: { mangaIdToMigrateTo: number; onClose: () => void }) => {
|
export const MigrateDialog = ({ mangaIdToMigrateTo, onClose }: { mangaIdToMigrateTo: number; onClose: () => void }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user