From 0e8ddd471779f56b8d9001133260ee39aa6745ed Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Wed, 16 Oct 2024 19:43:04 +0200 Subject: [PATCH] Prevent library duplicates logic from blocking UI --- src/modules/library/Library.types.ts | 13 +- .../library/screens/LibraryDuplicates.tsx | 129 ++++-------------- .../library/util/LibraryDuplicates.util.ts | 103 ++++++++++++++ .../workers/LibraryDuplicatesWorker.ts | 26 ++++ 4 files changed, 164 insertions(+), 107 deletions(-) create mode 100644 src/modules/library/util/LibraryDuplicates.util.ts create mode 100644 src/modules/library/workers/LibraryDuplicatesWorker.ts diff --git a/src/modules/library/Library.types.ts b/src/modules/library/Library.types.ts index 68bed005..183cc8cb 100644 --- a/src/modules/library/Library.types.ts +++ b/src/modules/library/Library.types.ts @@ -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>; hasStatus: Record>; } + +export type TMangaDuplicate = Pick; + +export type TMangaDuplicates = Record; + +export type TMangaDuplicateResult = { byTitle: Manga[]; byAlternativeTitle: Manga[] }; + +export type LibraryDuplicatesWorkerInput = { + mangas: Manga[]; + checkAlternativeTitles: boolean; +}; diff --git a/src/modules/library/screens/LibraryDuplicates.tsx b/src/modules/library/screens/LibraryDuplicates.tsx index ab25642b..77cf4f41 100644 --- a/src/modules/library/screens/LibraryDuplicates.tsx +++ b/src/modules/library/screens/LibraryDuplicates.tsx @@ -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 = >( - libraryMangas: Manga[], -): Record => { - 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; - -const findDuplicatesByTitleAndAlternativeTitlesSingleManga = ( - manga: Manga, - mangas: Manga[], -): { byTitle: Manga[]; byAlternativeTitle: Manga[] } => { - const titleToCheck = enhancedCleanup(manga.title); - - const result: ReturnType> = { - 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 = ( - libraryMangas: Manga[], -): Record => { - const titleToMangas: Record = {}; - const titleToAlternativeTitleMatches: Record = {}; - - 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>({}); + 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>) => { + 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 ; } diff --git a/src/modules/library/util/LibraryDuplicates.util.ts b/src/modules/library/util/LibraryDuplicates.util.ts new file mode 100644 index 00000000..afb200b0 --- /dev/null +++ b/src/modules/library/util/LibraryDuplicates.util.ts @@ -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 = >( + libraryMangas: Manga[], +): TMangaDuplicates => { + 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: Manga, + mangas: Manga[], +): TMangaDuplicateResult => { + const titleToCheck = enhancedCleanup(manga.title); + + const result: ReturnType> = { + 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 = ( + mangas: Manga[], +): Promise> => { + const titleToMangas: TMangaDuplicates = {}; + const titleToAlternativeTitleMatches: TMangaDuplicates = {}; + + 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); +}; diff --git a/src/modules/library/workers/LibraryDuplicatesWorker.ts b/src/modules/library/workers/LibraryDuplicatesWorker.ts new file mode 100644 index 00000000..d7a22bd9 --- /dev/null +++ b/src/modules/library/workers/LibraryDuplicatesWorker.ts @@ -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) => { + const { mangas } = event.data; + const { checkAlternativeTitles } = event.data; + + if (checkAlternativeTitles) { + postMessage(findDuplicatesByTitleAndAlternativeTitles(mangas)); + return; + } + + postMessage(findDuplicatesByTitle(mangas)); +};