Prevent library duplicates logic from blocking UI
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { NullAndUndefined } from '@/Base.types.ts';
|
||||
import { MangaStatus, TrackerType } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { MangaStatus, MangaType, TrackerType } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { GridLayout } from '@/modules/core/Core.types.ts';
|
||||
|
||||
export type MetadataLibrarySettings = {
|
||||
@@ -44,3 +44,14 @@ export interface LibraryOptions {
|
||||
hasTrackerBinding: Record<TrackerType['id'], NullAndUndefined<boolean>>;
|
||||
hasStatus: Record<MangaStatus, NullAndUndefined<boolean>>;
|
||||
}
|
||||
|
||||
export type TMangaDuplicate = Pick<MangaType, 'id' | 'title' | 'description'>;
|
||||
|
||||
export type TMangaDuplicates<Manga> = Record<string, Manga[]>;
|
||||
|
||||
export type TMangaDuplicateResult<Manga> = { byTitle: Manga[]; byAlternativeTitle: Manga[] };
|
||||
|
||||
export type LibraryDuplicatesWorkerInput<Manga extends TMangaDuplicate = TMangaDuplicate> = {
|
||||
mangas: Manga[];
|
||||
checkAlternativeTitles: boolean;
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCallback, useContext, useLayoutEffect, useMemo } from 'react';
|
||||
import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import PopupState, { bindMenu, bindTrigger } from 'material-ui-popup-state';
|
||||
@@ -26,111 +26,13 @@ import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'
|
||||
import { MangaCard } from '@/modules/manga/components/cards/MangaCard.tsx';
|
||||
import { StyledGroupedVirtuoso } from '@/modules/core/components/virtuoso/StyledGroupedVirtuoso.tsx';
|
||||
import { StyledGroupHeader } from '@/modules/core/components/virtuoso/StyledGroupHeader.tsx';
|
||||
import {
|
||||
GetMangasDuplicatesQuery,
|
||||
GetMangasDuplicatesQueryVariables,
|
||||
MangaType,
|
||||
} from '@/lib/graphql/generated/graphql.ts';
|
||||
import { GetMangasDuplicatesQuery, GetMangasDuplicatesQueryVariables } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { GET_MANGAS_DUPLICATES } from '@/lib/graphql/queries/MangaQuery.ts';
|
||||
import { BaseMangaGrid } from '@/modules/manga/components/BaseMangaGrid.tsx';
|
||||
import { IMangaGridProps } from '@/modules/manga/components/MangaGrid.tsx';
|
||||
import { StyledGroupItemWrapper } from '@/modules/core/components/virtuoso/StyledGroupItemWrapper.tsx';
|
||||
import { enhancedCleanup } from '@/util/Strings.ts';
|
||||
import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx';
|
||||
|
||||
const findDuplicatesByTitle = <Manga extends Pick<MangaType, 'title'>>(
|
||||
libraryMangas: Manga[],
|
||||
): Record<string, Manga[]> => {
|
||||
const titleToMangas = Object.groupBy(libraryMangas, ({ title }) => enhancedCleanup(title));
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(titleToMangas)
|
||||
.filter((titleToMangaMap): titleToMangaMap is [string, Manga[]] => (titleToMangaMap[1]?.length ?? 0) > 1)
|
||||
.map(([, mangas]) => [mangas[0].title, mangas]),
|
||||
);
|
||||
};
|
||||
|
||||
type TMangaDuplicate = Pick<MangaType, 'id' | 'title' | 'description'>;
|
||||
|
||||
const findDuplicatesByTitleAndAlternativeTitlesSingleManga = <Manga extends TMangaDuplicate>(
|
||||
manga: Manga,
|
||||
mangas: Manga[],
|
||||
): { byTitle: Manga[]; byAlternativeTitle: Manga[] } => {
|
||||
const titleToCheck = enhancedCleanup(manga.title);
|
||||
|
||||
const result: ReturnType<typeof findDuplicatesByTitleAndAlternativeTitlesSingleManga<Manga>> = {
|
||||
byTitle: [manga],
|
||||
byAlternativeTitle: [manga],
|
||||
};
|
||||
|
||||
mangas.forEach((libraryManga) => {
|
||||
const isDifferentManga = manga.id !== libraryManga.id;
|
||||
if (!isDifferentManga) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doesTitleMatch = enhancedCleanup(libraryManga.title) === titleToCheck;
|
||||
const doesAlternativeTitleMatch = enhancedCleanup(libraryManga?.description ?? '').includes(titleToCheck);
|
||||
|
||||
const isDuplicate = doesTitleMatch || doesAlternativeTitleMatch;
|
||||
if (!isDuplicate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (doesTitleMatch) {
|
||||
result.byTitle.push(libraryManga);
|
||||
}
|
||||
|
||||
if (doesAlternativeTitleMatch) {
|
||||
result.byAlternativeTitle.push(libraryManga);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const findDuplicatesByTitleAndAlternativeTitles = <Manga extends TMangaDuplicate>(
|
||||
libraryMangas: Manga[],
|
||||
): Record<string, Manga[]> => {
|
||||
const titleToMangas: Record<string, Manga[]> = {};
|
||||
const titleToAlternativeTitleMatches: Record<string, Manga[]> = {};
|
||||
|
||||
libraryMangas.forEach((mangaToCheck) => {
|
||||
const titleToCheck = enhancedCleanup(mangaToCheck.title);
|
||||
|
||||
titleToMangas[titleToCheck] ??= [];
|
||||
titleToAlternativeTitleMatches[titleToCheck] ??= [];
|
||||
|
||||
const { byTitle, byAlternativeTitle } = findDuplicatesByTitleAndAlternativeTitlesSingleManga(
|
||||
mangaToCheck,
|
||||
libraryMangas,
|
||||
);
|
||||
|
||||
titleToMangas[titleToCheck].push(...byTitle);
|
||||
titleToAlternativeTitleMatches[titleToCheck].push(...byAlternativeTitle);
|
||||
});
|
||||
|
||||
const titleToDuplicatesEntries = Object.entries(titleToMangas)
|
||||
.map(([title, titleMatches]) => {
|
||||
const uniqueTitleMatches = new Set(titleMatches);
|
||||
const uniqueAlternativeTitleMatches = new Set(titleToAlternativeTitleMatches[title] ?? []);
|
||||
|
||||
const originalTitle = [...uniqueTitleMatches][0].title;
|
||||
|
||||
const combinedDuplicates = [...uniqueTitleMatches, ...uniqueAlternativeTitleMatches];
|
||||
const duplicates = [...new Set([...combinedDuplicates])];
|
||||
|
||||
const noDuplicatesFound = duplicates.length === 1;
|
||||
if (noDuplicatesFound) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [originalTitle, duplicates];
|
||||
})
|
||||
.filter((entry) => !!entry);
|
||||
|
||||
return Object.fromEntries(titleToDuplicatesEntries);
|
||||
};
|
||||
import { LibraryDuplicatesWorkerInput, TMangaDuplicate, TMangaDuplicates } from '@/modules/library/Library.types.ts';
|
||||
|
||||
export const LibraryDuplicates = () => {
|
||||
const { t } = useTranslation();
|
||||
@@ -181,14 +83,29 @@ export const LibraryDuplicates = () => {
|
||||
GetMangasDuplicatesQueryVariables
|
||||
>(GET_MANGAS_DUPLICATES, { condition: { inLibrary: true } });
|
||||
|
||||
const mangasByTitle = useMemo(() => {
|
||||
const [isCheckingForDuplicates, setIsCheckingForDuplicates] = useState(true);
|
||||
|
||||
const [mangasByTitle, setMangasByTitle] = useState<Record<string, TMangaDuplicate[]>>({});
|
||||
useEffect(() => {
|
||||
setIsCheckingForDuplicates(true);
|
||||
const libraryMangas: TMangaDuplicate[] = data?.mangas.nodes ?? [];
|
||||
|
||||
if (checkAlternativeTitles) {
|
||||
return findDuplicatesByTitleAndAlternativeTitles(libraryMangas);
|
||||
if (!libraryMangas.length) {
|
||||
setMangasByTitle({});
|
||||
return () => {};
|
||||
}
|
||||
|
||||
return findDuplicatesByTitle(libraryMangas);
|
||||
const worker = new Worker(new URL('../workers/LibraryDuplicatesWorker.ts', import.meta.url), {
|
||||
type: 'module',
|
||||
});
|
||||
|
||||
worker.onmessage = (event: MessageEvent<TMangaDuplicates<(typeof libraryMangas)[number]>>) => {
|
||||
setMangasByTitle(event.data);
|
||||
setIsCheckingForDuplicates(false);
|
||||
};
|
||||
worker.postMessage({ mangas: libraryMangas, checkAlternativeTitles } satisfies LibraryDuplicatesWorkerInput);
|
||||
|
||||
return () => worker.terminate();
|
||||
}, [data?.mangas.nodes, checkAlternativeTitles]);
|
||||
|
||||
const duplicatedTitles = useMemo(
|
||||
@@ -213,7 +130,7 @@ export const LibraryDuplicates = () => {
|
||||
),
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
if (loading || isCheckingForDuplicates) {
|
||||
return <LoadingPlaceholder />;
|
||||
}
|
||||
|
||||
|
||||
103
src/modules/library/util/LibraryDuplicates.util.ts
Normal file
103
src/modules/library/util/LibraryDuplicates.util.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* 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 { MangaType } from '@/lib/graphql/generated/graphql.ts';
|
||||
import { enhancedCleanup } from '@/util/Strings.ts';
|
||||
import { TMangaDuplicate, TMangaDuplicateResult, TMangaDuplicates } from '@/modules/library/Library.types.ts';
|
||||
|
||||
export const findDuplicatesByTitle = <Manga extends Pick<MangaType, 'title'>>(
|
||||
libraryMangas: Manga[],
|
||||
): TMangaDuplicates<Manga> => {
|
||||
const titleToMangas = Object.groupBy(libraryMangas, ({ title }) => enhancedCleanup(title));
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(titleToMangas)
|
||||
.filter((titleToMangaMap): titleToMangaMap is [string, Manga[]] => (titleToMangaMap[1]?.length ?? 0) > 1)
|
||||
.map(([, mangas]) => [mangas[0].title, mangas]),
|
||||
);
|
||||
};
|
||||
|
||||
const findDuplicatesByTitleAndAlternativeTitlesSingleManga = <Manga extends TMangaDuplicate>(
|
||||
manga: Manga,
|
||||
mangas: Manga[],
|
||||
): TMangaDuplicateResult<Manga> => {
|
||||
const titleToCheck = enhancedCleanup(manga.title);
|
||||
|
||||
const result: ReturnType<typeof findDuplicatesByTitleAndAlternativeTitlesSingleManga<Manga>> = {
|
||||
byTitle: [manga],
|
||||
byAlternativeTitle: [manga],
|
||||
};
|
||||
|
||||
mangas.forEach((libraryManga) => {
|
||||
const isDifferentManga = manga.id !== libraryManga.id;
|
||||
if (!isDifferentManga) {
|
||||
return;
|
||||
}
|
||||
|
||||
const doesTitleMatch = enhancedCleanup(libraryManga.title) === titleToCheck;
|
||||
const doesAlternativeTitleMatch = enhancedCleanup(libraryManga?.description ?? '').includes(titleToCheck);
|
||||
|
||||
const isDuplicate = doesTitleMatch || doesAlternativeTitleMatch;
|
||||
if (!isDuplicate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (doesTitleMatch) {
|
||||
result.byTitle.push(libraryManga);
|
||||
}
|
||||
|
||||
if (doesAlternativeTitleMatch) {
|
||||
result.byAlternativeTitle.push(libraryManga);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const findDuplicatesByTitleAndAlternativeTitles = <Manga extends TMangaDuplicate>(
|
||||
mangas: Manga[],
|
||||
): Promise<TMangaDuplicates<Manga>> => {
|
||||
const titleToMangas: TMangaDuplicates<Manga> = {};
|
||||
const titleToAlternativeTitleMatches: TMangaDuplicates<Manga> = {};
|
||||
|
||||
mangas.forEach((mangaToCheck) => {
|
||||
const titleToCheck = enhancedCleanup(mangaToCheck.title);
|
||||
|
||||
titleToMangas[titleToCheck] ??= [];
|
||||
titleToAlternativeTitleMatches[titleToCheck] ??= [];
|
||||
|
||||
const { byTitle, byAlternativeTitle } = findDuplicatesByTitleAndAlternativeTitlesSingleManga(
|
||||
mangaToCheck,
|
||||
mangas,
|
||||
);
|
||||
|
||||
titleToMangas[titleToCheck].push(...byTitle);
|
||||
titleToAlternativeTitleMatches[titleToCheck].push(...byAlternativeTitle);
|
||||
});
|
||||
|
||||
const titleToDuplicatesEntries = Object.entries(titleToMangas)
|
||||
.map(([title, titleMatches]) => {
|
||||
const uniqueTitleMatches = new Set(titleMatches);
|
||||
const uniqueAlternativeTitleMatches = new Set(titleToAlternativeTitleMatches[title] ?? []);
|
||||
|
||||
const originalTitle = [...uniqueTitleMatches][0].title;
|
||||
|
||||
const combinedDuplicates = [...uniqueTitleMatches, ...uniqueAlternativeTitleMatches];
|
||||
const duplicates = [...new Set([...combinedDuplicates])];
|
||||
|
||||
const noDuplicatesFound = duplicates.length === 1;
|
||||
if (noDuplicatesFound) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [originalTitle, duplicates];
|
||||
})
|
||||
.filter((entry) => !!entry);
|
||||
|
||||
return Object.fromEntries(titleToDuplicatesEntries);
|
||||
};
|
||||
26
src/modules/library/workers/LibraryDuplicatesWorker.ts
Normal file
26
src/modules/library/workers/LibraryDuplicatesWorker.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
* 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 {
|
||||
findDuplicatesByTitle,
|
||||
findDuplicatesByTitleAndAlternativeTitles,
|
||||
} from '@/modules/library/util/LibraryDuplicates.util.ts';
|
||||
import { LibraryDuplicatesWorkerInput } from '@/modules/library/Library.types.ts';
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
self.onmessage = async (event: MessageEvent<LibraryDuplicatesWorkerInput>) => {
|
||||
const { mangas } = event.data;
|
||||
const { checkAlternativeTitles } = event.data;
|
||||
|
||||
if (checkAlternativeTitles) {
|
||||
postMessage(findDuplicatesByTitleAndAlternativeTitles(mangas));
|
||||
return;
|
||||
}
|
||||
|
||||
postMessage(findDuplicatesByTitle(mangas));
|
||||
};
|
||||
Reference in New Issue
Block a user