Optionally include client data during manga migration

This commit is contained in:
schroda
2026-03-07 03:07:40 +01:00
parent 57e05bf20d
commit 00d6350cee
17 changed files with 382 additions and 97 deletions

View File

@@ -172,10 +172,10 @@ export class Chapters {
return chapters.filter((chapter) => !Chapters.isRead(chapter));
}
static getMatchingChapterNumberChapters<Chapter extends ChapterNumberInfo>(
chaptersA: Chapter[],
chaptersB: Chapter[],
): [ChapterA: Chapter, ChapterB: Chapter][] {
static getMatchingChapterNumberChapters<ChapterA extends ChapterNumberInfo, ChapterB extends ChapterNumberInfo>(
chaptersA: ChapterA[],
chaptersB: ChapterB[],
): [ChapterA: ChapterA, ChapterB: ChapterB][] {
return chaptersA
.map((chapterA) => {
const matchingChapter = chaptersB.find((chapterB) => chapterA.chapterNumber === chapterB.chapterNumber);
@@ -186,7 +186,7 @@ export class Chapters {
return [chapterA, matchingChapter];
})
.filter((matchingChapters): matchingChapters is [Chapter, Chapter] => matchingChapters !== null);
.filter((matchingChapters): matchingChapters is [ChapterA, ChapterB] => matchingChapters !== null);
}
static async download(chapterIds: number[], disableConfirmation?: boolean): Promise<void> {

View File

@@ -18,6 +18,7 @@ import {
GetMangaToMigrateQuery,
GetMangaToMigrateToFetchMutation,
MangaBaseFieldsFragment,
SetChapterMetasItemInput,
UpdateMangaCategoriesPatchInput,
} from '@/lib/graphql/generated/graphql.ts';
import { Chapters } from '@/features/chapter/services/Chapters.ts';
@@ -52,6 +53,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<GetMangaToMigrateQuery['manga']>;
type MangaToMigrateTo = NonNullable<GetMangaToMigrateToFetchMutation['fetchManga']>['manga'];
@@ -317,6 +319,7 @@ export class Mangas {
mangaToMigrate: GetMangaToMigrateQuery['manga'],
mangaToMigrateToInfo: GetMangaToMigrateToFetchMutation,
deleteChapters: boolean,
migrateMetadata: boolean,
): MigrateAction {
if (!mangaToMigrate.chapters || !mangaToMigrateToInfo.fetchChapters?.chapters) {
throw new Error('Chapters are missing');
@@ -330,8 +333,11 @@ export class Mangas {
const readChapters: number[] = [];
const bookmarkedChapters: number[] = [];
const chapterPreDeleteIds: number[] = [];
const chapterUpdateItems: SetChapterMetasItemInput[] = [];
migratableChapters.forEach(([chapterToMigrate, chapterToMigrateTo]) => {
const { isRead, isBookmarked } = chapterToMigrate;
const { isRead, isBookmarked, meta } = chapterToMigrate;
if (isRead) {
readChapters.push(chapterToMigrateTo.id);
@@ -340,6 +346,14 @@ export class Mangas {
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 {
@@ -348,6 +362,18 @@ export class Mangas {
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'
@@ -407,22 +433,45 @@ export class Mangas {
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,
],
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'
? [
@@ -444,6 +493,7 @@ export class Mangas {
migrateCategories,
migrateTracking,
deleteChapters,
migrateMetadata,
}: Omit<MigrateOptions, 'mangaIdToMigrateTo'>,
disableConfirmation?: boolean,
): Promise<void> {
@@ -458,6 +508,7 @@ export class Mangas {
migrateCategories,
migrateTracking,
deleteChapters,
migrateMetadata,
}).response,
requestManager.getMangaToMigrateToFetch(mangaIdToMigrateTo, {
migrateChapters,
@@ -511,6 +562,7 @@ export class Mangas {
mangaToMigrateData.manga,
mangaToMigrateToData,
!!deleteChapters,
!!migrateMetadata,
),
],
[
@@ -531,6 +583,7 @@ export class Mangas {
mangaToMigrateToData.fetchManga!.manga,
!!migrateCategories,
removeMangaFromCategories,
!!migrateMetadata,
),
],
);

View File

@@ -120,6 +120,9 @@ export const APP_METADATA: Record<
deleteChapters: {
convert: convertToBoolean,
},
migrateMetadata: {
convert: convertToBoolean,
},
migrateSortSettings: {
convert: convertToObject<SortSettings>,
},
@@ -437,6 +440,7 @@ export const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = [
'migrateCategories',
'migrateTracking',
'deleteChapters',
'migrateMetadata',
'migrateSortSettings',
// browse
@@ -696,3 +700,13 @@ export const METADATA_MIGRATIONS: IMetadataMigration[] = [
deleteKeys: ['sourceLanguages', 'extensionLanguages'],
},
];
export const ALL_APP_METADATA_KEY_PREFIXES: string[] = [
...METADATA_MIGRATIONS.reduce<Set<string>>((acc, migration) => {
if (migration.appKeyPrefix?.oldPrefix) {
acc.add(migration.appKeyPrefix.oldPrefix);
}
return acc;
}, new Set()),
APP_METADATA_KEY_PREFIX,
];

View File

@@ -39,7 +39,7 @@ type MetadataUpdateOptions = {
type ProcessedEntityMetadata = {
updateMetas: MetaInput[];
deleteKeys: string[];
postUpdateDeleteKeys: string[];
migrateMetas: MetaInput[];
};
@@ -98,7 +98,7 @@ const processEntityMetadata = (
value: `${value}`,
}));
return { updateMetas: allUpdateMetas, deleteKeys: uniqueDeleteKeys, migrateMetas };
return { updateMetas: allUpdateMetas, postUpdateDeleteKeys: uniqueDeleteKeys, migrateMetas };
};
type ProcessedEntry = ProcessedEntityMetadata & { metadataHolder: GqlMetaHolder };
@@ -107,11 +107,11 @@ const groupByIdenticalMetas = <Id extends number | string>(
processed: Array<ProcessedEntry & { metadataHolder: { id: Id } }>,
): {
updateGroups: Array<{ ids: Id[]; metas: MetaInput[] }>;
deleteGroups: Array<{ ids: Id[]; keys: string[] }>;
postUpdateDeleteGroups: Array<{ ids: Id[]; keys: string[] }>;
migrateGroups: Array<{ ids: Id[]; metas: MetaInput[] }>;
} => {
const updateMap = new Map<string, { ids: Id[]; metas: MetaInput[] }>();
const deleteMap = new Map<string, { ids: Id[]; keys: string[] }>();
const postUpdateDeleteMap = new Map<string, { ids: Id[]; keys: string[] }>();
const migrateMap = new Map<string, { ids: Id[]; metas: MetaInput[] }>();
for (const entry of processed) {
@@ -127,13 +127,13 @@ const groupByIdenticalMetas = <Id extends number | string>(
}
}
if (entry.deleteKeys.length > 0) {
const key = JSON.stringify(entry.deleteKeys);
const existing = deleteMap.get(key);
if (entry.postUpdateDeleteKeys.length > 0) {
const key = JSON.stringify(entry.postUpdateDeleteKeys);
const existing = postUpdateDeleteMap.get(key);
if (existing) {
existing.ids.push(id);
} else {
deleteMap.set(key, { ids: [id], keys: entry.deleteKeys });
postUpdateDeleteMap.set(key, { ids: [id], keys: entry.postUpdateDeleteKeys });
}
}
@@ -150,7 +150,7 @@ const groupByIdenticalMetas = <Id extends number | string>(
return {
updateGroups: [...updateMap.values()],
deleteGroups: [...deleteMap.values()],
postUpdateDeleteGroups: [...postUpdateDeleteMap.values()],
migrateGroups: [...migrateMap.values()],
};
};
@@ -159,18 +159,21 @@ const createEntityMetaInput = <Key extends string, Id extends number | string>(
processed: ProcessedEntry[],
idKey: Key,
) => {
const { updateGroups, deleteGroups, migrateGroups } = groupByIdenticalMetas(
const { updateGroups, postUpdateDeleteGroups, migrateGroups } = groupByIdenticalMetas(
processed as Array<ProcessedEntry & { metadataHolder: { id: Id } }>,
);
return {
preUpdateDeleteInput: {
items: [],
},
updateInput: {
items: updateGroups.map(
({ ids, metas }) => ({ [idKey]: ids, metas }) as Record<Key, Id[]> & { metas: MetaInput[] },
),
},
deleteInput: {
items: deleteGroups.map(
postUpdateDeleteInput: {
items: postUpdateDeleteGroups.map(
({ ids, keys }) => ({ [idKey]: ids, keys }) as Record<Key, Id[]> & { keys: string[] },
),
},
@@ -198,12 +201,15 @@ const requestBatchMetadataUpdate = async (
switch (holderType) {
case 'global': {
const withUpdates = processed.filter(({ updateMetas }) => updateMetas.length > 0);
const withDeletes = processed.filter(({ deleteKeys }) => deleteKeys.length > 0);
const withDeletes = processed.filter(({ postUpdateDeleteKeys }) => postUpdateDeleteKeys.length > 0);
const withMigrations = processed.filter(({ migrateMetas }) => migrateMetas.length > 0);
await requestManager.updateGlobalMeta({
preUpdateDeleteInput: { keys: [] },
updateInput: { metas: withUpdates.flatMap(({ updateMetas }) => updateMetas) },
deleteInput: { keys: withDeletes.flatMap(({ deleteKeys }) => deleteKeys) },
postUpdateDeleteInput: {
keys: withDeletes.flatMap(({ postUpdateDeleteKeys }) => postUpdateDeleteKeys),
},
migrateInput: { metas: withMigrations.flatMap(({ migrateMetas }) => migrateMetas) },
}).response;
break;

View File

@@ -30,5 +30,6 @@ export type MetadataMigrationSettings = {
migrateCategories: boolean;
migrateTracking: boolean;
deleteChapters: boolean;
migrateMetadata: boolean;
migrateSortSettings: SortSettings;
};

View File

@@ -37,7 +37,7 @@ export const MigrateDialog = ({ mangaIdToMigrateTo, onClose }: { mangaIdToMigrat
const mangaId = Number(mangaIdAsString);
const {
settings: { migrateChapters, migrateCategories, migrateTracking, deleteChapters },
settings: { migrateChapters, migrateCategories, migrateTracking, deleteChapters, migrateMetadata },
} = useMetadataServerSettings();
const [isMigrationInProcess, setIsMigrationInProcess] = useState(false);
@@ -62,6 +62,7 @@ export const MigrateDialog = ({ mangaIdToMigrateTo, onClose }: { mangaIdToMigrat
migrateCategories,
migrateTracking,
deleteChapters,
migrateMetadata,
});
navigate(AppRoutes.manga.path(mangaIdToMigrateTo), { replace: true });
@@ -93,6 +94,12 @@ export const MigrateDialog = ({ mangaIdToMigrateTo, onClose }: { mangaIdToMigrat
checked={migrateTracking}
onChange={(_, checked) => setMigrationFlag('migrateTracking', checked)}
/>
<CheckboxInput
disabled={isMigrationInProcess}
label={t`Client data`}
checked={migrateMetadata}
onChange={(_, checked) => setMigrationFlag('migrateMetadata', checked)}
/>
<CheckboxInput
disabled={isMigrationInProcess}
label={t`Delete downloaded`}

View File

@@ -64,6 +64,7 @@ export const SERVER_SETTINGS_METADATA_DEFAULT: MetadataServerSettings = {
migrateCategories: true,
migrateTracking: true,
deleteChapters: true,
migrateMetadata: true,
migrateSortSettings: DEFAULT_SORT_SETTINGS,
// browse