/* * 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 type { ApolloClient, DocumentNode, InMemoryCache, MaybeMasked, OperationVariables, TypedDocumentNode, } from '@apollo/client/core'; import { CombinedGraphQLErrors } from '@apollo/client/core'; import { useMutation, useQuery, useSubscription } from '@apollo/client/react'; import type { Reference } from '@apollo/client/utilities'; import { useEffect, useMemo, useRef, useState } from 'react'; import { d } from 'koration'; import type { IRestClient } from '@/lib/requests/client/RestClient.ts'; import { RestClient } from '@/lib/requests/client/RestClient.ts'; import { GraphQLClient } from '@/lib/requests/client/GraphQLClient.ts'; import { BaseClient } from '@/lib/requests/client/BaseClient.ts'; import type { AddExtensionStoreMutation, AddExtensionStoreMutationVariables, CheckForServerUpdatesQuery, CheckForServerUpdatesQueryVariables, CheckForWebuiUpdateQuery, CheckForWebuiUpdateQueryVariables, ClearDownloaderMutation, ClearDownloaderMutationVariables, ClearServerCacheMutation, ClearServerCacheMutationVariables, CreateBackupMutation, CreateBackupMutationVariables, CreateCategoryMutation, CreateCategoryMutationVariables, DeleteCategoryMutation, DeleteCategoryMutationVariables, DeleteDownloadedChapterMutation, DeleteDownloadedChapterMutationVariables, DeleteDownloadedChaptersMutation, DeleteDownloadedChaptersMutationVariables, DequeueChapterDownloadMutation, DequeueChapterDownloadMutationVariables, DequeueChapterDownloadsMutation, DequeueChapterDownloadsMutationVariables, DownloadStatusSubscription, DownloadStatusSubscriptionVariables, EnqueueChapterDownloadMutation, EnqueueChapterDownloadMutationVariables, EnqueueChapterDownloadsMutation, EnqueueChapterDownloadsMutationVariables, GetAboutQuery, GetAboutQueryVariables, GetCategoriesSettingsQuery, GetCategoriesSettingsQueryVariables, GetCategoryMangasQuery, GetCategoryMangasQueryVariables, GetChapterPagesFetchMutation, GetChapterPagesFetchMutationVariables, GetChaptersHistoryQuery, GetChaptersHistoryQueryVariables, GetChaptersMangaQuery, GetChaptersMangaQueryVariables, GetChaptersUpdatesQuery, GetChaptersUpdatesQueryVariables, GetDownloadStatusQuery, GetDownloadStatusQueryVariables, GetExtensionQuery, GetExtensionQueryVariables, GetExtensionsFetchMutation, GetExtensionsFetchMutationVariables, GetExtensionsQuery, GetExtensionsQueryVariables, GetExtensionStoreQuery, GetExtensionStoreQueryVariables, GetExtensionStoresQuery, GetExtensionStoresQueryVariables, GetGlobalMetadatasQuery, GetGlobalMetadatasQueryVariables, GetKoSyncStatusQuery, GetKoSyncStatusQueryVariables, GetLastUpdateTimestampQuery, GetLastUpdateTimestampQueryVariables, GetMangasLibraryQuery, GetMangasLibraryQueryVariables, GetMangaToMigrateQuery, GetMangaToMigrateQueryVariables, GetMangaToMigrateToFetchMutation, GetMangaToMigrateToFetchMutationVariables, GetMigratableSourcesQuery, GetMigratableSourcesQueryVariables, GetRestoreStatusQuery, GetRestoreStatusQueryVariables, GetServerSettingsQuery, GetServerSettingsQueryVariables, GetSourceMangasFetchMutation, GetSourceMangasFetchMutationVariables, GetSourcesListQuery, GetSourcesListQueryVariables, GetUpdateStatusQuery, GetUpdateStatusQueryVariables, GetWebuiUpdateStatusQuery, GetWebuiUpdateStatusQueryVariables, InstallExternalExtensionMutation, InstallExternalExtensionMutationVariables, KoSyncLoginMutation, KoSyncLoginMutationVariables, KoSyncLogoutMutation, KoSyncLogoutMutationVariables, RefreshMangaMutation, RefreshMangaMutationVariables, RemoveExtensionStoreMutation, RemoveExtensionStoreMutationVariables, ReorderChapterDownloadMutation, ReorderChapterDownloadMutationVariables, ResetWebuiUpdateStatusMutation, ResetWebuiUpdateStatusMutationVariables, RestoreBackupMutation, RestoreBackupMutationVariables, StartDownloaderMutation, StartDownloaderMutationVariables, StopDownloaderMutation, StopDownloaderMutationVariables, StopUpdaterMutation, StopUpdaterMutationVariables, TrackerBindMutation, TrackerBindMutationVariables, TrackerBindTrackRecordMutation, TrackerBindTrackRecordMutationVariables, TrackerFetchBindMutation, TrackerFetchBindMutationVariables, TrackerLoginCredentialsMutation, TrackerLoginCredentialsMutationVariables, TrackerLoginOauthMutation, TrackerLoginOauthMutationVariables, TrackerLogoutMutation, TrackerLogoutMutationVariables, TrackerSearchQuery, TrackerSearchQueryVariables, TrackerUnbindMutation, TrackerUnbindMutationVariables, TrackerUpdateBindMutation, TrackerUpdateBindMutationVariables, UpdateCategoryMetadataMutation, UpdateCategoryMetadataMutationVariables, UpdateCategoryMutation, UpdateCategoryMutationVariables, UpdateCategoryOrderMutation, UpdateCategoryOrderMutationVariables, UpdateChapterMetadataMutation, UpdateChapterMetadataMutationVariables, UpdateChapterMutation, UpdateChapterMutationVariables, UpdateChaptersMutation, UpdateChaptersMutationVariables, UpdateExtensionMutation, UpdateExtensionMutationVariables, UpdateExtensionsMutation, UpdateExtensionsMutationVariables, UpdateGlobalMetadataMutation, UpdateGlobalMetadataMutationVariables, UpdateLibraryMutation, UpdateLibraryMutationVariables, UpdateMangaCategoriesMutation, UpdateMangaCategoriesMutationVariables, UpdateMangaMetadataMutation, UpdateMangaMetadataMutationVariables, UpdateMangaMutation, UpdateMangaMutationVariables, UpdateMangasCategoriesMutation, UpdateMangasCategoriesMutationVariables, UpdateMangasMutation, UpdateMangasMutationVariables, UpdaterSubscription, UpdaterSubscriptionVariables, UpdateServerSettingsMutation, UpdateServerSettingsMutationVariables, UpdateSourceMetadataMutation, UpdateSourceMetadataMutationVariables, UpdateSourcePreferencesMutation, UpdateSourcePreferencesMutationVariables, UpdateWebuiMutation, UpdateWebuiMutationVariables, UserLoginMutation, UserLoginMutationVariables, UserRefreshMutation, UserRefreshMutationVariables, ValidateBackupQuery, ValidateBackupQueryVariables, WebuiUpdateSubscription, } from '@/lib/graphql/generated/graphql.ts'; import type { CreateBackupInput, CreateCategoryInput, DeleteCategoryMetasInput, DeleteChapterMetasInput, DeleteGlobalMetasInput, DeleteMangaMetasInput, DeleteSourceMetasInput, FetchSourceMangaInput, FilterChangeInput, RestoreBackupInput, SetCategoryMetasInput, SetChapterMetasInput, SetGlobalMetasInput, SetMangaMetasInput, SetSourceMetasInput, SourcePreferenceChangeInput, UpdateCategoryPatchInput, UpdateChapterPatchInput, UpdateExtensionPatchInput, UpdateMangaCategoriesPatchInput, UpdateMangaPatchInput, UpdateTrackInput, } from '@/lib/graphql/generated/graphql-base.types.ts'; import { CategoryOrderBy, ChapterOrderBy, DownloaderState, DownloadUpdateType, FetchSourceMangaType, MangaJobStatus, SortOrder, } from '@/lib/graphql/generated/graphql-base.types.ts'; import { GET_GLOBAL_METADATAS } from '@/lib/graphql/metadata/GlobalMetadataQuery.ts'; import { UPDATE_GLOBAL_METADATA } from '@/lib/graphql/metadata/GlobalMetadataMutation.ts'; import { CHECK_FOR_SERVER_UPDATES, CHECK_FOR_WEBUI_UPDATE, GET_ABOUT, GET_WEBUI_UPDATE_STATUS, } from '@/lib/graphql/server/ServerInfoQuery.ts'; import { GET_EXTENSION, GET_EXTENSIONS } from '@/lib/graphql/extension/ExtensionQuery.ts'; import { GET_EXTENSIONS_FETCH, INSTALL_EXTERNAL_EXTENSION, UPDATE_EXTENSION, UPDATE_EXTENSIONS, } from '@/lib/graphql/extension/ExtensionMutation.ts'; import { GET_MIGRATABLE_SOURCES, GET_SOURCES_LIST } from '@/lib/graphql/source/SourceQuery.ts'; import { GET_MANGA_TO_MIGRATE_TO_FETCH, REFRESH_MANGA, UPDATE_MANGA, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA_METADATA, UPDATE_MANGAS, UPDATE_MANGAS_CATEGORIES, } from '@/lib/graphql/manga/MangaMutation.ts'; import { GET_MANGA_TO_MIGRATE, GET_MANGA_TRACK_RECORDS, GET_MANGAS_LIBRARY } from '@/lib/graphql/manga/MangaQuery.ts'; import { GET_CATEGORIES_BASE, GET_CATEGORIES_LIBRARY, GET_CATEGORIES_SETTINGS, GET_CATEGORY_MANGAS, } from '@/lib/graphql/category/CategoryQuery.ts'; import { GET_SOURCE_MANGAS_FETCH, UPDATE_SOURCE_METADATA, UPDATE_SOURCE_PREFERENCES, } from '@/lib/graphql/source/SourceMutation.ts'; import { CLEAR_DOWNLOADER, DELETE_DOWNLOADED_CHAPTER, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_CHAPTER_DOWNLOAD, DEQUEUE_CHAPTER_DOWNLOADS, ENQUEUE_CHAPTER_DOWNLOAD, ENQUEUE_CHAPTER_DOWNLOADS, REORDER_CHAPTER_DOWNLOAD, START_DOWNLOADER, STOP_DOWNLOADER, } from '@/lib/graphql/download/DownloaderMutation.ts'; import { GET_CHAPTERS_HISTORY, GET_CHAPTERS_MANGA, GET_CHAPTERS_READER, GET_CHAPTERS_UPDATES, } from '@/lib/graphql/chapter/ChapterQuery.ts'; import { GET_CHAPTER_PAGES_FETCH, UPDATE_CHAPTER, UPDATE_CHAPTER_METADATA, UPDATE_CHAPTERS, } from '@/lib/graphql/chapter/ChapterMutation.ts'; import { CREATE_CATEGORY, DELETE_CATEGORY, UPDATE_CATEGORY, UPDATE_CATEGORY_METADATA, UPDATE_CATEGORY_ORDER, } from '@/lib/graphql/category/CategoryMutation.ts'; import { STOP_UPDATER, UPDATE_LIBRARY } from '@/lib/graphql/updater/UpdaterMutation.ts'; import { GET_LAST_UPDATE_TIMESTAMP, GET_UPDATE_STATUS } from '@/lib/graphql/updater/UpdaterQuery.ts'; import { CustomCache } from '@/lib/storage/CustomCache.ts'; import { CREATE_BACKUP, RESTORE_BACKUP } from '@/lib/graphql/backup/BackupMutation.ts'; import { GET_RESTORE_STATUS, VALIDATE_BACKUP } from '@/lib/graphql/backup/BackupQuery.ts'; import { DOWNLOAD_STATUS_SUBSCRIPTION } from '@/lib/graphql/download/DownloaderSubscription.ts'; import { UPDATER_SUBSCRIPTION } from '@/lib/graphql/updater/UpdaterSubscription.ts'; import { GET_SERVER_SETTINGS } from '@/lib/graphql/settings/SettingsQuery.ts'; import { UPDATE_SERVER_SETTINGS } from '@/lib/graphql/settings/SettingsMutation.ts'; import { CLEAR_SERVER_CACHE } from '@/lib/graphql/image/ImageMutation.ts'; import { RESET_WEBUI_UPDATE_STATUS, UPDATE_WEBUI } from '@/lib/graphql/server/ServerInfoMutation.ts'; import { WEBUI_UPDATE_SUBSCRIPTION } from '@/lib/graphql/server/ServerInfoSubscription.ts'; import { GET_DOWNLOAD_STATUS } from '@/lib/graphql/download/DownloaderQuery.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import type { QueuePriority } from '@/lib/Queue.ts'; import { SourceAwareQueue } from '@/lib/SourceAwareQueue.ts'; import { TRACKER_SEARCH } from '@/lib/graphql/tracker/TrackerQuery.ts'; import { TRACK_BIND_TRACK_RECORD, TRACKER_BIND, TRACKER_FETCH_BIND, TRACKER_LOGIN_CREDENTIALS, TRACKER_LOGIN_OAUTH, TRACKER_LOGOUT, TRACKER_UNBIND, TRACKER_UPDATE_BIND, } from '@/lib/graphql/tracker/TrackerMutation.ts'; import { ControlledPromise } from '@/lib/ControlledPromise.ts'; import { DOWNLOAD_STATUS_FIELDS } from '@/lib/graphql/download/DownloadFragments.ts'; import { EXTENSION_LIST_FIELDS } from '@/lib/graphql/extension/ExtensionFragments.ts'; import { MANGA_BASE_FIELDS, MANGA_META_FIELDS } from '@/lib/graphql/manga/MangaFragments.ts'; import { GLOBAL_METADATA } from '@/lib/graphql/common/Fragments.ts'; import { CATEGORY_META_FIELDS } from '@/lib/graphql/category/CategoryFragments.ts'; import { SOURCE_META_FIELDS } from '@/lib/graphql/source/SourceFragments.ts'; import { CHAPTER_META_FIELDS } from '@/lib/graphql/chapter/ChapterFragments.ts'; import type { MetadataMigrationSettings } from '@/features/migration/Migration.types.ts'; import type { MangaIdInfo } from '@/features/manga/Manga.types.ts'; import { updateMetadataList } from '@/features/metadata/services/MetadataApolloCacheHandler.ts'; import { USER_LOGIN, USER_REFRESH } from '@/lib/graphql/user/UserMutation.ts'; import { AuthManager } from '@/features/authentication/AuthManager.ts'; import { useLocalStorage } from '@/base/hooks/useStorage.tsx'; import { KO_SYNC_LOGIN, KO_SYNC_LOGOUT } from '@/lib/graphql/koreader/KoreaderSyncMutation.ts'; import { GET_KO_SYNC_STATUS } from '@/lib/graphql/koreader/KoreaderSyncQuery.ts'; import { ImageCache } from '@/lib/service-worker/ImageCache.ts'; import { Sources } from '@/features/source/services/Sources.ts'; import uniqBy from 'lodash/fp/uniqBy'; import { EXTENSION_STORE_FIELDS } from '@/lib/graphql/extension/store/ExtensionStoreFragments.ts'; import { ADD_EXTENSION_STORE, REMOVE_EXTENSION_STORE } from '@/lib/graphql/extension/store/ExtensionStoreMutation.ts'; import { assertIsDefined } from '@/base/Asserts.ts'; import { GET_EXTENSION_STORE, GET_EXTENSION_STORES } from '@/lib/graphql/extension/store/ExtensionStoreQuery.ts'; enum GQLMethod { QUERY = 'QUERY', USE_QUERY = 'USE_QUERY', USE_MUTATION = 'USE_MUTATION', MUTATION = 'MUTATION', USE_SUBSCRIPTION = 'USE_SUBSCRIPTION', } type CustomApolloOptions = { /** * This is a workaround for an apollo bug (?). * * A new abort signal gets passed on every hook call. * This causes the passed arguments to change (due to updating the "context" option, which is only relevant for the actual request), * which - I assume - results in apollo to handle this as a completely new hook call. * Due to this, when e.g. calling "fetchMore", "loading" and "networkStatus" do not get updated when enabling "notifyOnNetworkStatusChange". * * It also causes apollo to spam requests in case of request failures on every rerender. * * Instead of adding the abort signal by default, it has to be added manually which will cause these stated issues (and potentially more?) */ addAbortSignal?: boolean; }; type QueryOptions = Partial< ApolloClient.QueryOptions > & CustomApolloOptions; type QueryHookOptions = Partial< useQuery.Options > & CustomApolloOptions; type MutationHookOptions = Partial< useMutation.Options > & CustomApolloOptions; type MutationOptions = Partial< ApolloClient.MutateOptions > & CustomApolloOptions; type ApolloPaginatedMutationOptions = Partial< MutationHookOptions > & { skipRequest?: boolean }; type SubscriptionHookOptions = Partial< useSubscription.Options > & Omit & { addAbortSignal?: never }; type AbortableRequest = { abortRequest: AbortController['abort'] }; export type ImageRequest = { response: Promise; cleanup: () => void; fromCache: boolean } & AbortableRequest; type ImageRequestOptions = { priority?: QueuePriority; shouldDecode?: boolean; disableCors?: boolean; ignoreQueue?: boolean; }; export type AbortabaleApolloQueryResponse = { response: Promise>>; } & AbortableRequest; export type AbortableApolloUseQueryResponse< Data = any, Variables extends OperationVariables = OperationVariables, > = useQuery.Result, Variables, 'empty' | 'complete' | 'streaming'> & AbortableRequest; export type AbortableApolloUseMutationResponse< Data = any, Variables extends OperationVariables = OperationVariables, > = [useMutation.ResultTuple[0], useMutation.ResultTuple[1] & AbortableRequest]; export type AbortableApolloUseMutationPaginatedResponse< Data = any, Variables extends OperationVariables = OperationVariables, > = [ (page: number) => Promise>>, (Omit[1], 'loading'> & AbortableRequest & { size: number; /** * Indicates whether any request is currently active. * In case only "isLoading" is true, it means that it's the initial request */ isLoading: boolean; /** * Indicates if a next page is being fetched, which is not part of the initial pages */ isLoadingMore: boolean; /** * Indicates if the cached pages are currently getting revalidated */ isValidating: boolean; })[], ]; export type AbortableApolloMutationResponse = { response: Promise>>; } & AbortableRequest; const EXTENSION_LIST_CACHE_KEY = 'useExtensionListFetch'; const CACHE_INITIAL_PAGES_FETCHING_KEY = 'GET_SOURCE_MANGAS_FETCH_FETCHING_INITIAL_PAGES'; const CACHE_PAGES_KEY = 'GET_SOURCE_MANGAS_FETCH_PAGES'; const CACHE_RESULTS_KEY = 'GET_SOURCE_MANGAS_FETCH'; export const SPECIAL_ED_SOURCES = { REVALIDATION_UNSUPPORTED: [ '57122881048805941', // e-hentai ], REVALIDATION_SKIP_TTL: [Sources.LOCAL_SOURCE_ID], }; // TODO - extract logic to reduce the size of this file... grew waaaaaaaaaaaaay too big peepoFat // TODO - correctly update cache after all mutations instead of refetching queries export class RequestManager { public static readonly API_VERSION = '/api/v1/'; public readonly graphQLClient = new GraphQLClient(this.refreshUser.bind(this)); private readonly restClient: RestClient = new RestClient(this.refreshUser.bind(this)); private readonly cache = new CustomCache(); private readonly imageQueue: SourceAwareQueue; constructor() { const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:'; const isHttp2 = isHttps && this.detectHttp2(); this.imageQueue = new SourceAwareQueue(!isHttp2, 5); BaseClient.setTokenRefreshCompleteCallback(() => { this.processQueues(); }); } private detectHttp2(): boolean { const entries = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[]; if (!entries.length) { return false; } return ['h2', 'h3'].includes(entries[0].nextHopProtocol); } public getClient(): IRestClient { return this.restClient; } public updateClient(config: RequestInit): void { this.restClient.updateConfig(config); this.graphQLClient.updateConfig(); } public reset(): void { AuthManager.setAuthRequired(null); AuthManager.setAuthInitialized(false); AuthManager.removeTokens(); this.graphQLClient.reset(); this.restClient.reset(); this.cache.clear(); this.imageQueue.clear(); } public processQueues(): void { this.graphQLClient.processQueue(); this.restClient.processQueue(); } public getBaseUrl(): string { return this.restClient.getBaseUrl(); } public useBaseUrl() { return useLocalStorage(BaseClient.BASE_URL_KEY, () => this.getBaseUrl()); } public getValidUrlFor(endpoint: string, apiVersion: string = RequestManager.API_VERSION): string { return `${this.getBaseUrl()}${apiVersion}${endpoint}`; } public getWebviewUrl(url: string): string { return `${this.getValidUrlFor('webview')}#${url}`; } public clearBrowseCacheFor(sourceId: string) { const cacheKeys = this.cache.getMatchingKeys( new RegExp(`${CACHE_INITIAL_PAGES_FETCHING_KEY}|${CACHE_PAGES_KEY}|${CACHE_RESULTS_KEY}.*${sourceId}`), ); this.cache.clearFor(...cacheKeys); } public clearExtensionCache() { this.cache.clearFor(this.cache.getKeyFor(EXTENSION_LIST_CACHE_KEY, undefined)); } private createAbortController(): { signal: AbortSignal } & AbortableRequest { const abortController = new AbortController(); const abortRequest = (reason?: any): void => { if (!abortController.signal.aborted) { abortController.abort(reason); } }; return { signal: abortController.signal, abortRequest }; } private createPaginatedResult( result: Partial | undefined | null, defaultPage: number, page?: number, ): Result { const isLoading = !result?.error && (result?.isLoading || !result?.called); const size = page ?? result?.size ?? defaultPage; return { client: this.graphQLClient.client, abortRequest: () => {}, reset: () => {}, called: false, data: undefined, error: undefined, size, isLoading, isLoadingMore: isLoading && size > 1, isValidating: !!result?.isValidating, ...result, } as Result; } private async revalidatePage( sourceId: string, cacheResultsKey: string, cachePagesKey: string, getVariablesFor: (page: number) => Variables, options: ApolloPaginatedMutationOptions | undefined, checkIfCachedPageIsInvalid: ( cachedResult: AbortableApolloUseMutationPaginatedResponse[1][number] | undefined, revalidatedResult: ApolloClient.MutateResult>, ) => boolean, hasNextPage: (revalidatedResult: ApolloClient.MutateResult>) => boolean, pageToRevalidate: number, maxPage: number, signal: AbortSignal, ): Promise { if (SPECIAL_ED_SOURCES.REVALIDATION_UNSUPPORTED.includes(sourceId)) { return; } const isFirstPage = pageToRevalidate === 1; const isTtlReached = Date.now() - (this.cache.getFetchTimestampFor(cacheResultsKey, getVariablesFor(pageToRevalidate)) ?? 0) >= d(5).minutes.inWholeMilliseconds; if (isFirstPage && !isTtlReached && !SPECIAL_ED_SOURCES.REVALIDATION_SKIP_TTL.includes(sourceId)) { return; } const { response: revalidationRequest } = this.doRequest( GQLMethod.MUTATION, GET_SOURCE_MANGAS_FETCH, getVariablesFor(pageToRevalidate), { ...options, context: { fetchOptions: { signal } }, } as MutationOptions, ); const revalidationResponse = await revalidationRequest; const cachedPageData = this.cache.getResponseFor< AbortableApolloUseMutationPaginatedResponse[1][number] >(cacheResultsKey, getVariablesFor(pageToRevalidate)); const isCachedPageInvalid = checkIfCachedPageIsInvalid(cachedPageData, revalidationResponse); this.cache.cacheResponse(cacheResultsKey, getVariablesFor(pageToRevalidate), { ...revalidationResponse, called: true, isLoading: false, size: pageToRevalidate, }); if (!hasNextPage(revalidationResponse)) { const currentCachedPages = this.cache.getResponseFor>(cachePagesKey, getVariablesFor(0))!; this.cache.cacheResponse( cachePagesKey, getVariablesFor(0), [...currentCachedPages].filter((cachedPage) => cachedPage <= pageToRevalidate), ); [...currentCachedPages] .filter((cachedPage) => cachedPage > pageToRevalidate) .forEach((cachedPage) => this.cache.cacheResponse(cacheResultsKey, getVariablesFor(cachedPage), undefined), ); return; } if (isCachedPageInvalid && pageToRevalidate < maxPage) { await this.revalidatePage( sourceId, cacheResultsKey, cachePagesKey, getVariablesFor, options, checkIfCachedPageIsInvalid, hasNextPage, pageToRevalidate + 1, maxPage, signal, ); } } private async revalidatePages( activeRevalidationRef: | [ForInput: Variables, Request: Promise, AbortRequest: AbortableRequest['abortRequest']] | null, setRevalidationDone: (isDone: boolean) => void, setActiveRevalidation: ( activeRevalidation: | [ForInput: Variables, Request: Promise, AbortRequest: AbortableRequest['abortRequest']] | null, ) => void, getVariablesFor: (page: number) => Variables, setValidating: (isValidating: boolean) => void, revalidatePage: (pageToRevalidate: number, maxPage: number, signal: AbortSignal) => Promise, maxPage: number, abortRequest: AbortableRequest['abortRequest'], signal: AbortSignal, ): Promise { setRevalidationDone(true); const [currRevVars, currRevPromise, currRevAbortRequest] = activeRevalidationRef ?? []; const isActiveRevalidationForInput = JSON.stringify(currRevVars) === JSON.stringify(getVariablesFor(0)); setValidating(true); if (!isActiveRevalidationForInput) { currRevAbortRequest?.(new Error('Abort revalidation for different input')); } let revalidationPromise = currRevPromise; if (!isActiveRevalidationForInput) { revalidationPromise = revalidatePage(1, maxPage, signal); setActiveRevalidation([getVariablesFor(0), revalidationPromise, abortRequest]); } try { await revalidationPromise; setActiveRevalidation(null); } catch (e) { defaultPromiseErrorHandler(`RequestManager..revalidatePages(${getVariablesFor(0)})`)(e); } finally { setValidating(false); } } private async fetchPaginatedMutationPage< Data = any, Variables extends OperationVariables = OperationVariables, ResultIdInfo extends Record = any, >( getVariablesFor: (page: number) => Variables, setAbortRequest: (abortRequest: AbortableRequest['abortRequest']) => void, getResultIdInfo: () => ResultIdInfo, createPaginatedResult: ( result: Partial[1][number]>, ) => AbortableApolloUseMutationPaginatedResponse[1][number], setResult: ( result: AbortableApolloUseMutationPaginatedResponse[1][number] & ResultIdInfo, ) => void, revalidate: ( maxPage: number, abortRequest: AbortableRequest['abortRequest'], signal: AbortSignal, ) => Promise, options: ApolloPaginatedMutationOptions | undefined, documentNode: DocumentNode, cachePagesKey: string, cacheResultsKey: string, cachedPages: Set, newPage: number, ): Promise>> { const basePaginatedResult: Partial[1][number]> = { size: newPage, isLoading: false, isLoadingMore: false, called: true, }; let response: ApolloClient.MutateResult> = { data: undefined }; try { const { signal, abortRequest } = this.createAbortController(); setAbortRequest(abortRequest); const isRefetch = newPage === [...cachedPages][cachedPages.size - 1]; if (isRefetch) { this.cache.cacheResponse( cachePagesKey, getVariablesFor(0), new Set([...cachedPages].slice(0, cachedPages.size - 1)), ); this.cache.clearFor(this.cache.getKeyFor(cacheResultsKey, getVariablesFor(newPage))); } setResult({ ...getResultIdInfo(), ...createPaginatedResult({ isLoading: true, abortRequest, size: newPage, called: true }), }); if (newPage !== 1 && cachedPages.size) { await revalidate(newPage, abortRequest, signal); } const { response: request } = this.doRequest( GQLMethod.MUTATION, documentNode, getVariablesFor(newPage), { ...options, context: { fetchOptions: { signal } } } as MutationOptions, ); response = await request; basePaginatedResult.data = response.data; } catch (error: any) { defaultPromiseErrorHandler('RequestManager::fetchPaginatedMutationPage')(error); basePaginatedResult.error = CombinedGraphQLErrors.is(error) ? error : new Error(error?.message ?? String(error)); } const fetchPaginatedResult = { ...getResultIdInfo(), ...createPaginatedResult(basePaginatedResult), }; setResult(fetchPaginatedResult); const currentCachedPages = this.cache.getResponseFor>(cachePagesKey, getVariablesFor(0)) ?? []; this.cache.cacheResponse(cachePagesKey, getVariablesFor(0), new Set([...currentCachedPages, newPage])); this.cache.cacheResponse(cacheResultsKey, getVariablesFor(newPage), fetchPaginatedResult); return response; } private fetchInitialPages( options: ApolloPaginatedMutationOptions | undefined, areFetchingInitialPages: boolean, areInitialPagesFetched: boolean, setRevalidationDone: (isDone: boolean) => void, cacheFetchingInitialPagesKey: string, getVariablesFor: (page: number) => Variables, initialPages: number, fetchPage: (page: number) => Promise>, hasNextPage: (result: ApolloClient.MutateResult) => boolean, ): void { useEffect(() => { const shouldFetchInitialPages = !options?.skipRequest && !areFetchingInitialPages && !areInitialPagesFetched; if (!shouldFetchInitialPages) { return; } setRevalidationDone(true); this.cache.cacheResponse(cacheFetchingInitialPagesKey, getVariablesFor(0), true); const loadInitialPages = async (initialPage: number) => { const areAllPagesFetched = initialPage > initialPages; if (areAllPagesFetched) { return; } const pageResult = await fetchPage(initialPage); if (hasNextPage(pageResult)) { await loadInitialPages(initialPage + 1); } }; loadInitialPages(1).finally(() => this.cache.cacheResponse(cacheFetchingInitialPagesKey, getVariablesFor(0), false), ); }, [!options?.skipRequest, !areFetchingInitialPages, !areInitialPagesFetched]); } private returnPaginatedMutationResult( areInitialPagesFetched: boolean, cachedResults: AbortableApolloUseMutationPaginatedResponse[1][number][], getVariablesFor: (page: number) => Variables, paginatedResult: AbortableApolloUseMutationPaginatedResponse[1][number], fetchPage: (page: number) => Promise>>, hasCachedResult: boolean, createPaginatedResult: ( result: Partial[1][number]>, ) => AbortableApolloUseMutationPaginatedResponse[1][number], ): AbortableApolloUseMutationPaginatedResponse { const doCachedResultsExist = areInitialPagesFetched && cachedResults.length; if (!doCachedResultsExist) { return [fetchPage, [paginatedResult]]; } const areAllPagesCached = doCachedResultsExist && hasCachedResult; if (!areAllPagesCached) { return [fetchPage, [...cachedResults, paginatedResult]]; } return [ fetchPage, [ ...cachedResults.slice(0, cachedResults.length - 1), createPaginatedResult({ ...cachedResults[cachedResults.length - 1], isValidating: paginatedResult.isValidating, }), ], ]; } private revalidateInitialPages( isRevalidationDone: boolean, cachedResultsLength: number, cachedPages: Set, setRevalidationDone: (isDone: boolean) => void, getVariablesFor: (page: number) => Variables, triggerRerender: () => void, revalidate: ( maxPage: number, abortRequest: AbortableRequest['abortRequest'], signal: AbortSignal, ) => Promise, ): void { const isMountedRef = useRef(false); useEffect(() => { const isRevalidationRequired = isMountedRef.current && cachedResultsLength; if (!isRevalidationRequired) { return; } setRevalidationDone(false); triggerRerender(); }, [JSON.stringify(getVariablesFor(0))]); useEffect(() => { const shouldRevalidateData = isMountedRef.current && !isRevalidationDone && cachedResultsLength; if (shouldRevalidateData) { setRevalidationDone(true); const { signal, abortRequest } = this.createAbortController(); revalidate(Math.max(...cachedPages), abortRequest, signal); } }, [isMountedRef.current, isRevalidationDone]); useEffect(() => { isMountedRef.current = true; }, []); } public getValidImgUrlFor(imageUrl: string, apiVersion: string = ''): string { // server provided image urls already contain the api version return `${this.getValidUrlFor(imageUrl, apiVersion)}`; } /** * Aborts pending image requests * * prevents aborting image requests that are already in progress * e.g. for source image requests, ongoing requests are already handled by the server and aborting them * will just cause new source image requests to be sent to the server, which then will cause the server * to become really slow for image requests to the same source */ private abortImageRequest(key: string, sourceId: string | null, abort: () => void): void { if (this.imageQueue.isProcessing(sourceId, key)) { return; } abort(); } private async optionallyDecodeImage(url: string, shouldDecode?: boolean, disableCors?: boolean): Promise { if (!shouldDecode) { return url; } const decodePromise = new ControlledPromise(); const img = new Image(); if (!disableCors) { img.crossOrigin = 'anonymous'; } img.src = url; img.onload = async () => { try { await img.decode(); } catch (error) { decodePromise.reject(error); } decodePromise.resolve(); }; img.onerror = (error) => decodePromise.reject(error); img.onabort = (error) => decodePromise.reject(error); await decodePromise.promise; return url; } private getSourceIdFromUrl(url: string): string | null { try { return new URL(url).searchParams.get('sourceId'); } catch { return null; } } private async maybeEnqueueImageRequest( url: string, request: () => Promise, priority?: QueuePriority, ignoreQueue?: boolean, ): Promise> & { fromCache?: boolean }> { const sourceId = this.getSourceIdFromUrl(url); try { const isCached = await ImageCache.has(url); if (!!ignoreQueue || isCached) { return { key: `image-cache-${url}`, promise: request(), fromCache: isCached, }; } return this.imageQueue.enqueue(sourceId, url, request, priority); } catch (error) { return this.imageQueue.enqueue(sourceId, url, request, priority); } } private async fetchImageViaTag( url: string, { priority, shouldDecode, disableCors, ignoreQueue }: ImageRequestOptions = {}, ): Promise { const imgRequest = new ControlledPromise(); imgRequest.promise.catch(() => {}); const img = new Image(); const abortRequest = (reason?: any) => { img.src = ''; img.onload = null; img.onerror = null; img.onabort = null; imgRequest.reject(reason); }; const { key, promise: response, fromCache, } = await this.maybeEnqueueImageRequest( url, async () => { // throws error in case request was already aborted await Promise.race([imgRequest.promise, Promise.resolve()]); if (!disableCors) { img.crossOrigin = 'anonymous'; } img.src = url; img.onload = async () => { try { await this.optionallyDecodeImage(url, shouldDecode); imgRequest.resolve(url); } catch (error) { imgRequest.reject(error); } }; img.onerror = (error) => imgRequest.reject(error); img.onabort = (error) => imgRequest.reject(error); return imgRequest.promise; }, priority, ignoreQueue, ); return { response, abortRequest: (reason?: any) => this.abortImageRequest(key, this.getSourceIdFromUrl(url), () => abortRequest(reason)), cleanup: () => {}, fromCache: !!fromCache, }; } /** * After the image has been handled, {@see URL#revokeObjectURL} has to be called. * * @example * * const imageRequest = requestManager.requestImage("someUrl"); * const imageUrl = await imageRequest.response * * const img = new Image(); * img.onLoad = () => imageRequest.cleanup(); * img.src = imageUrl; * */ private async fetchImageViaFetchApi( url: string, { priority, shouldDecode, disableCors, ignoreQueue }: ImageRequestOptions = {}, ): Promise { let objectUrl: string = ''; const { abortRequest, signal } = this.createAbortController(); const { key, promise: response, fromCache, } = await this.maybeEnqueueImageRequest( url, () => this.restClient .fetcher(url, { checkResponseIsJson: false, config: { signal, priority: 'low', }, }) .then((data) => data.blob()) .then((data) => URL.createObjectURL(data)) .then(async (imageUrl) => { objectUrl = imageUrl; await this.optionallyDecodeImage(imageUrl, shouldDecode, disableCors); return imageUrl; }), priority, ignoreQueue, ); return { response, abortRequest: (reason?: any) => this.abortImageRequest(key, this.getSourceIdFromUrl(url), () => abortRequest(reason)), cleanup: () => URL.revokeObjectURL(objectUrl), fromCache: !!fromCache, }; } /** * Make sure to call "cleanup" once the image is not needed anymore (only required if fetched via "fetch api") * * options: * - shouldDecode: decodes the image in case the browser is Firefox to prevent a flickering/blinking when an image gets visible for the first time */ public async requestImage( url: string, options: ImageRequestOptions & { useFetchApi?: boolean } = {}, ): Promise { const finalOptions = { useFetchApi: AuthManager.isAuthRequired(), shouldDecode: false, disableCors: false, ignoreQueue: false, ...Object.fromEntries(Object.entries(options).filter(([, value]) => value !== undefined)), }; // on firefox images are decoded async which causes a "flicker/blinking" when they're getting visible for the first time // this is an issue especially in the reader because pages that should not be shown are rendered but // not displayed, which then causes this issue once they get displayed const shouldDecode = finalOptions.shouldDecode && navigator.userAgent.toLowerCase().includes('firefox'); if (finalOptions.useFetchApi) { return this.fetchImageViaFetchApi(url, { ...finalOptions, shouldDecode }); } return this.fetchImageViaTag(url, { ...finalOptions, shouldDecode }); } private doRequest( method: GQLMethod.QUERY, operation: DocumentNode | TypedDocumentNode, variables: Variables | undefined, options?: QueryOptions, ): AbortabaleApolloQueryResponse; private doRequest( method: GQLMethod.USE_QUERY, operation: DocumentNode | TypedDocumentNode, variables: Variables | undefined, options?: QueryHookOptions, ): AbortableApolloUseQueryResponse; private doRequest( method: GQLMethod.USE_MUTATION, operation: DocumentNode | TypedDocumentNode, variables: Variables | undefined, options?: MutationHookOptions, ): AbortableApolloUseMutationResponse; private doRequest( method: GQLMethod.MUTATION, operation: DocumentNode | TypedDocumentNode, variables: Variables | undefined, options?: MutationOptions, ): AbortableApolloMutationResponse; private doRequest( method: GQLMethod.USE_SUBSCRIPTION, operation: DocumentNode | TypedDocumentNode, variables: Variables | undefined, options?: SubscriptionHookOptions, ): useSubscription.Result; private doRequest( method: GQLMethod, operation: DocumentNode | TypedDocumentNode, variables: Variables | undefined, options?: | QueryOptions | QueryHookOptions | MutationHookOptions | MutationOptions | SubscriptionHookOptions, ): | AbortabaleApolloQueryResponse | AbortableApolloUseQueryResponse | AbortableApolloUseMutationResponse | AbortableApolloMutationResponse | useSubscription.Result { const { signal, abortRequest } = this.createAbortController(); switch (method) { case GQLMethod.QUERY: { const { addAbortSignal: addSignal, ...queryOptions } = (options ?? {}) as QueryOptions; return { response: this.graphQLClient.client.query({ query: operation, variables, ...queryOptions, context: { ...queryOptions?.context, fetchOptions: { signal: addSignal ? signal : undefined, ...queryOptions?.context?.fetchOptions, }, }, } as ApolloClient.QueryOptions), abortRequest, }; } case GQLMethod.USE_QUERY: { const { addAbortSignal: addSignal, ...queryHookOptions } = (options ?? {}) as QueryHookOptions< Data, Variables >; return { ...useQuery(operation, { variables, client: this.graphQLClient.client, ...queryHookOptions, context: { ...queryHookOptions?.context, fetchOptions: { signal: addSignal ? signal : undefined, ...queryHookOptions?.context?.fetchOptions, }, }, } as useQuery.Options), abortRequest, } as AbortableApolloUseQueryResponse; } case GQLMethod.USE_MUTATION: { const { addAbortSignal: addSignal, ...mutationHookOptions } = (options ?? {}) as MutationHookOptions< Data, Variables >; const mutationResult = useMutation(operation, { variables, client: this.graphQLClient.client, ...mutationHookOptions, context: { ...mutationHookOptions?.context, fetchOptions: { signal: addSignal ? signal : undefined, ...mutationHookOptions?.context?.fetchOptions, }, }, }); return [mutationResult[0], { ...mutationResult[1], abortRequest }]; } case GQLMethod.MUTATION: { const { addAbortSignal: addSignal, ...mutationOptions } = (options ?? {}) as MutationOptions< Data, Variables >; return { response: this.graphQLClient.client.mutate({ mutation: operation, variables, ...mutationOptions, context: { ...mutationOptions?.context, fetchOptions: { signal: addSignal ? signal : undefined, ...mutationOptions?.context?.fetchOptions, }, }, } as ApolloClient.MutateOptions), abortRequest, }; } case GQLMethod.USE_SUBSCRIPTION: { const subscription = useSubscription(operation, { client: this.graphQLClient.client, variables, ...(options as SubscriptionHookOptions), } as useSubscription.Options); this.graphQLClient.useRestartSubscription(subscription.restart); return subscription; } default: throw new Error(`unexpected GQLRequest type "${method}"`); } } public getGlobalMeta( options?: QueryOptions, ): AbortabaleApolloQueryResponse { return this.doRequest(GQLMethod.QUERY, GET_GLOBAL_METADATAS, {}, options); } public useGetGlobalMeta( options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, GET_GLOBAL_METADATAS, {}, options); } public updateGlobalMeta( { preUpdateDeleteInput = { keys: [] }, updateInput = { metas: [] }, postUpdateDeleteInput = { keys: [] }, migrateInput = { metas: [] }, }: { preUpdateDeleteInput?: DeleteGlobalMetasInput; updateInput?: SetGlobalMetasInput; postUpdateDeleteInput?: DeleteGlobalMetasInput; migrateInput?: SetGlobalMetasInput; }, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, UPDATE_GLOBAL_METADATA, { preUpdateDeleteInput, hasPreUpdateDeletions: !!preUpdateDeleteInput.keys?.length || !!preUpdateDeleteInput.prefixes?.length, updateInput, hasUpdates: !!updateInput.metas.length, postUpdateDeleteInput, hasPostUpdateDeletions: !!postUpdateDeleteInput.keys?.length || !!postUpdateDeleteInput.prefixes?.length, migrateInput, isMigration: !!migrateInput.metas.length, }, { optimisticResponse: { __typename: 'Mutation', preUpdateDeletedMeta: { __typename: 'DeleteGlobalMetasPayload', metas: (preUpdateDeleteInput?.keys ?? []).map((key) => ({ __typename: 'GlobalMetaType' as const, key, value: '', })), }, updatedMeta: { __typename: 'SetGlobalMetasPayload', metas: (updateInput?.metas ?? []).map((meta) => ({ __typename: 'GlobalMetaType', key: meta.key, value: meta.value, })), }, postUpdateDeletedMeta: { __typename: 'DeleteGlobalMetasPayload', metas: (postUpdateDeleteInput?.keys ?? []).map((key) => ({ __typename: 'GlobalMetaType' as const, key, value: '', })), }, migrationMeta: { __typename: 'SetGlobalMetasPayload', metas: (migrateInput?.metas ?? []).map((meta) => ({ __typename: 'GlobalMetaType', key: meta.key, value: meta.value, })), }, }, update(cache, { data }) { preUpdateDeleteInput?.keys?.forEach((key) => { cache.evict({ id: cache.identify({ __typename: 'GlobalMetaType', key }) }); }); postUpdateDeleteInput?.keys?.forEach((key) => { cache.evict({ id: cache.identify({ __typename: 'GlobalMetaType', key }) }); }); cache.modify({ fields: { metas(existingMetas, { readField }) { if (!existingMetas) { return existingMetas; } if (!data?.updatedMeta && !data?.migrationMeta) { return existingMetas; } const updatedMeta = [ ...(data?.updatedMeta?.metas ?? []), ...(data?.migrationMeta?.metas ?? []), ]; const newMetas = updatedMeta.filter((meta) => existingMetas.nodes.every( (existingMeta: Reference) => readField('key', existingMeta) !== meta.key, ), ); const newMetaRefs = newMetas.map((meta) => cache.writeFragment({ data: meta, fragment: GLOBAL_METADATA, }), ); return { ...existingMetas, nodes: [...existingMetas.nodes, ...newMetaRefs], }; }, }, }); }, ...options, }, ); } public useGetAbout( options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, GET_ABOUT, {}, options); } public useCheckForServerUpdate( options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, CHECK_FOR_SERVER_UPDATES, {}, options); } public useCheckForWebUIUpdate( options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, CHECK_FOR_WEBUI_UPDATE, {}, options); } public updateWebUI( options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest(GQLMethod.MUTATION, UPDATE_WEBUI, undefined, options); } public useGetExtensionStore( indexUrl: string, options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, GET_EXTENSION_STORE, { indexUrl }, options); } public useGetExtensionStores( options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, GET_EXTENSION_STORES, undefined, options); } public useAddExtensionStore( options?: MutationHookOptions, ): AbortableApolloUseMutationResponse { return this.doRequest(GQLMethod.USE_MUTATION, ADD_EXTENSION_STORE, undefined, { refetchQueries: [GET_EXTENSION_STORES], ...options, }); } public useRemoveExtensionStore( options?: MutationHookOptions, ): AbortableApolloUseMutationResponse { const [mutate, request] = this.doRequest(GQLMethod.USE_MUTATION, REMOVE_EXTENSION_STORE, undefined, { refetchQueries: [GET_EXTENSION_STORES], ...options, }); const wrappedMutate = (mutateOptions: Parameters[0]) => { const variables = mutateOptions?.variables; assertIsDefined(variables); const { indexUrl } = variables.input; return mutate({ update: (cache) => { cache.updateQuery( { query: GET_EXTENSION_STORES }, (data) => ({ ...data!, extensionStores: { ...data!.extensionStores, nodes: data!.extensionStores.nodes.filter((store) => store.indexUrl !== indexUrl), }, }), ); }, optimisticResponse: { __typename: 'Mutation', removeExtensionStore: { __typename: 'RemoveExtensionStorePayload', extensionStore: { __typename: 'ExtensionStoreType', indexUrl, signingKey: '', name: 'store', isLegacy: false, badgeLabel: '', contactWebsite: '', contactDiscord: null, extensionListUrl: null, extensions: { __typename: 'ExtensionNodeList', totalCount: 0 }, }, }, }, ...mutateOptions, }); }; return [wrappedMutate, request]; } public useGetExtension( pkgName: string, options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, GET_EXTENSION, { pkgName }, options); } public useGetExtensionList( options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, GET_EXTENSIONS, {}, options); } public useExtensionListFetch( options?: MutationHookOptions, ): AbortableApolloUseMutationResponse { const [mutate, result] = this.doRequest( GQLMethod.USE_MUTATION, GET_EXTENSIONS_FETCH, {}, { ...options, refetchQueries: [GET_EXTENSIONS], update(cache, { data: mutationData }) { if (!mutationData?.fetchExtensions?.extensions) { return; } cache.updateQuery( { query: GET_EXTENSIONS }, () => ({ __typename: 'Query', extensions: { __typename: 'ExtensionNodeList', nodes: mutationData?.fetchExtensions?.extensions ?? [], pageInfo: { __typename: 'PageInfo', hasNextPage: false, hasPreviousPage: false, startCursor: null, endCursor: null, }, totalCount: mutationData?.fetchExtensions?.extensions.length ?? 0, }, }), ); }, }, ); const [, setUpdatedCache] = useState({}); useEffect(() => { if (result.loading) { return; } if (!result.data?.fetchExtensions?.extensions) { return; } this.cache.cacheResponse(EXTENSION_LIST_CACHE_KEY, undefined, result); setUpdatedCache({}); }, [result.loading]); const getCachedResult = () => this.cache.getResponseFor( EXTENSION_LIST_CACHE_KEY, undefined, d(1).minutes.inWholeMilliseconds, ); const cachedResult = getCachedResult(); const normalizedCachedResult = useMemo( () => !cachedResult ? result : { ...cachedResult, data: !cachedResult?.data?.fetchExtensions?.extensions ? cachedResult?.data : { ...cachedResult.data, fetchExtensions: { ...cachedResult.data.fetchExtensions, extensions: cachedResult.data.fetchExtensions.extensions.map( (extension) => this.graphQLClient.client.cache.readFragment< NonNullable< GetExtensionsFetchMutation['fetchExtensions'] >['extensions'][0] >({ id: this.graphQLClient.client.cache.identify(extension), fragment: EXTENSION_LIST_FIELDS, }) ?? extension, ), extensionStores: cachedResult.data.fetchExtensions.extensionStores.map( (store) => this.graphQLClient.client.cache.readFragment< NonNullable< GetExtensionsFetchMutation['fetchExtensions'] >['extensionStores'][0] >({ id: this.graphQLClient.client.cache.identify(store), fragment: EXTENSION_STORE_FIELDS, }), ), }, }, }, [this.cache.getFetchTimestampFor(EXTENSION_LIST_CACHE_KEY, undefined), result.loading], ); const wrappedMutate = async (mutateOptions: Parameters[0]) => { if (getCachedResult()) { return normalizedCachedResult; } return mutate(mutateOptions); }; return [wrappedMutate, normalizedCachedResult] as AbortableApolloUseMutationResponse< GetExtensionsFetchMutation, GetExtensionsFetchMutationVariables >; } public installExternalExtension( extensionFile: File, options?: MutationOptions, ): AbortableApolloMutationResponse { const result = this.doRequest( GQLMethod.MUTATION, INSTALL_EXTERNAL_EXTENSION, { file: extensionFile }, { refetchQueries: [GET_EXTENSIONS], ...options }, ); result.response.then((response) => { if (!response.data?.installExternalExtension?.extension) { return; } this.graphQLClient.client.cache.evict({ fieldName: 'sources' }); const cachedExtensions = this.cache.getResponseFor>( EXTENSION_LIST_CACHE_KEY, undefined, ); const installedExtension = response.data?.installExternalExtension.extension; if (!cachedExtensions || !cachedExtensions.data) { this.cache.cacheResponse(EXTENSION_LIST_CACHE_KEY, undefined, { data: { fetchExtensions: { extensions: [installedExtension], }, }, }); return; } const isExtensionCached = !!cachedExtensions.data.fetchExtensions?.extensions.find( (extension) => installedExtension?.pkgName === extension.pkgName, ); const updatedCachedExtensions: useMutation.Result = { ...cachedExtensions, data: { ...cachedExtensions.data, fetchExtensions: { __typename: 'FetchExtensionsPayload', ...cachedExtensions.data.fetchExtensions, extensions: isExtensionCached ? cachedExtensions.data.fetchExtensions!.extensions.map((extension) => { const isUpdatedExtension = installedExtension?.pkgName === extension.pkgName; if (!isUpdatedExtension) { return extension; } return { ...extension, ...installedExtension, hasUpdate: Number(installedExtension?.versionCodeLong ?? -1) < Number(extension.versionCodeLong), }; }) : [ ...(cachedExtensions.data.fetchExtensions?.extensions ?? []), installedExtension as NonNullable< GetExtensionsFetchMutation['fetchExtensions'] >['extensions'][number], ], extensionStores: cachedExtensions.data?.fetchExtensions!.extensionStores, }, }, }; this.cache.cacheResponse(EXTENSION_LIST_CACHE_KEY, undefined, updatedCachedExtensions); }); return result; } public updateExtension( id: string, { isObsolete = false, ...patch }: UpdateExtensionPatchInput & { isObsolete?: boolean }, options?: MutationOptions, ): AbortableApolloMutationResponse { const result = this.doRequest( GQLMethod.MUTATION, UPDATE_EXTENSION, { input: { id, patch } }, options, ); result.response.then((response) => { if (response.error) { return; } this.graphQLClient.client.cache.evict({ fieldName: 'sources' }); const cachedExtensions = this.cache.getResponseFor>( EXTENSION_LIST_CACHE_KEY, undefined, ); if (!cachedExtensions || !cachedExtensions.data) { return; } const updatedCachedExtensions: useMutation.Result = { ...cachedExtensions, data: { ...cachedExtensions.data, fetchExtensions: { __typename: 'FetchExtensionsPayload', ...cachedExtensions.data.fetchExtensions, extensions: cachedExtensions.data.fetchExtensions?.extensions .filter((extension) => { if (!isObsolete) { return true; } const isUpdatedExtension = id === extension.pkgName; return !isUpdatedExtension; }) .map((extension) => { const isUpdatedExtension = id === extension.pkgName; if (!isUpdatedExtension) { return extension; } return { ...extension, ...(response.data?.updateExtension?.extension ?? []), }; }) ?? [], extensionStores: cachedExtensions.data.fetchExtensions?.extensionStores ?? [], }, }, }; this.cache.cacheResponse(EXTENSION_LIST_CACHE_KEY, undefined, updatedCachedExtensions); }); return result; } public updateExtensions( ids: string[], { isObsolete = false, ...patch }: UpdateExtensionPatchInput & { isObsolete?: boolean }, options?: MutationOptions, ): AbortableApolloMutationResponse { const result = this.doRequest( GQLMethod.MUTATION, UPDATE_EXTENSIONS, { input: { ids, patch } }, options, ); result.response.then((response) => { if (response.error) { return; } this.graphQLClient.client.cache.evict({ fieldName: 'sources' }); const cachedExtensions = this.cache.getResponseFor>( EXTENSION_LIST_CACHE_KEY, undefined, ); if (!cachedExtensions || !cachedExtensions.data) { return; } const updatedCachedExtensions: useMutation.Result = { ...cachedExtensions, data: { ...cachedExtensions.data, fetchExtensions: { __typename: 'FetchExtensionsPayload', ...cachedExtensions.data.fetchExtensions, extensions: cachedExtensions.data.fetchExtensions?.extensions .filter((extension) => { if (!isObsolete) { return true; } const isUpdatedExtension = ids.includes(extension.pkgName); return !isUpdatedExtension; }) .map((extension) => { const isUpdatedExtension = ids.includes(extension.pkgName); if (!isUpdatedExtension) { return extension; } return { ...extension, ...(response.data?.updateExtensions?.extensions.find( (updatedExtension) => updatedExtension.pkgName === extension.pkgName, ) ?? []), }; }) ?? [], extensionStores: cachedExtensions.data.fetchExtensions?.extensionStores ?? [], }, }, }; this.cache.cacheResponse(EXTENSION_LIST_CACHE_KEY, undefined, updatedCachedExtensions); }); return result; } public getExtensionIconUrl(extension: string): string { return this.getValidImgUrlFor(`extension/icon/${extension}`); } public useGetSourceList( options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, GET_SOURCES_LIST, {}, options); } public useGetSource( document: DocumentNode | TypedDocumentNode, id: string, options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, document, { id } as unknown as Variables, options); } public updateSourceMeta( { preUpdateDeleteInput = { items: [] }, updateInput = { items: [] }, postUpdateDeleteInput = { items: [] }, migrateInput = { items: [] }, }: { preUpdateDeleteInput?: DeleteSourceMetasInput; updateInput?: SetSourceMetasInput; postUpdateDeleteInput?: DeleteSourceMetasInput; migrateInput?: SetSourceMetasInput; }, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, UPDATE_SOURCE_METADATA, { preUpdateDeleteInput, hasPreUpdateDeletions: preUpdateDeleteInput.items.some( (item) => !!item.keys?.length || !!item.prefixes?.length, ), updateInput, hasUpdates: updateInput.items.some((item) => !!item.metas?.length), postUpdateDeleteInput, hasPostUpdateDeletions: postUpdateDeleteInput.items.some( (item) => !!item.keys?.length || !!item.prefixes?.length, ), migrateInput, isMigration: migrateInput.items.some((item) => !!item.metas?.length), }, { optimisticResponse: { __typename: 'Mutation', preUpdateDeletedMeta: { __typename: 'DeleteSourceMetasPayload', metas: (preUpdateDeleteInput?.items ?? []).flatMap((item) => item.sourceIds.flatMap((sourceId) => (item.keys ?? []).map((key) => ({ __typename: 'SourceMetaType' as const, sourceId, key, value: '', })), ), ), }, updatedMeta: { __typename: 'SetSourceMetasPayload', metas: (updateInput?.items ?? []).flatMap((item) => item.sourceIds.flatMap((sourceId) => item.metas.map((meta) => ({ __typename: 'SourceMetaType' as const, sourceId, key: meta.key, value: meta.value, })), ), ), }, postUpdateDeletedMeta: { __typename: 'DeleteSourceMetasPayload', metas: (postUpdateDeleteInput?.items ?? []).flatMap((item) => item.sourceIds.flatMap((sourceId) => (item.keys ?? []).map((key) => ({ __typename: 'SourceMetaType' as const, sourceId, key, value: '', })), ), ), }, migrationMeta: { __typename: 'SetSourceMetasPayload', metas: (migrateInput?.items ?? []).flatMap((item) => item.sourceIds.flatMap((sourceId) => item.metas.map((meta) => ({ __typename: 'SourceMetaType' as const, sourceId, key: meta.key, value: meta.value, })), ), ), }, }, update(cache, { data }) { preUpdateDeleteInput?.items.forEach((item) => item.sourceIds.forEach((sourceId) => item.keys?.forEach((key) => { cache.evict({ id: cache.identify({ __typename: 'SourceMetaType', sourceId, key }), }); }), ), ); postUpdateDeleteInput?.items.forEach((item) => item.sourceIds.forEach((sourceId) => item.keys?.forEach((key) => { cache.evict({ id: cache.identify({ __typename: 'SourceMetaType', sourceId, key }), }); }), ), ); if (!data?.updatedMeta && !data?.migrationMeta) { return; } const sourceIds = [ ...updateInput.items.flatMap((item) => item.sourceIds), ...migrateInput.items.flatMap((item) => item.sourceIds), ]; sourceIds.forEach((sourceId) => { cache.modify({ id: cache.identify({ __typename: 'SourceType', id: sourceId }), fields: { meta(existingMetas, { readField }) { const updatedMeta = [ ...(data?.updatedMeta?.metas.filter((meta) => meta.sourceId === sourceId) ?? []), ...(data?.migrationMeta?.metas.filter((meta) => meta.sourceId === sourceId) ?? []), ]; return updateMetadataList(updatedMeta, existingMetas, readField, (meta) => cache.writeFragment({ data: meta, fragment: SOURCE_META_FIELDS, }), ); }, }, }); }); }, ...options, }, ); } public useGetSourceMangas( input: FetchSourceMangaInput, initialPages: number = 1, options?: ApolloPaginatedMutationOptions, ): AbortableApolloUseMutationPaginatedResponse< GetSourceMangasFetchMutation, GetSourceMangasFetchMutationVariables > { type MutationResult = AbortableApolloUseMutationPaginatedResponse< GetSourceMangasFetchMutation, GetSourceMangasFetchMutationVariables >[1]; type MutationDataResult = MutationResult[number]; const createPaginatedResult = ( result?: Partial | null, page?: number, ) => this.createPaginatedResult(result, input.page, page); const getVariablesFor = (page: number): GetSourceMangasFetchMutationVariables => ({ input: { ...input, page, }, }); const isRevalidationDoneRef = useRef(false); const activeRevalidationRef = useRef< | [ ForInput: GetSourceMangasFetchMutationVariables, Request: Promise, AbortRequest: AbortableRequest['abortRequest'], ] | null >(null); const abortRequestRef = useRef(() => {}); const resultRef = useRef<(MutationDataResult & { forInput: string }) | null>(null); const result = resultRef.current; const [, setTriggerRerender] = useState(0); const triggerRerender = () => setTriggerRerender((prev) => prev + 1); const setResult = (nextResult: typeof resultRef.current) => { resultRef.current = nextResult; triggerRerender(); }; const cachedPages = this.cache.getResponseFor>(CACHE_PAGES_KEY, getVariablesFor(0)) ?? new Set(); const cachedResults = [...cachedPages] .map( (cachedPage) => this.cache.getResponseFor(CACHE_RESULTS_KEY, getVariablesFor(cachedPage))!, ) .sort((a, b) => a.size - b.size); const areFetchingInitialPages = !!this.cache.getResponseFor( CACHE_INITIAL_PAGES_FETCHING_KEY, getVariablesFor(0), ); const areInitialPagesFetched = cachedResults.length >= initialPages || (!!cachedResults.length && !cachedResults[cachedResults.length - 1].data?.fetchSourceManga?.hasNextPage); const isResultForCurrentInput = result?.forInput === JSON.stringify(getVariablesFor(0)); const lastPage = cachedPages.size ? Math.max(...cachedPages) : input.page; const nextPage = isResultForCurrentInput ? result.size : lastPage; const paginatedResult = isResultForCurrentInput && areInitialPagesFetched ? result : createPaginatedResult(undefined, nextPage); paginatedResult.abortRequest = abortRequestRef.current; // make sure that the result is always for the current input resultRef.current = { forInput: JSON.stringify(getVariablesFor(0)), ...paginatedResult }; const hasCachedResult = !!this.cache.getResponseFor(CACHE_RESULTS_KEY, getVariablesFor(nextPage)); const revalidatePage = async (pageToRevalidate: number, maxPage: number, signal: AbortSignal) => this.revalidatePage( input.source, CACHE_RESULTS_KEY, CACHE_PAGES_KEY, getVariablesFor, options, (cachedResult, revalidatedResult) => !cachedResult || !cachedResult.data?.fetchSourceManga?.mangas.length || cachedResult.data.fetchSourceManga.mangas.some( (manga, index) => manga.id !== revalidatedResult.data?.fetchSourceManga?.mangas[index]?.id, ), (revalidatedResult) => !!revalidatedResult.data?.fetchSourceManga?.hasNextPage, pageToRevalidate, maxPage, signal, ); const revalidate = async ( maxPage: number, abortRequest: AbortableRequest['abortRequest'], signal: AbortSignal, ) => this.revalidatePages( activeRevalidationRef.current, (isDone) => { isRevalidationDoneRef.current = isDone; }, (activeRevalidation) => { activeRevalidationRef.current = activeRevalidation; }, getVariablesFor, (isValidating) => { setResult({ ...createPaginatedResult(resultRef.current), isValidating, forInput: JSON.stringify(getVariablesFor(0)), }); }, revalidatePage, maxPage, abortRequest, signal, ); // wrap "mutate" function to align with the expected type, which allows only passing a "page" argument const wrappedMutate = async (newPage: number) => this.fetchPaginatedMutationPage( getVariablesFor, (abortRequest) => { abortRequestRef.current = abortRequest; }, () => ({ forType: input.type, forQuery: input.query }), createPaginatedResult, setResult, revalidate, options, GET_SOURCE_MANGAS_FETCH, CACHE_PAGES_KEY, CACHE_RESULTS_KEY, cachedPages, newPage, ); this.fetchInitialPages( options, areFetchingInitialPages, areInitialPagesFetched, (isDone) => { isRevalidationDoneRef.current = isDone; }, CACHE_INITIAL_PAGES_FETCHING_KEY, getVariablesFor, initialPages, wrappedMutate, (fetchedResult) => !!fetchedResult.data?.fetchSourceManga?.hasNextPage, ); this.revalidateInitialPages( isRevalidationDoneRef.current, cachedResults.length, cachedPages, (isDone) => { isRevalidationDoneRef.current = isDone; }, getVariablesFor, triggerRerender, revalidate, ); const normalizedCachedResults = cachedResults.map((cachedResult) => { const hasResults = !!cachedResult.data?.fetchSourceManga?.mangas; if (!hasResults) { return cachedResult; } return { ...cachedResult, data: { ...cachedResult.data, fetchSourceManga: { ...cachedResult.data?.fetchSourceManga, mangas: cachedResult.data?.fetchSourceManga?.mangas.map( (manga) => this.graphQLClient.client.cache.readFragment({ id: this.graphQLClient.client.cache.identify(manga), fragment: MANGA_BASE_FIELDS, fragmentName: 'MANGA_BASE_FIELDS', }) ?? manga, ), }, }, }; }); return this.returnPaginatedMutationResult( areInitialPagesFetched, normalizedCachedResults, getVariablesFor, paginatedResult, wrappedMutate, hasCachedResult, createPaginatedResult, ); } public useGetSourcePopularMangas( sourceId: string, initialPages?: number, options?: ApolloPaginatedMutationOptions, ): AbortableApolloUseMutationPaginatedResponse< GetSourceMangasFetchMutation, GetSourceMangasFetchMutationVariables > { return this.useGetSourceMangas( { type: FetchSourceMangaType.Popular, source: sourceId, page: 1 }, initialPages, options, ); } public useGetSourceLatestMangas( sourceId: string, initialPages?: number, options?: ApolloPaginatedMutationOptions, ): AbortableApolloUseMutationPaginatedResponse< GetSourceMangasFetchMutation, GetSourceMangasFetchMutationVariables > { return this.useGetSourceMangas( { type: FetchSourceMangaType.Latest, source: sourceId, page: 1 }, initialPages, options, ); } public setSourcePreferences( source: string, change: SourcePreferenceChangeInput, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest(GQLMethod.MUTATION, UPDATE_SOURCE_PREFERENCES, { input: { source, change } }, options); } public useSourceSearch( source: string, query?: string, filters?: FilterChangeInput[], initialPages?: number, options?: ApolloPaginatedMutationOptions, ): AbortableApolloUseMutationPaginatedResponse< GetSourceMangasFetchMutation, GetSourceMangasFetchMutationVariables > { return this.useGetSourceMangas( { type: FetchSourceMangaType.Search, source, query, filters, page: 1 }, initialPages, options, ); } public useGetManga( document: DocumentNode | TypedDocumentNode, mangaId: number | string, options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, document, { id: Number(mangaId) } as unknown as Variables, options); } public getManga( document: DocumentNode | TypedDocumentNode, mangaId: number | string, options?: QueryOptions, ): AbortabaleApolloQueryResponse { return this.doRequest(GQLMethod.QUERY, document, { id: Number(mangaId) } as unknown as Variables, options); } public getMangaToMigrate( mangaId: number | string, { migrateChapters = false, migrateCategories = false, migrateTracking = false, deleteChapters = false, migrateMetadata = false, apolloOptions: options, }: Partial & { apolloOptions?: QueryOptions; } = {}, ): AbortabaleApolloQueryResponse { return this.doRequest( GQLMethod.QUERY, GET_MANGA_TO_MIGRATE, { id: Number(mangaId), getChapterData: migrateChapters || deleteChapters, migrateCategories, migrateTracking, migrateMetadata, }, options, ); } public refreshManga( mangaId: number | string, { fetchManga = true, fetchChapters = true, ...options }: MutationOptions & { fetchManga?: boolean; fetchChapters?: boolean; } = {}, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, REFRESH_MANGA, { id: Number(mangaId), fetchManga, fetchChapters, }, { refetchQueries: [GET_CHAPTERS_MANGA, GET_CHAPTERS_READER], ...options }, ); } public getMangaToMigrateToFetch( mangaId: number | string, { migrateChapters = false, migrateCategories = false, migrateTracking = false, apolloOptions: options, }: Partial> & { apolloOptions?: MutationOptions< GetMangaToMigrateToFetchMutation, GetMangaToMigrateToFetchMutationVariables >; } = {}, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, GET_MANGA_TO_MIGRATE_TO_FETCH, { id: Number(mangaId), migrateChapters, migrateCategories, migrateTracking, }, options, ); } public useGetMangas( document: DocumentNode | TypedDocumentNode, variables: Variables, options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, document, variables, options); } public getMangas( document: DocumentNode | TypedDocumentNode, variables: Variables, options?: QueryOptions, ): AbortabaleApolloQueryResponse { return this.doRequest(GQLMethod.QUERY, document, variables, options); } public useUpdateMangaCategories( options?: MutationHookOptions, ): AbortableApolloUseMutationResponse { const [mutate, result] = this.doRequest(GQLMethod.USE_MUTATION, UPDATE_MANGA_CATEGORIES, undefined, options); const wrappedMutate = (mutateOptions: Parameters[0]) => mutate({ onCompleted: () => { this.graphQLClient.client.refetchQueries({ updateCache(cache) { cache.evict({ fieldName: 'categories' }); cache.evict({ fieldName: 'mangas' }); }, }); }, ...mutateOptions, }); return [wrappedMutate, result]; } public updateMangasCategories( mangaIds: number[], patch: UpdateMangaCategoriesPatchInput, options?: MutationOptions, ): AbortableApolloMutationResponse { const response = this.doRequest( GQLMethod.MUTATION, UPDATE_MANGAS_CATEGORIES, { input: { ids: mangaIds, patch } }, options, ); response.response.then(() => { this.graphQLClient.client.refetchQueries({ updateCache(cache) { cache.evict({ fieldName: 'categories' }); cache.evict({ fieldName: 'mangas' }); }, }); }); return response; } public updateManga( id: number, patch: { updateManga: UpdateMangaPatchInput; updateMangaCategories?: UpdateMangaCategoriesPatchInput }, options?: MutationOptions, ): AbortableApolloMutationResponse { const result = this.doRequest( GQLMethod.MUTATION, UPDATE_MANGA, { input: { id, patch: patch.updateManga }, updateCategoryInput: { id, patch: patch.updateMangaCategories ?? {} }, updateCategories: !!patch.updateMangaCategories, }, options, ); result.response.then(() => { this.graphQLClient.client.refetchQueries({ updateCache(cache) { cache.evict({ fieldName: 'categories' }); cache.evict({ fieldName: 'category' }); cache.evict({ fieldName: 'mangas' }); }, }); }); return result; } public updateMangas( ids: number[], patch: { updateMangas: UpdateMangaPatchInput; updateMangasCategories?: UpdateMangaCategoriesPatchInput }, options?: MutationOptions, ): AbortableApolloMutationResponse { const result = this.doRequest( GQLMethod.MUTATION, UPDATE_MANGAS, { input: { ids, patch: patch.updateMangas }, updateCategoryInput: { ids, patch: patch.updateMangasCategories ?? {} }, updateCategories: !!patch.updateMangasCategories, }, options, ); result.response.then(() => { this.graphQLClient.client.refetchQueries({ updateCache(cache) { cache.evict({ fieldName: 'categories' }); cache.evict({ fieldName: 'category' }); cache.evict({ fieldName: 'mangas' }); }, }); }); return result; } public updateMangaMeta( { preUpdateDeleteInput = { items: [] }, updateInput = { items: [] }, postUpdateDeleteInput = { items: [] }, migrateInput = { items: [] }, }: { preUpdateDeleteInput?: DeleteMangaMetasInput; updateInput?: SetMangaMetasInput; postUpdateDeleteInput?: DeleteMangaMetasInput; migrateInput?: SetMangaMetasInput; }, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, UPDATE_MANGA_METADATA, { preUpdateDeleteInput, hasPreUpdateDeletions: preUpdateDeleteInput.items.some( (item) => !!item.keys?.length || !!item.prefixes?.length, ), updateInput, hasUpdates: updateInput.items.some((item) => !!item.metas?.length), postUpdateDeleteInput, hasPostUpdateDeletions: postUpdateDeleteInput.items.some( (item) => !!item.keys?.length || !!item.prefixes?.length, ), migrateInput, isMigration: migrateInput.items.some((item) => !!item.metas?.length), }, { optimisticResponse: { __typename: 'Mutation', preUpdateDeletedMeta: { __typename: 'DeleteMangaMetasPayload', metas: (preUpdateDeleteInput?.items ?? []).flatMap((item) => item.mangaIds.flatMap((mangaId) => (item.keys ?? []).map((key) => ({ __typename: 'MangaMetaType' as const, mangaId, key, value: '', })), ), ), }, updatedMeta: { __typename: 'SetMangaMetasPayload', metas: (updateInput?.items ?? []).flatMap((item) => item.mangaIds.flatMap((mangaId) => item.metas.map((meta) => ({ __typename: 'MangaMetaType' as const, mangaId, key: meta.key, value: meta.value, })), ), ), }, postUpdateDeletedMeta: { __typename: 'DeleteMangaMetasPayload', metas: (postUpdateDeleteInput?.items ?? []).flatMap((item) => item.mangaIds.flatMap((mangaId) => (item.keys ?? []).map((key) => ({ __typename: 'MangaMetaType' as const, mangaId, key, value: '', })), ), ), }, migrationMeta: { __typename: 'SetMangaMetasPayload', metas: (migrateInput?.items ?? []).flatMap((item) => item.mangaIds.flatMap((mangaId) => item.metas.map((meta) => ({ __typename: 'MangaMetaType' as const, mangaId, key: meta.key, value: meta.value, })), ), ), }, }, update(cache, { data }) { preUpdateDeleteInput?.items.forEach((item) => item.mangaIds.forEach((mangaId) => item.keys?.forEach((key) => { cache.evict({ id: cache.identify({ __typename: 'MangaMetaType', mangaId, key }), }); }), ), ); postUpdateDeleteInput?.items.forEach((item) => item.mangaIds.forEach((mangaId) => item.keys?.forEach((key) => { cache.evict({ id: cache.identify({ __typename: 'MangaMetaType', mangaId, key }), }); }), ), ); if (!data?.updatedMeta && !data?.migrationMeta) { return; } const mangaIds = [ ...updateInput.items.flatMap((item) => item.mangaIds), ...migrateInput.items.flatMap((item) => item.mangaIds), ]; mangaIds.forEach((mangaId) => { cache.modify({ id: cache.identify({ __typename: 'MangaType', id: mangaId }), fields: { meta(existingMetas, { readField }) { const updatedMeta = [ ...(data?.updatedMeta?.metas.filter((meta) => meta.mangaId === mangaId) ?? []), ...(data?.migrationMeta?.metas.filter((meta) => meta.mangaId === mangaId) ?? []), ]; return updateMetadataList(updatedMeta, existingMetas, readField, (meta) => cache.writeFragment({ data: meta, fragment: MANGA_META_FIELDS, }), ); }, }, }); }); }, ...options, }, ); } public useGetChapters( document: DocumentNode | TypedDocumentNode, variables: Variables, options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, document, variables, options); } public getChapters( document: DocumentNode | TypedDocumentNode, variables: Variables, options?: QueryOptions, ): AbortabaleApolloQueryResponse { return this.doRequest(GQLMethod.QUERY, document, variables, options); } public useGetMangaChapters( document: DocumentNode | TypedDocumentNode, mangaId: number | string, options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.useGetChapters( document, { condition: { mangaId: Number(mangaId) }, order: [{ by: ChapterOrderBy.SourceOrder, byType: SortOrder.Desc }], } satisfies GetChaptersMangaQueryVariables as unknown as Variables, options, ); } public useGetMangaChapter( mangaId: number | string, chapterIndex: number | string, options?: QueryHookOptions, ): AbortableApolloUseQueryResponse< Omit & { chapter: GetChaptersMangaQuery['chapters']['nodes'][number] }, GetChaptersMangaQueryVariables > { type Response = AbortableApolloUseQueryResponse< Omit & { chapter: GetChaptersMangaQuery['chapters']['nodes'][number] }, GetChaptersMangaQueryVariables >; const chapterResponse = this.useGetChapters( GET_CHAPTERS_MANGA, { condition: { mangaId: Number(mangaId), sourceOrder: Number(chapterIndex) } }, options, ); if (!chapterResponse.data) { return chapterResponse as unknown as Response; } return { ...chapterResponse, data: { chapter: chapterResponse.data.chapters.nodes[0], }, } as unknown as Response; } public useGetChapterPagesFetch( chapterId: string | number, options?: MutationHookOptions, ): AbortableApolloUseMutationResponse { return this.doRequest( GQLMethod.USE_MUTATION, GET_CHAPTER_PAGES_FETCH, { input: { chapterId: Number(chapterId) }, }, options, ); } public deleteDownloadedChapter( id: number, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, DELETE_DOWNLOADED_CHAPTER, { input: { id } }, options, ); } public deleteDownloadedChapters( ids: number[], options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, DELETE_DOWNLOADED_CHAPTERS, { input: { ids } }, options, ); } public updateChapter( id: number, patch: UpdateChapterPatchInput & { chapterIdToDelete?: number; trackProgressMangaId?: number; }, options?: MutationOptions, ): AbortableApolloMutationResponse { const { chapterIdToDelete = -1, trackProgressMangaId = -1, ...updatePatch } = patch; return this.doRequest( GQLMethod.MUTATION, UPDATE_CHAPTER, { input: { id, patch: updatePatch }, getBookmarked: patch.isBookmarked != null, getRead: patch.isRead != null, getLastPageRead: patch.lastPageRead != null, chapterIdToDelete, deleteChapter: chapterIdToDelete >= 0, mangaId: trackProgressMangaId, trackProgress: trackProgressMangaId >= 0, }, options, ); } public updateChapterMeta( { preUpdateDeleteInput = { items: [] }, updateInput = { items: [] }, postUpdateDeleteInput = { items: [] }, migrateInput = { items: [] }, }: { preUpdateDeleteInput?: DeleteChapterMetasInput; updateInput?: SetChapterMetasInput; postUpdateDeleteInput?: DeleteChapterMetasInput; migrateInput?: SetChapterMetasInput; }, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, UPDATE_CHAPTER_METADATA, { preUpdateDeleteInput, hasPreUpdateDeletions: preUpdateDeleteInput.items.some( (item) => !!item.keys?.length || !!item.prefixes?.length, ), updateInput, hasUpdates: updateInput.items.some((item) => !!item.metas?.length), postUpdateDeleteInput, hasPostUpdateDeletions: postUpdateDeleteInput.items.some( (item) => !!item.keys?.length || !!item.prefixes?.length, ), migrateInput, isMigration: migrateInput.items.some((item) => !!item.metas?.length), }, { optimisticResponse: { __typename: 'Mutation', preUpdateDeletedMeta: { __typename: 'DeleteChapterMetasPayload', metas: (preUpdateDeleteInput?.items ?? []).flatMap((item) => item.chapterIds.flatMap((chapterId) => (item.keys ?? []).map((key) => ({ __typename: 'ChapterMetaType' as const, chapterId, key, value: '', })), ), ), }, updatedMeta: { __typename: 'SetChapterMetasPayload', metas: (updateInput?.items ?? []).flatMap((item) => item.chapterIds.flatMap((chapterId) => item.metas.map((meta) => ({ __typename: 'ChapterMetaType' as const, chapterId, key: meta.key, value: meta.value, })), ), ), }, postUpdateDeletedMeta: { __typename: 'DeleteChapterMetasPayload', metas: (postUpdateDeleteInput?.items ?? []).flatMap((item) => item.chapterIds.flatMap((chapterId) => (item.keys ?? []).map((key) => ({ __typename: 'ChapterMetaType' as const, chapterId, key, value: '', })), ), ), }, migrationMeta: { __typename: 'SetChapterMetasPayload', metas: (migrateInput?.items ?? []).flatMap((item) => item.chapterIds.flatMap((chapterId) => item.metas.map((meta) => ({ __typename: 'ChapterMetaType' as const, chapterId, key: meta.key, value: meta.value, })), ), ), }, }, update(cache, { data }) { preUpdateDeleteInput?.items.forEach((item) => item.chapterIds.forEach((chapterId) => item.keys?.forEach((key) => { cache.evict({ id: cache.identify({ __typename: 'ChapterMetaType', chapterId, key }), }); }), ), ); postUpdateDeleteInput?.items.forEach((item) => item.chapterIds.forEach((chapterId) => item.keys?.forEach((key) => { cache.evict({ id: cache.identify({ __typename: 'ChapterMetaType', chapterId, key }), }); }), ), ); if (!data?.updatedMeta && !data?.migrationMeta) { return; } const chapterIds = [ ...updateInput.items.flatMap((item) => item.chapterIds), ...migrateInput.items.flatMap((item) => item.chapterIds), ]; chapterIds.forEach((chapterId) => { cache.modify({ id: cache.identify({ __typename: 'ChapterType', id: chapterId }), fields: { meta(existingMetas, { readField }) { const updatedMeta = [ ...(data?.updatedMeta?.metas.filter((meta) => meta.chapterId === chapterId) ?? []), ...(data?.migrationMeta?.metas.filter((meta) => meta.chapterId === chapterId) ?? []), ]; return updateMetadataList(updatedMeta, existingMetas, readField, (meta) => cache.writeFragment({ data: meta, fragment: CHAPTER_META_FIELDS, }), ); }, }, }); }); }, ...options, }, ); } public getChapterPageUrl(mangaId: number | string, chapterIndex: number | string, page: number): string { return this.getValidImgUrlFor( `manga/${mangaId}/chapter/${chapterIndex}/page/${page}`, RequestManager.API_VERSION, ); } public updateChapters( ids: number[], patch: UpdateChapterPatchInput & { chapterIdsToDelete?: number[]; trackProgressMangaId?: MangaIdInfo['id'] }, options?: MutationOptions, ): AbortableApolloMutationResponse { const { chapterIdsToDelete = [], trackProgressMangaId = -1, ...updatePatch } = patch; return this.doRequest( GQLMethod.MUTATION, UPDATE_CHAPTERS, { input: { ids, patch: updatePatch }, getBookmarked: patch.isBookmarked != null, getRead: patch.isRead != null, getLastPageRead: patch.lastPageRead != null, chapterIdsToDelete, deleteChapters: !!chapterIdsToDelete.length, mangaId: trackProgressMangaId, trackProgress: trackProgressMangaId >= 0, }, options, ); } public useGetCategories( document: DocumentNode | TypedDocumentNode, options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest( GQLMethod.USE_QUERY, document, { order: [{ by: CategoryOrderBy.Order }], } satisfies GetCategoriesSettingsQueryVariables as unknown as Variables, options, ); } public getCategories( document: DocumentNode | TypedDocumentNode, options?: QueryOptions, ): AbortabaleApolloQueryResponse { return this.doRequest( GQLMethod.QUERY, document, { order: [{ by: CategoryOrderBy.Order }], } satisfies GetCategoriesSettingsQueryVariables as unknown as Variables, options, ); } public createCategory( input: CreateCategoryInput, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, CREATE_CATEGORY, { input }, { refetchQueries: [GET_CATEGORIES_BASE, GET_CATEGORIES_LIBRARY, GET_CATEGORIES_SETTINGS], ...options }, ); } public useReorderCategory( options?: MutationHookOptions, ): AbortableApolloUseMutationResponse { const [mutate, result] = this.doRequest( GQLMethod.USE_MUTATION, UPDATE_CATEGORY_ORDER, undefined, { refetchQueries: [GET_CATEGORIES_BASE, GET_CATEGORIES_LIBRARY], ...options }, ); const wrappedMutate = (mutateOptions: Parameters[0]) => { const variables = mutateOptions?.variables?.input; const cachedCategories = this.graphQLClient.client.readQuery< GetCategoriesSettingsQuery, GetCategoriesSettingsQueryVariables >({ query: GET_CATEGORIES_SETTINGS, variables: { order: [{ by: CategoryOrderBy.Order }] }, })?.categories.nodes; if (!variables) { throw new Error('useReorderCategory: no variables passed'); } if (!cachedCategories) { throw new Error('useReorderCategory: there are no cached results'); } const movedIndex = cachedCategories.findIndex((category) => category.id === variables.id); const newData = [...cachedCategories.map((category) => ({ ...category }))]; const [removed] = newData.splice(movedIndex, 1); newData.splice(variables.position, 0, removed); removed.order = variables.position; newData[movedIndex].order = movedIndex; return mutate({ update: (cache) => { cache.updateQuery( { id: cache.identify({ __typename: 'CategoryNodeList' }), query: GET_CATEGORIES_SETTINGS, variables: { order: [{ by: CategoryOrderBy.Order }] }, }, (data) => ({ ...data!, categories: { ...data!.categories, nodes: newData, }, }), ); }, optimisticResponse: { __typename: 'Mutation', updateCategoryOrder: { __typename: 'UpdateCategoryOrderPayload', categories: newData, }, }, ...mutateOptions, }); }; return [wrappedMutate, result]; } public useGetCategoryMangas( id: number, options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { const isDefaultCategory = id === 0; if (isDefaultCategory) { // hacky way of loading the default category mangas - some stuff won't work but since that is not used anyway, it won't be a problem // can't be loaded via "useGetMangas" because mangas are not actually mapped to the default category in the database const { data, ...result } = this.doRequest( GQLMethod.USE_QUERY, GET_CATEGORY_MANGAS, { id }, options as QueryHookOptions, ); return { ...result, data: data ? { ...data?.category, __typename: 'Query', } : undefined, } as unknown as AbortableApolloUseQueryResponse; } return this.useGetMangas(GET_MANGAS_LIBRARY, { condition: { inLibrary: true, categoryIds: [id] } }, options); } public deleteCategory( categoryId: number, options?: MutationOptions, ): AbortableApolloMutationResponse { const result = this.doRequest( GQLMethod.MUTATION, DELETE_CATEGORY, { input: { categoryId } }, options, ); result.response.then(() => { this.graphQLClient.client.refetchQueries({ updateCache(cache) { cache.evict({ id: cache.identify({ __typename: 'CategoryType', id: categoryId.toString() }) }); }, }); }); return result; } public updateCategory( id: number, patch: UpdateCategoryPatchInput, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, UPDATE_CATEGORY, { input: { id, patch }, getIncludeInUpdate: patch.includeInUpdate != null, getIncludeInDownload: patch.includeInDownload != null, getDefault: patch.default != null, getName: patch.name != null, }, options, ); } public updateCategoryMeta( { preUpdateDeleteInput = { items: [] }, updateInput = { items: [] }, postUpdateDeleteInput = { items: [] }, migrateInput = { items: [] }, }: { preUpdateDeleteInput?: DeleteCategoryMetasInput; updateInput?: SetCategoryMetasInput; postUpdateDeleteInput?: DeleteCategoryMetasInput; migrateInput?: SetCategoryMetasInput; }, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, UPDATE_CATEGORY_METADATA, { preUpdateDeleteInput, hasPreUpdateDeletions: preUpdateDeleteInput.items.some( (item) => !!item.keys?.length || !!item.prefixes?.length, ), updateInput, hasUpdates: updateInput.items.some((item) => !!item.metas?.length), postUpdateDeleteInput, hasPostUpdateDeletions: postUpdateDeleteInput.items.some( (item) => !!item.keys?.length || !!item.prefixes?.length, ), migrateInput, isMigration: migrateInput.items.some((item) => !!item.metas?.length), }, { optimisticResponse: { __typename: 'Mutation', preUpdateDeletedMeta: { __typename: 'DeleteCategoryMetasPayload', metas: (preUpdateDeleteInput?.items ?? []).flatMap((item) => item.categoryIds.flatMap((categoryId) => (item.keys ?? []).map((key) => ({ __typename: 'CategoryMetaType' as const, categoryId, key, value: '', })), ), ), }, updatedMeta: { __typename: 'SetCategoryMetasPayload', metas: (updateInput?.items ?? []).flatMap((item) => item.categoryIds.flatMap((categoryId) => item.metas.map((meta) => ({ __typename: 'CategoryMetaType' as const, categoryId, key: meta.key, value: meta.value, })), ), ), }, postUpdateDeletedMeta: { __typename: 'DeleteCategoryMetasPayload', metas: (postUpdateDeleteInput?.items ?? []).flatMap((item) => item.categoryIds.flatMap((categoryId) => (item.keys ?? []).map((key) => ({ __typename: 'CategoryMetaType' as const, categoryId, key, value: '', })), ), ), }, migrationMeta: { __typename: 'SetCategoryMetasPayload', metas: (migrateInput?.items ?? []).flatMap((item) => item.categoryIds.flatMap((categoryId) => item.metas.map((meta) => ({ __typename: 'CategoryMetaType' as const, categoryId, key: meta.key, value: meta.value, })), ), ), }, }, update(cache, { data }) { preUpdateDeleteInput?.items.forEach((item) => item.categoryIds.forEach((categoryId) => item.keys?.forEach((key) => { cache.evict({ id: cache.identify({ __typename: 'CategoryMetaType', categoryId, key }), }); }), ), ); postUpdateDeleteInput?.items.forEach((item) => item.categoryIds.forEach((categoryId) => item.keys?.forEach((key) => { cache.evict({ id: cache.identify({ __typename: 'CategoryMetaType', categoryId, key }), }); }), ), ); if (!data?.updatedMeta && !data?.migrationMeta) { return; } const categoryIds = [ ...updateInput.items.flatMap((item) => item.categoryIds), ...migrateInput.items.flatMap((item) => item.categoryIds), ]; categoryIds.forEach((categoryId) => { cache.modify({ id: cache.identify({ __typename: 'CategoryType', id: categoryId }), fields: { meta(existingMetas, { readField }) { const updatedMeta = [ ...(data?.updatedMeta?.metas.filter((meta) => meta.categoryId === categoryId) ?? []), ...(data?.migrationMeta?.metas.filter( (meta) => meta.categoryId === categoryId, ) ?? []), ]; return updateMetadataList(updatedMeta, existingMetas, readField, (meta) => cache.writeFragment({ data: meta, fragment: CATEGORY_META_FIELDS, }), ); }, }, }); }); }, ...options, }, ); } public createBackupFile( input: CreateBackupInput, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, CREATE_BACKUP, { input }, { ...options, }, ); } public restoreBackupFile( input: RestoreBackupInput, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, RESTORE_BACKUP, input, { ...options, }, ); } public validateBackupFile( file: File, options?: QueryOptions, ): AbortabaleApolloQueryResponse { return this.doRequest(GQLMethod.QUERY, VALIDATE_BACKUP, { backup: file }, options); } public useGetBackupRestoreStatus( id: string, options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, GET_RESTORE_STATUS, { id }, options); } public startDownloads( options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, START_DOWNLOADER, {}, options, ); } public stopDownloads( options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, STOP_DOWNLOADER, {}, options, ); } public clearDownloads( options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, CLEAR_DOWNLOADER, {}, options, ); } public addChapterToDownloadQueue( id: number, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, ENQUEUE_CHAPTER_DOWNLOAD, { input: { id } }, options, ); } public removeChapterFromDownloadQueue( id: number, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, DEQUEUE_CHAPTER_DOWNLOAD, { input: { id } }, options, ); } public useReorderChapterInDownloadQueue( options?: MutationHookOptions, ): AbortableApolloUseMutationResponse { const [mutate, result] = this.doRequest(GQLMethod.USE_MUTATION, REORDER_CHAPTER_DOWNLOAD, undefined, options); const wrappedMutate = (mutationOptions: Parameters[0]) => { const variables = mutationOptions?.variables?.input; const cachedDownloadStatus = this.graphQLClient.client.readFragment< GetDownloadStatusQuery['downloadStatus'] >({ id: 'DownloadStatus:{}', fragment: DOWNLOAD_STATUS_FIELDS, fragmentName: 'DOWNLOAD_STATUS_FIELDS', }); if (!variables) { throw new Error('useReorderChapterInDownloadQueue: no variables passed'); } if (!cachedDownloadStatus) { throw new Error('useReorderChapterInDownloadQueue: there are no cached results'); } const movedIndex = cachedDownloadStatus.queue.findIndex( ({ chapter }) => chapter.id === variables.chapterId, ); const chapterDownload = cachedDownloadStatus.queue[movedIndex]; const queueWithoutChapterDownload = cachedDownloadStatus.queue.toSpliced(movedIndex, 1); const updatedQueue = queueWithoutChapterDownload.toSpliced(variables.to, 0, chapterDownload); return mutate({ optimisticResponse: { __typename: 'Mutation', reorderChapterDownload: { __typename: 'ReorderChapterDownloadPayload', downloadStatus: { ...cachedDownloadStatus, queue: updatedQueue, }, }, }, ...mutationOptions, }); }; return [wrappedMutate, result]; } public addChaptersToDownloadQueue( ids: number[], options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, ENQUEUE_CHAPTER_DOWNLOADS, { input: { ids } }, options, ); } public removeChaptersFromDownloadQueue( ids: number[], options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, DEQUEUE_CHAPTER_DOWNLOADS, { input: { ids } }, options, ); } public useGetRecentlyUpdatedChapters( initialPages: number = 1, options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { const PAGE_SIZE = 50; const CACHE_KEY = 'useGetRecentlyUpdatedChapters'; const offset = this.cache.getResponseFor(CACHE_KEY, undefined) ?? 0; const [lastOffset] = useState(offset); const result = this.useGetChapters( GET_CHAPTERS_UPDATES, { filter: { inLibrary: { equalTo: true } }, order: [ { by: ChapterOrderBy.FetchedAt, byType: SortOrder.Desc }, { by: ChapterOrderBy.SourceOrder, byType: SortOrder.Desc }, ], first: initialPages * PAGE_SIZE + lastOffset, }, options, ); useEffect(() => { this.graphQLClient.client.refetchQueries({ updateCache(cache) { uniqBy('mangaId', result.data?.chapters.nodes).forEach((chapter) => { Object.keys((cache as InMemoryCache).extract().ROOT_QUERY as object) .filter((key) => key.includes('chapters') && key.includes(`mangaId":${chapter.mangaId}`)) .forEach((key) => cache.evict({ fieldName: key })); }); }, }); }, [result]); return { ...result, fetchMore: (...args: Parameters<(typeof result)['fetchMore']>) => { const fetchMoreOptions = args[0] ?? {}; this.cache.cacheResponse(CACHE_KEY, undefined, fetchMoreOptions.variables?.offset); return result.fetchMore({ ...fetchMoreOptions, variables: { first: PAGE_SIZE, ...fetchMoreOptions.variables }, }); }, } as typeof result; } public useGetRecentlyReadChapters( initialPages: number = 1, options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { const PAGE_SIZE = 50; const CACHE_KEY = 'useGetRecentlyReadChapters'; const offset = this.cache.getResponseFor(CACHE_KEY, undefined) ?? 0; const [lastOffset] = useState(offset); const result = this.useGetChapters( GET_CHAPTERS_HISTORY, { filter: { lastReadAt: { isNull: false, notEqualToAll: ['0'] } }, order: [ { by: ChapterOrderBy.LastReadAt, byType: SortOrder.Desc }, { by: ChapterOrderBy.SourceOrder, byType: SortOrder.Desc }, ], first: initialPages * PAGE_SIZE + lastOffset, }, options, ); return { ...result, fetchMore: (...args: Parameters<(typeof result)['fetchMore']>) => { const fetchMoreOptions = args[0] ?? {}; this.cache.cacheResponse(CACHE_KEY, undefined, fetchMoreOptions.variables?.offset); return result.fetchMore({ ...fetchMoreOptions, variables: { first: PAGE_SIZE, ...fetchMoreOptions.variables }, }); }, } as typeof result; } public startGlobalUpdate( categories?: number[], options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest(GQLMethod.MUTATION, UPDATE_LIBRARY, { input: { categories } }, options); } public resetGlobalUpdate( options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, STOP_UPDATER, {}, options, ); } public useGetGlobalUpdateSummary( options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, GET_UPDATE_STATUS, {}, options); } public useGetDownloadStatus( options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, GET_DOWNLOAD_STATUS, {}, options); } public useDownloadSubscription( options?: SubscriptionHookOptions, ): useSubscription.Result { return this.doRequest( GQLMethod.USE_SUBSCRIPTION, DOWNLOAD_STATUS_SUBSCRIPTION, { input: { maxUpdates: 30 } }, { ...options, onData: (onDataOptions) => { const downloadChanged = onDataOptions.data.data?.downloadStatusChanged; const { cache } = this.graphQLClient.client; if (downloadChanged?.omittedUpdates) { this.graphQLClient.client.refetchQueries({ updateCache() { cache.evict({ id: 'DownloadStatus:{}' }); cache.evict({ fieldName: 'downloadStatus' }); }, }); return; } if (downloadChanged?.state) { cache.modify({ id: 'DownloadStatus:{}', fields: { state() { return downloadChanged.state; }, }, }); } const downloadStatusQueryCache = cache.readQuery({ query: GET_DOWNLOAD_STATUS, }); const downloadsToAdd = downloadChanged?.updates.filter((update) => { const removeDownload = [DownloadUpdateType.Dequeued, DownloadUpdateType.Finished].includes( update.type, ); if (removeDownload) { return false; } const isAlreadyKnown = downloadStatusQueryCache?.downloadStatus.queue.some( (download) => download.chapter.id === update.download.chapter.id, ); return !isAlreadyKnown; }); if (downloadsToAdd?.length) { cache.writeQuery({ query: GET_DOWNLOAD_STATUS, data: { __typename: 'Query', ...downloadStatusQueryCache, downloadStatus: { __typename: 'DownloadStatus', state: downloadChanged?.state ?? downloadStatusQueryCache?.downloadStatus.state ?? DownloaderState.Stopped, queue: [ ...(downloadStatusQueryCache?.downloadStatus?.queue ?? []), ...(downloadsToAdd?.map((update) => update.download) ?? []), ], }, }, }); } downloadChanged?.updates.forEach((update) => { const removeDownload = [DownloadUpdateType.Dequeued, DownloadUpdateType.Finished].includes( update.type, ); if (!removeDownload) { return; } cache.evict({ id: cache.identify({ __typename: 'DownloadType', chapter: { __ref: cache.identify({ __typename: 'ChapterType', id: update.download.chapter.id, }), }, }), }); }); }, } as SubscriptionHookOptions, ) as useSubscription.Result; } public useUpdaterSubscription( options?: SubscriptionHookOptions, ): useSubscription.Result { return this.doRequest( GQLMethod.USE_SUBSCRIPTION, UPDATER_SUBSCRIPTION, { input: { maxUpdates: 30 } }, { ...options, onData: (onDataOptions) => { const updatesChanged = onDataOptions.data.data?.libraryUpdateStatusChanged; const cache = this.graphQLClient.client.cache as InMemoryCache; if (!updatesChanged?.omittedUpdates) { updatesChanged?.mangaUpdates .filter((update) => update.status === MangaJobStatus.Complete) .forEach((update) => Object.keys(cache.extract().ROOT_QUERY as object) .filter( (key) => key.includes('chapters') && key.includes(`mangaId":${update.manga.id}`), ) .forEach((key) => cache.evict({ fieldName: key })), ); return; } this.graphQLClient.client.refetchQueries({ updateCache() { cache.evict({ fieldName: 'chapters' }); cache.evict({ fieldName: 'libraryUpdateStatus' }); }, }); }, } as SubscriptionHookOptions, ) as useSubscription.Result; } public getServerSettings( options?: QueryOptions, ): AbortabaleApolloQueryResponse { return this.doRequest(GQLMethod.QUERY, GET_SERVER_SETTINGS, undefined, options); } public useGetServerSettings( options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, GET_SERVER_SETTINGS, undefined, options); } public useUpdateServerSettings( options?: MutationHookOptions, ): AbortableApolloUseMutationResponse { const [mutate, result] = this.doRequest(GQLMethod.USE_MUTATION, UPDATE_SERVER_SETTINGS, undefined, options); const wrappedMutate = async (mutateOptions: Parameters[0]) => mutate({ optimisticResponse: { __typename: 'Mutation', setSettings: { __typename: 'SetSettingsPayload', settings: { __typename: 'SettingsType', ...(mutateOptions?.variables?.input.settings ?? {}), } as UpdateServerSettingsMutation['setSettings']['settings'], }, }, ...mutateOptions, }); return [wrappedMutate, result]; } public useGetLastGlobalUpdateTimestamp( options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, GET_LAST_UPDATE_TIMESTAMP, {}, options); } public useClearServerCache( options?: MutationHookOptions, ): AbortableApolloUseMutationResponse { return this.doRequest(GQLMethod.USE_MUTATION, CLEAR_SERVER_CACHE, { input: {} }, options); } public useWebUIUpdateSubscription( options?: SubscriptionHookOptions, ): useSubscription.Result { return this.doRequest(GQLMethod.USE_SUBSCRIPTION, WEBUI_UPDATE_SUBSCRIPTION, undefined, options); } public resetWebUIUpdateStatus( options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest(GQLMethod.MUTATION, RESET_WEBUI_UPDATE_STATUS, undefined, options); } public useGetWebUIUpdateStatus( options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, GET_WEBUI_UPDATE_STATUS, undefined, options); } public useGetMigratableSources( options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, GET_MIGRATABLE_SOURCES, undefined, options); } public useGetTrackerList( document: DocumentNode | TypedDocumentNode, options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, document, undefined, options); } public logoutFromTracker( trackerId: TrackerLogoutMutationVariables['trackerId'], options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest(GQLMethod.MUTATION, TRACKER_LOGOUT, { trackerId }, options); } public loginToTrackerOauth( trackerId: number, callbackUrl: string, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest(GQLMethod.MUTATION, TRACKER_LOGIN_OAUTH, { input: { trackerId, callbackUrl } }, options); } public loginTrackerCredentials( trackerId: number, username: string, password: string, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, TRACKER_LOGIN_CREDENTIALS, { input: { trackerId, username, password } }, options, ); } public useTrackerSearch( trackerId: number, query: string, options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, TRACKER_SEARCH, { trackerId, query }, options); } public useBindTracker( options?: MutationHookOptions, ): AbortableApolloUseMutationResponse { return this.doRequest(GQLMethod.USE_MUTATION, TRACKER_BIND, undefined, options); } public bindTracker( mangaId: number, trackerId: number, remoteId: string, asPrivate: boolean, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, TRACKER_BIND, { input: { mangaId, remoteId, trackerId, private: asPrivate } }, options, ); } public bindTrackRecord( mangaId: number, trackRecordId: number, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, TRACK_BIND_TRACK_RECORD, { input: { mangaId, trackRecordId } }, options, ); } public unbindTracker( recordId: number, deleteRemoteTrack?: boolean, options?: MutationHookOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, TRACKER_UNBIND, { input: { recordId, deleteRemoteTrack } }, { refetchQueries: [GET_MANGA_TRACK_RECORDS], ...options } as MutationOptions< TrackerUnbindMutation, TrackerUnbindMutationVariables >, ); } public updateTrackerBind( id: number, patch: Omit, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest(GQLMethod.MUTATION, TRACKER_UPDATE_BIND, { input: { ...patch, recordId: id } }, options); } public fetchTrackBind( recordId: number, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, TRACKER_FETCH_BIND, { recordId }, options, ); } public useLoginUser( options?: MutationHookOptions, ): AbortableApolloUseMutationResponse { return this.doRequest(GQLMethod.USE_MUTATION, USER_LOGIN, undefined, options); } public useKoSyncStatus( options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { return this.doRequest(GQLMethod.USE_QUERY, GET_KO_SYNC_STATUS, undefined, options); } public koSyncLogin( serverAddress: string, username: string, password: string, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, KO_SYNC_LOGIN, { serverAddress, username, password, }, options, ); } public koSyncLogout( options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest(GQLMethod.MUTATION, KO_SYNC_LOGOUT, undefined, options); } public refreshUser( refreshToken: string, options?: MutationOptions, ): AbortableApolloMutationResponse { return this.doRequest( GQLMethod.MUTATION, USER_REFRESH, { refreshToken: refreshToken ?? undefined }, options, ); } } export const requestManager = new RequestManager();