/* * 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 { StringParam, useQueryParam } from 'use-query-params'; import { useMemo } from 'react'; import { LibraryOptions, LibrarySortMode, NullAndUndefined } from '@/typings.ts'; import { useLibraryOptionsContext } from '@/components/context/LibraryOptionsContext.tsx'; import { useMetadataServerSettings } from '@/lib/metadata/metadataServerSettings.ts'; import { ChapterType, MangaType, SourceType, TrackRecordType } from '@/lib/graphql/generated/graphql.ts'; import { MangaIdInfo } from '@/lib/data/Mangas.ts'; import { baseCleanup, enhancedCleanup } from '@/lib/data/Strings.ts'; const triStateFilter = ( triState: NullAndUndefined, enabledFilter: () => boolean, disabledFilter: () => boolean, ): boolean => { switch (triState) { case true: return enabledFilter(); case false: return disabledFilter(); default: return true; } }; const triStateFilterNumber = (triState: NullAndUndefined, count?: number): boolean => triStateFilter( triState, () => !!count && count >= 1, () => count === 0, ); const triStateFilterBoolean = (triState: NullAndUndefined, status?: boolean): boolean => triStateFilter( triState, () => !!status, () => !status, ); type TMangaQueryFilter = Pick; const queryFilter = (query: NullAndUndefined, { title }: TMangaQueryFilter): boolean => { if (!query) return true; const cleanedUpQuery = baseCleanup(query); return baseCleanup(title).includes(cleanedUpQuery) || enhancedCleanup(title).includes(cleanedUpQuery); }; type TMangaQueryGenreFilter = Pick; const queryGenreFilter = (query: NullAndUndefined, { genre: mangaGenres }: TMangaQueryGenreFilter): boolean => { if (!query) return true; const genreQueries = query.split(',').map(baseCleanup); const genres = mangaGenres .map((genre) => { const baseCleanedUpGenre = baseCleanup(genre); const fullyCleanedUpGenre = enhancedCleanup(baseCleanedUpGenre); return `${baseCleanedUpGenre}, ${fullyCleanedUpGenre}`; }) .join(', '); return genreQueries.every((genreQuery) => genres.includes(genreQuery)); }; type TMangaDescriptionFilter = Pick; const queryDescriptionFilter = (query: NullAndUndefined, { description }: TMangaDescriptionFilter): boolean => { if (!query) return true; if (!description) return false; const cleanedUpQuery = baseCleanup(query); return baseCleanup(description).includes(cleanedUpQuery) || enhancedCleanup(description).includes(cleanedUpQuery); }; type TMangaArtistFilter = Pick; const queryArtistFilter = (query: NullAndUndefined, { artist }: TMangaArtistFilter): boolean => { if (!query) return true; if (!artist) return false; return enhancedCleanup(artist).includes(enhancedCleanup(query)); }; type TMangaAuthorFilter = Pick; const queryAuthorFilter = (query: NullAndUndefined, { author }: TMangaAuthorFilter): boolean => { if (!query) return true; if (!author) return false; return enhancedCleanup(author).includes(enhancedCleanup(query)); }; type TMangaSourceFilter = { source?: NullAndUndefined> }; const querySourceFilter = (query: NullAndUndefined, { source }: TMangaSourceFilter): boolean => { if (!query) return true; if (!source) return false; return enhancedCleanup(source.displayName).includes(enhancedCleanup(query)); }; type TMangaTrackerFilter = { trackRecords: { nodes: Pick[] } }; const trackerFilter = (trackFilters: LibraryOptions['tracker'], manga: TMangaTrackerFilter): boolean => Object.entries(trackFilters) .map(([trackFilterId, trackFilterState]) => { const isTrackerBound = manga.trackRecords.nodes.some( (trackRecord) => trackRecord.trackerId === Number(trackFilterId), ); return triStateFilter( trackFilterState, () => isTrackerBound, () => !isTrackerBound, ); }) .every((matchesFilter) => matchesFilter); type TMangaFilter = TMangaQueryFilter & TMangaQueryGenreFilter & TMangaTrackerFilter & TMangaDescriptionFilter & TMangaArtistFilter & TMangaAuthorFilter & TMangaSourceFilter & Pick; const filterManga = ( mangas: Manga[], query: NullAndUndefined, unread: NullAndUndefined, downloaded: NullAndUndefined, bookmarked: NullAndUndefined, hasDuplicateChapters: NullAndUndefined, tracker: LibraryOptions['tracker'], ignoreFilters: boolean, ): Manga[] => mangas.filter((manga) => { const ignoreFiltersWhileSearching = ignoreFilters && query?.length; const matchesSearch = queryFilter(query, manga) || queryGenreFilter(query, manga) || queryDescriptionFilter(query, manga) || queryArtistFilter(query, manga) || queryAuthorFilter(query, manga) || querySourceFilter(query, manga); const matchesFilters = ignoreFiltersWhileSearching || (triStateFilterNumber(downloaded, manga.downloadCount) && triStateFilterNumber(unread, manga.unreadCount) && triStateFilterNumber(bookmarked, manga.bookmarkCount) && triStateFilterBoolean(hasDuplicateChapters, manga.hasDuplicateChapters) && trackerFilter(tracker, manga)); return matchesSearch && matchesFilters; }); const sortByNumber = (a: number | string = 0, b: number | string = 0) => Number(a) - Number(b); const sortByString = (a: string, b: string): number => a.localeCompare(b); type TMangaSort = Pick & { lastReadChapter?: Pick | null; latestUploadedChapter?: Pick | null; latestFetchedChapter?: Pick | null; }; const sortManga = ( manga: Manga[], sort: NullAndUndefined, desc: NullAndUndefined, ): Manga[] => { const result = [...manga]; switch (sort) { case 'sortAlph': result.sort((a, b) => sortByString(a.title, b.title)); break; case 'sortDateAdded': result.sort((a, b) => sortByNumber(a.inLibraryAt, b.inLibraryAt)); break; case 'sortToRead': result.sort((a, b) => sortByNumber(a.unreadCount, b.unreadCount)); break; case 'sortLastRead': result.sort((a, b) => sortByNumber(a.lastReadChapter?.lastReadAt, b.lastReadChapter?.lastReadAt)); break; case 'sortLatestUploadedChapter': result.sort((a, b) => sortByNumber(a.latestUploadedChapter?.uploadDate, b.latestUploadedChapter?.uploadDate), ); break; case 'sortLatestFetchedChapter': result.sort((a, b) => sortByNumber(a.latestFetchedChapter?.fetchedAt, b.latestFetchedChapter?.fetchedAt)); break; default: break; } if (desc) { result.reverse(); } return result; }; export const useGetVisibleLibraryMangas = ( mangas: Manga[], ): { visibleMangas: Manga[]; showFilteredOutMessage: boolean; } => { const [query] = useQueryParam('query', StringParam); const { options } = useLibraryOptionsContext(); const { unread, downloaded, bookmarked, tracker, hasDuplicateChapters } = options; const { settings } = useMetadataServerSettings(); const filteredMangas = useMemo( () => filterManga( mangas, query, unread, downloaded, bookmarked, hasDuplicateChapters, tracker, settings.ignoreFilters, ), [mangas, query, unread, downloaded, bookmarked, hasDuplicateChapters, tracker, settings.ignoreFilters], ); const sortedMangas = useMemo( () => sortManga(filteredMangas, options.sorts, options.sortDesc), [filteredMangas, options.sorts, options.sortDesc], ); const isATrackFilterActive = Object.values(options.tracker).some((trackFilterState) => trackFilterState != null); const showFilteredOutMessage = (unread != null || downloaded != null || bookmarked != null || !!query || isATrackFilterActive) && filteredMangas.length === 0 && mangas.length > 0; return { visibleMangas: sortedMangas, showFilteredOutMessage, }; };