From e71c4b7ef1590df5dcde75dfda8b0d7d30031356 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Sat, 26 Oct 2024 17:25:04 +0200 Subject: [PATCH] Cleanup deprecated metadata after migration - delete deprecated metadata - save applied migration id per metadata holder --- src/lib/graphql/generated/graphql.ts | 13 +- src/lib/graphql/mutations/CategoryMutation.ts | 6 - src/lib/graphql/mutations/ChapterMutation.ts | 6 - src/lib/graphql/mutations/MangaMutation.ts | 6 - src/lib/graphql/mutations/SourceMutation.ts | 12 + src/lib/requests/RequestManager.ts | 164 +++++++++++++- .../category/services/CategoryMetadata.ts | 41 +++- src/modules/core/components/GridLayouts.tsx | 3 +- .../library/components/LibraryMangaGrid.tsx | 3 +- .../components/LibraryOptionsPanel.tsx | 7 +- .../contexts/LibraryOptionsContext.tsx | 10 +- .../contexts/LibraryOptionsProvider.tsx | 6 +- .../hooks/useGetVisibleLibraryMangas.ts | 7 +- src/modules/library/screens/Library.tsx | 4 +- .../library/screens/LibraryDuplicates.tsx | 2 +- src/modules/manga/Manga.types.ts | 2 +- src/modules/manga/components/MangaGrid.tsx | 2 +- .../manga/components/cards/MangaCard.tsx | 3 +- .../manga/components/cards/MangaGridCard.tsx | 2 +- src/modules/metadata/Metadata.constants.ts | 49 +++++ src/modules/metadata/Metadata.types.ts | 7 + .../services/MetadataApolloCacheHandler.ts | 33 +++ .../metadata/services/MetadataMigrations.ts | 3 +- .../metadata/services/MetadataReader.ts | 208 +++++++++++++++++- .../metadata/services/MetadataUpdater.ts | 62 +++++- src/modules/migration/screens/Migrate.tsx | 2 +- src/modules/reader/Reader.types.ts | 4 - .../reader/screens/DefaultReaderSettings.tsx | 14 +- src/modules/reader/screens/Reader.tsx | 11 +- .../reader/services/ReaderSettingsMetadata.ts | 128 +++++------ .../services/ServerSettingsMetadata.ts | 9 +- .../source/components/SourceGridLayout.tsx | 2 +- src/modules/source/screens/SourceMangas.tsx | 11 +- src/modules/source/services/SourceMetadata.ts | 28 ++- 34 files changed, 687 insertions(+), 183 deletions(-) create mode 100644 src/modules/metadata/services/MetadataApolloCacheHandler.ts diff --git a/src/lib/graphql/generated/graphql.ts b/src/lib/graphql/generated/graphql.ts index d1723da4..172665a1 100644 --- a/src/lib/graphql/generated/graphql.ts +++ b/src/lib/graphql/generated/graphql.ts @@ -2826,7 +2826,7 @@ export type DeleteCategoryMetadataMutationVariables = Exact<{ }>; -export type DeleteCategoryMetadataMutation = { __typename?: 'Mutation', deleteCategoryMeta?: { __typename?: 'DeleteCategoryMetaPayload', meta?: { __typename?: 'CategoryMetaType', categoryId: number, key: string, value: string } | null, category: { __typename?: 'CategoryType', id: number, meta: Array<{ __typename?: 'CategoryMetaType', categoryId: number, key: string, value: string }> } } | null }; +export type DeleteCategoryMetadataMutation = { __typename?: 'Mutation', deleteCategoryMeta?: { __typename?: 'DeleteCategoryMetaPayload', meta?: { __typename?: 'CategoryMetaType', categoryId: number, key: string, value: string } | null } | null }; export type SetCategoryMetadataMutationVariables = Exact<{ input: SetCategoryMetaInput; @@ -2869,7 +2869,7 @@ export type DeleteChapterMetadataMutationVariables = Exact<{ }>; -export type DeleteChapterMetadataMutation = { __typename?: 'Mutation', deleteChapterMeta?: { __typename?: 'DeleteChapterMetaPayload', meta?: { __typename?: 'ChapterMetaType', chapterId: number, key: string, value: string } | null, chapter: { __typename?: 'ChapterType', id: number, meta: Array<{ __typename?: 'ChapterMetaType', chapterId: number, key: string, value: string }> } } | null }; +export type DeleteChapterMetadataMutation = { __typename?: 'Mutation', deleteChapterMeta?: { __typename?: 'DeleteChapterMetaPayload', meta?: { __typename?: 'ChapterMetaType', chapterId: number, key: string, value: string } | null } | null }; export type GetChapterPagesFetchMutationVariables = Exact<{ input: FetchChapterPagesInput; @@ -3044,7 +3044,7 @@ export type DeleteMangaMetadataMutationVariables = Exact<{ }>; -export type DeleteMangaMetadataMutation = { __typename?: 'Mutation', deleteMangaMeta?: { __typename?: 'DeleteMangaMetaPayload', meta?: { __typename?: 'MangaMetaType', mangaId: number, key: string, value: string } | null, manga: { __typename?: 'MangaType', id: number, meta: Array<{ __typename?: 'MangaMetaType', mangaId: number, key: string, value: string }> } } | null }; +export type DeleteMangaMetadataMutation = { __typename?: 'Mutation', deleteMangaMeta?: { __typename?: 'DeleteMangaMetaPayload', meta?: { __typename?: 'MangaMetaType', mangaId: number, key: string, value: string } | null } | null }; export type GetMangaFetchMutationVariables = Exact<{ input: FetchMangaInput; @@ -3149,6 +3149,13 @@ export type SetSourceMetadataMutationVariables = Exact<{ export type SetSourceMetadataMutation = { __typename?: 'Mutation', setSourceMeta?: { __typename?: 'SetSourceMetaPayload', meta: { __typename?: 'SourceMetaType', sourceId: string, key: string, value: string } } | null }; +export type DeleteSourceMetadataMutationVariables = Exact<{ + input: DeleteSourceMetaInput; +}>; + + +export type DeleteSourceMetadataMutation = { __typename?: 'Mutation', deleteSourceMeta?: { __typename?: 'DeleteSourceMetaPayload', meta?: { __typename?: 'SourceMetaType', sourceId: string, key: string, value: string } | null } | null }; + export type TrackerLoginOauthMutationVariables = Exact<{ input: LoginTrackerOAuthInput; }>; diff --git a/src/lib/graphql/mutations/CategoryMutation.ts b/src/lib/graphql/mutations/CategoryMutation.ts index 6bb3ebb4..82231498 100644 --- a/src/lib/graphql/mutations/CategoryMutation.ts +++ b/src/lib/graphql/mutations/CategoryMutation.ts @@ -38,12 +38,6 @@ export const DELETE_CATEGORY_METADATA = gql` meta { ...CATEGORY_META_FIELDS } - category { - id - meta { - ...CATEGORY_META_FIELDS - } - } } } `; diff --git a/src/lib/graphql/mutations/ChapterMutation.ts b/src/lib/graphql/mutations/ChapterMutation.ts index b4461401..238e9d46 100644 --- a/src/lib/graphql/mutations/ChapterMutation.ts +++ b/src/lib/graphql/mutations/ChapterMutation.ts @@ -19,12 +19,6 @@ export const DELETE_CHAPTER_METADATA = gql` meta { ...CHAPTER_META_FIELDS } - chapter { - id - meta { - ...CHAPTER_META_FIELDS - } - } } } `; diff --git a/src/lib/graphql/mutations/MangaMutation.ts b/src/lib/graphql/mutations/MangaMutation.ts index 76a19fb6..1bac0e43 100644 --- a/src/lib/graphql/mutations/MangaMutation.ts +++ b/src/lib/graphql/mutations/MangaMutation.ts @@ -17,12 +17,6 @@ export const DELETE_MANGA_METADATA = gql` meta { ...MANGA_META_FIELDS } - manga { - id - meta { - ...MANGA_META_FIELDS - } - } } } `; diff --git a/src/lib/graphql/mutations/SourceMutation.ts b/src/lib/graphql/mutations/SourceMutation.ts index 46176f36..dce1a5ca 100644 --- a/src/lib/graphql/mutations/SourceMutation.ts +++ b/src/lib/graphql/mutations/SourceMutation.ts @@ -46,3 +46,15 @@ export const SET_SOURCE_METADATA = gql` } } `; + +export const DELETE_SOURCE_METADATA = gql` + ${SOURCE_META_FIELDS} + + mutation DELETE_SOURCE_METADATA($input: DeleteSourceMetaInput!) { + deleteSourceMeta(input: $input) { + meta { + ...SOURCE_META_FIELDS + } + } + } +`; diff --git a/src/lib/requests/RequestManager.ts b/src/lib/requests/RequestManager.ts index 4e9215d0..e6704311 100644 --- a/src/lib/requests/RequestManager.ts +++ b/src/lib/requests/RequestManager.ts @@ -46,12 +46,22 @@ import { CreateCategoryInput, CreateCategoryMutation, CreateCategoryMutationVariables, + DeleteCategoryMetadataMutation, + DeleteCategoryMetadataMutationVariables, DeleteCategoryMutation, DeleteCategoryMutationVariables, + DeleteChapterMetadataMutation, + DeleteChapterMetadataMutationVariables, DeleteDownloadedChapterMutation, DeleteDownloadedChapterMutationVariables, DeleteDownloadedChaptersMutation, DeleteDownloadedChaptersMutationVariables, + DeleteGlobalMetadataMutation, + DeleteGlobalMetadataMutationVariables, + DeleteMangaMetadataMutation, + DeleteMangaMetadataMutationVariables, + DeleteSourceMetadataMutation, + DeleteSourceMetadataMutationVariables, DequeueChapterDownloadMutation, DequeueChapterDownloadMutationVariables, DequeueChapterDownloadsMutation, @@ -199,7 +209,7 @@ import { WebuiUpdateSubscription, } from '@/lib/graphql/generated/graphql.ts'; import { GET_GLOBAL_METADATAS } from '@/lib/graphql/queries/GlobalMetadataQuery.ts'; -import { SET_GLOBAL_METADATA } from '@/lib/graphql/mutations/GlobalMetadataMutation.ts'; +import { DELETE_GLOBAL_METADATA, SET_GLOBAL_METADATA } from '@/lib/graphql/mutations/GlobalMetadataMutation.ts'; import { CHECK_FOR_SERVER_UPDATES, CHECK_FOR_WEBUI_UPDATE, @@ -214,6 +224,7 @@ import { } from '@/lib/graphql/mutations/ExtensionMutation.ts'; import { GET_MIGRATABLE_SOURCES, GET_SOURCES_LIST } from '@/lib/graphql/queries/SourceQuery.ts'; import { + DELETE_MANGA_METADATA, GET_MANGA_FETCH, GET_MANGA_TO_MIGRATE_TO_FETCH, SET_MANGA_METADATA, @@ -235,6 +246,7 @@ import { GET_CATEGORY_MANGAS, } from '@/lib/graphql/queries/CategoryQuery.ts'; import { + DELETE_SOURCE_METADATA, GET_SOURCE_MANGAS_FETCH, SET_SOURCE_METADATA, UPDATE_SOURCE_PREFERENCES, @@ -257,6 +269,7 @@ import { GET_MANGAS_CHAPTER_IDS_WITH_STATE, } from '@/lib/graphql/queries/ChapterQuery.ts'; import { + DELETE_CHAPTER_METADATA, GET_CHAPTER_PAGES_FETCH, GET_MANGA_CHAPTERS_FETCH, SET_CHAPTER_METADATA, @@ -266,6 +279,7 @@ import { import { CREATE_CATEGORY, DELETE_CATEGORY, + DELETE_CATEGORY_METADATA, SET_CATEGORY_METADATA, UPDATE_CATEGORY, UPDATE_CATEGORY_ORDER, @@ -1152,6 +1166,34 @@ export class RequestManager { ); } + public deleteGlobalMeta( + key: string, + options?: MutationOptions, + ): AbortableApolloMutationResponse { + return this.doRequest( + GQLMethod.MUTATION, + DELETE_GLOBAL_METADATA, + { input: { key } }, + { + optimisticResponse: { + __typename: 'Mutation', + deleteGlobalMeta: { + __typename: 'DeleteGlobalMetaPayload', + meta: { + __typename: 'GlobalMetaType', + key, + value: '', + }, + }, + }, + update(cache) { + cache.evict({ id: cache.identify({ __typename: 'GlobalMetaType', key }) }); + }, + ...options, + }, + ); + } + public useGetAbout( options?: QueryHookOptions, ): AbortableApolloUseQueryResponse { @@ -1448,6 +1490,36 @@ export class RequestManager { ); } + public deleteSourceMeta( + sourceId: string, + key: string, + options?: MutationOptions, + ): AbortableApolloMutationResponse { + return this.doRequest( + GQLMethod.MUTATION, + DELETE_SOURCE_METADATA, + { input: { sourceId, key } }, + { + optimisticResponse: { + __typename: 'Mutation', + deleteSourceMeta: { + __typename: 'DeleteSourceMetaPayload', + meta: { + __typename: 'SourceMetaType', + sourceId, + key, + value: '', + }, + }, + }, + update(cache) { + cache.evict({ id: cache.identify({ __typename: 'SourceMetaType', id: sourceId, key }) }); + }, + ...options, + }, + ); + } + public useGetSourceMangas( input: FetchSourceMangaInput, initialPages: number = 1, @@ -1942,6 +2014,36 @@ export class RequestManager { ); } + public deleteMangaMeta( + mangaId: number, + key: string, + options?: MutationOptions, + ): AbortableApolloMutationResponse { + return this.doRequest( + GQLMethod.MUTATION, + DELETE_MANGA_METADATA, + { input: { mangaId, key } }, + { + optimisticResponse: { + __typename: 'Mutation', + deleteMangaMeta: { + __typename: 'DeleteMangaMetaPayload', + meta: { + __typename: 'MangaMetaType', + mangaId, + key, + value: '', + }, + }, + }, + update(cache) { + cache.evict({ id: cache.identify({ __typename: 'MangaMetaType', id: mangaId, key }) }); + }, + ...options, + }, + ); + } + public useGetChapters( document: DocumentNode | TypedDocumentNode, variables: Variables, @@ -2140,6 +2242,36 @@ export class RequestManager { ); } + public deleteChapterMeta( + chapterId: number, + key: string, + options?: MutationOptions, + ): AbortableApolloMutationResponse { + return this.doRequest( + GQLMethod.MUTATION, + DELETE_CHAPTER_METADATA, + { input: { chapterId, key } }, + { + optimisticResponse: { + __typename: 'Mutation', + deleteChapterMeta: { + __typename: 'DeleteChapterMetaPayload', + meta: { + __typename: 'ChapterMetaType', + chapterId, + key, + value: '', + }, + }, + }, + update(cache) { + cache.evict({ id: cache.identify({ __typename: 'ChapterMetaType', id: chapterId, key }) }); + }, + ...options, + }, + ); + } + public getChapterPageUrl(mangaId: number | string, chapterIndex: number | string, page: number): string { return this.getValidImgUrlFor( `manga/${mangaId}/chapter/${chapterIndex}/page/${page}`, @@ -2389,6 +2521,36 @@ export class RequestManager { ); } + public deleteCategoryMeta( + categoryId: number, + key: string, + options?: MutationOptions, + ): AbortableApolloMutationResponse { + return this.doRequest( + GQLMethod.MUTATION, + DELETE_CATEGORY_METADATA, + { input: { categoryId, key } }, + { + optimisticResponse: { + __typename: 'Mutation', + deleteCategoryMeta: { + __typename: 'DeleteCategoryMetaPayload', + meta: { + __typename: 'CategoryMetaType', + categoryId, + key, + value: '', + }, + }, + }, + update(cache) { + cache.evict({ id: cache.identify({ __typename: 'CategoryMetaType', id: categoryId, key }) }); + }, + ...options, + }, + ); + } + public restoreBackupFile( file: File, options?: MutationOptions, diff --git a/src/modules/category/services/CategoryMetadata.ts b/src/modules/category/services/CategoryMetadata.ts index 1b19ab75..9c54ee2a 100644 --- a/src/modules/category/services/CategoryMetadata.ts +++ b/src/modules/category/services/CategoryMetadata.ts @@ -6,10 +6,10 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { useEffect, useMemo } from 'react'; import { jsonSaveParse } from '@/lib/HelperFunctions.ts'; import { requestUpdateCategoryMetadata } from '@/modules/metadata/services/MetadataUpdater.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; -import { GridLayout } from '@/modules/library/contexts/LibraryOptionsContext.tsx'; import { LibraryOptions } from '@/modules/library/Library.types.ts'; import { CategoryIdInfo, CategoryMetadataKeys, ICategoryMetadata } from '@/modules/category/Category.types.ts'; import { convertFromGqlMeta } from '@/modules/metadata/services/MetadataConverter.ts'; @@ -19,9 +19,11 @@ import { AppMetadataKeys, GqlMetaHolder, Metadata, + MetadataHolder, } from '@/modules/metadata/Metadata.types.ts'; +import { GridLayout } from '@/modules/core/Core.types.ts'; -export const getDefaultCategoryMetadata = (): ICategoryMetadata => ({ +export const DEFAULT_CATEGORY_METADATA: ICategoryMetadata = { // display options showContinueReadingButton: false, showDownloadBadge: false, @@ -39,7 +41,7 @@ export const getDefaultCategoryMetadata = (): ICategoryMetadata => ({ hasDuplicateChapters: undefined, hasTrackerBinding: {}, hasStatus: {} as LibraryOptions['hasStatus'], -}); +}; const convertAppMetadataToGqlMetadata = ( metadata: Partial, @@ -60,20 +62,37 @@ const convertGqlMetadataToAppMetadata = ( }); const getCategoryMetadataWithDefaultValueFallback = ( - meta?: Metadata, - defaultMetadata: ICategoryMetadata = getDefaultCategoryMetadata(), - applyMetadataMigration: boolean = true, + meta: CategoryIdInfo & MetadataHolder, + defaultMetadata: ICategoryMetadata = DEFAULT_CATEGORY_METADATA, + useEffectFn?: typeof useEffect, ): ICategoryMetadata => convertGqlMetadataToAppMetadata( - getMetadataFrom({ meta }, convertAppMetadataToGqlMetadata(defaultMetadata), applyMetadataMigration), + getMetadataFrom('category', meta, convertAppMetadataToGqlMetadata(defaultMetadata), true, useEffectFn), + ); + +const getMetadata = ( + metaHolder: CategoryIdInfo & GqlMetaHolder, + defaultMetadata?: ICategoryMetadata, + useEffectFn?: typeof useEffect, +) => + getCategoryMetadataWithDefaultValueFallback( + { ...metaHolder, meta: convertFromGqlMeta(metaHolder.meta) }, + defaultMetadata, + useEffectFn, ); export const getCategoryMetadata = ( - { meta }: GqlMetaHolder = {}, + metaHolder: CategoryIdInfo & GqlMetaHolder, defaultMetadata?: ICategoryMetadata, - applyMetadataMigration?: boolean, -): ICategoryMetadata => - getCategoryMetadataWithDefaultValueFallback(convertFromGqlMeta(meta), defaultMetadata, applyMetadataMigration); +): ICategoryMetadata => getMetadata(metaHolder, defaultMetadata); + +export const useGetCategoryMetadata = ( + metaHolder: CategoryIdInfo & GqlMetaHolder, + defaultMetadata?: ICategoryMetadata, +): ICategoryMetadata => { + const metadata = getMetadata(metaHolder, defaultMetadata, useEffect); + return useMemo(() => metadata, [metaHolder, defaultMetadata]); +}; export const updateCategoryMetadata = async < MetadataKeys extends CategoryMetadataKeys = CategoryMetadataKeys, diff --git a/src/modules/core/components/GridLayouts.tsx b/src/modules/core/components/GridLayouts.tsx index 86132b4d..c6237c1d 100644 --- a/src/modules/core/components/GridLayouts.tsx +++ b/src/modules/core/components/GridLayouts.tsx @@ -15,7 +15,8 @@ import Tooltip from '@mui/material/Tooltip'; import React from 'react'; import ViewModuleIcon from '@mui/icons-material/ViewModule'; import { useTranslation } from 'react-i18next'; -import { GridLayout } from '@/modules/library/contexts/LibraryOptionsContext.tsx'; + +import { GridLayout } from '@/modules/core/Core.types.ts'; // TODO: clean up this to use a FormControl, and remove dependency on name o radio button export function GridLayouts({ diff --git a/src/modules/library/components/LibraryMangaGrid.tsx b/src/modules/library/components/LibraryMangaGrid.tsx index 3bf64fe6..f5f0aae3 100644 --- a/src/modules/library/components/LibraryMangaGrid.tsx +++ b/src/modules/library/components/LibraryMangaGrid.tsx @@ -8,8 +8,9 @@ import React, { useLayoutEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { GridLayout, useLibraryOptionsContext } from '@/modules/library/contexts/LibraryOptionsContext.tsx'; +import { useLibraryOptionsContext } from '@/modules/library/contexts/LibraryOptionsContext.tsx'; import { IMangaGridProps, MangaGrid } from '@/modules/manga/components/MangaGrid.tsx'; +import { GridLayout } from '@/modules/core/Core.types.ts'; interface LibraryMangaGridProps extends Required>, diff --git a/src/modules/library/components/LibraryOptionsPanel.tsx b/src/modules/library/components/LibraryOptionsPanel.tsx index 6a966f95..8125eb4a 100644 --- a/src/modules/library/components/LibraryOptionsPanel.tsx +++ b/src/modules/library/components/LibraryOptionsPanel.tsx @@ -8,19 +8,17 @@ import FormLabel from '@mui/material/FormLabel'; import RadioGroup from '@mui/material/RadioGroup'; -import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { CheckboxInput } from '@/modules/core/components/inputs/CheckboxInput.tsx'; import { RadioInput } from '@/modules/core/components/inputs/RadioInput.tsx'; import { SortRadioInput } from '@/modules/core/components/inputs/SortRadioInput.tsx'; import { ThreeStateCheckboxInput } from '@/modules/core/components/inputs/ThreeStateCheckboxInput.tsx'; -import { GridLayout } from '@/modules/library/contexts/LibraryOptionsContext.tsx'; import { OptionsTabs } from '@/modules/core/components/OptionsTabs.tsx'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { Trackers } from '@/modules/tracker/services/Trackers.ts'; import { GetTrackersSettingsQuery, MangaStatus } from '@/lib/graphql/generated/graphql.ts'; import { GET_TRACKERS_SETTINGS } from '@/lib/graphql/queries/TrackerQuery.ts'; -import { createUpdateCategoryMetadata, getCategoryMetadata } from '@/modules/category/services/CategoryMetadata.ts'; +import { createUpdateCategoryMetadata, useGetCategoryMetadata } from '@/modules/category/services/CategoryMetadata.ts'; import { makeToast } from '@/modules/core/utils/Toast.ts'; import { createUpdateMetadataServerSettings, @@ -30,6 +28,7 @@ import { TranslationKey } from '@/Base.types.ts'; import { LibrarySortMode } from '@/modules/library/Library.types.ts'; import { CategoryMetadataInfo } from '@/modules/category/Category.types.ts'; import { statusToTranslationKey } from '@/modules/manga/Manga.constants.ts'; +import { GridLayout } from '@/modules/core/Core.types.ts'; const TITLES: { [key in 'filter' | 'sort' | 'display']: TranslationKey } = { filter: 'global.label.filter', @@ -61,7 +60,7 @@ export const LibraryOptionsPanel = ({ const trackerList = requestManager.useGetTrackerList(GET_TRACKERS_SETTINGS); const loggedInTrackers = Trackers.getLoggedIn(trackerList.data?.trackers.nodes ?? []); - const categoryLibraryOptions = useMemo(() => getCategoryMetadata(category), [category]); + const categoryLibraryOptions = useGetCategoryMetadata(category); const updateCategoryLibraryOptions = createUpdateCategoryMetadata(category, () => makeToast(t('global.error.label.failed_to_save_changes', 'error')), ); diff --git a/src/modules/library/contexts/LibraryOptionsContext.tsx b/src/modules/library/contexts/LibraryOptionsContext.tsx index cf975716..009d9bf3 100644 --- a/src/modules/library/contexts/LibraryOptionsContext.tsx +++ b/src/modules/library/contexts/LibraryOptionsContext.tsx @@ -7,7 +7,7 @@ */ import React, { useContext } from 'react'; -import { getDefaultCategoryMetadata } from '@/modules/category/services/CategoryMetadata.ts'; +import { DEFAULT_CATEGORY_METADATA } from '@/modules/category/services/CategoryMetadata.ts'; import { LibraryOptions } from '@/modules/library/Library.types.ts'; type ContextType = { @@ -15,14 +15,8 @@ type ContextType = { setOptions: React.Dispatch>; }; -export enum GridLayout { - Compact = 0, - Comfortable = 1, - List = 2, -} - export const LibraryOptionsContext = React.createContext({ - options: getDefaultCategoryMetadata(), + options: DEFAULT_CATEGORY_METADATA, setOptions: () => {}, }); diff --git a/src/modules/library/contexts/LibraryOptionsProvider.tsx b/src/modules/library/contexts/LibraryOptionsProvider.tsx index bca7e67c..3eaee8d0 100644 --- a/src/modules/library/contexts/LibraryOptionsProvider.tsx +++ b/src/modules/library/contexts/LibraryOptionsProvider.tsx @@ -9,7 +9,7 @@ import React, { useMemo } from 'react'; import { useLocalStorage } from '@/modules/core/hooks/useStorage.tsx'; import { LibraryOptionsContext } from '@/modules/library/contexts/LibraryOptionsContext.tsx'; -import { getDefaultCategoryMetadata } from '@/modules/category/services/CategoryMetadata.ts'; +import { DEFAULT_CATEGORY_METADATA } from '@/modules/category/services/CategoryMetadata.ts'; import { LibraryOptions } from '@/modules/library/Library.types.ts'; interface IProps { @@ -17,10 +17,10 @@ interface IProps { } export const LibraryOptionsContextProvider: React.FC = ({ children }) => { - const [options, setOptions] = useLocalStorage('libraryOptions', getDefaultCategoryMetadata()); + const [options, setOptions] = useLocalStorage('libraryOptions', DEFAULT_CATEGORY_METADATA); const value = useMemo( - () => ({ options: { ...getDefaultCategoryMetadata(), ...options }, setOptions }), + () => ({ options: { ...DEFAULT_CATEGORY_METADATA, ...options }, setOptions }), [options, setOptions], ); diff --git a/src/modules/library/hooks/useGetVisibleLibraryMangas.ts b/src/modules/library/hooks/useGetVisibleLibraryMangas.ts index a567c005..380a22a8 100644 --- a/src/modules/library/hooks/useGetVisibleLibraryMangas.ts +++ b/src/modules/library/hooks/useGetVisibleLibraryMangas.ts @@ -11,10 +11,10 @@ import { useMemo } from 'react'; import { useMetadataServerSettings } from '@/modules/settings/services/ServerSettingsMetadata.ts'; import { ChapterType, MangaType, SourceType, TrackRecordType } from '@/lib/graphql/generated/graphql.ts'; import { enhancedCleanup } from '@/util/Strings.ts'; -import { getCategoryMetadata } from '@/modules/category/services/CategoryMetadata.ts'; +import { useGetCategoryMetadata } from '@/modules/category/services/CategoryMetadata.ts'; import { NullAndUndefined } from '@/Base.types.ts'; import { LibraryOptions, LibrarySortMode } from '@/modules/library/Library.types.ts'; -import { CategoryMetadataInfo } from '@/modules/category/Category.types.ts'; +import { CategoryIdInfo, CategoryMetadataInfo } from '@/modules/category/Category.types.ts'; import { MangaChapterCountInfo, MangaIdInfo } from '@/modules/manga/Manga.types.ts'; const triStateFilter = ( @@ -198,6 +198,7 @@ const sortManga = ( return result; }; +const DEFAULT_CATEGORY: CategoryIdInfo = { id: -1 }; export const useGetVisibleLibraryMangas = ( mangas: Manga[], category?: CategoryMetadataInfo, @@ -206,7 +207,7 @@ export const useGetVisibleLibraryMangas = { const [query] = useQueryParam('query', StringParam); - const options = getCategoryMetadata(category); + const options = useGetCategoryMetadata(category ?? DEFAULT_CATEGORY); const { hasUnreadChapters, hasDownloadedChapters, diff --git a/src/modules/library/screens/Library.tsx b/src/modules/library/screens/Library.tsx index 265b9159..6a3993c2 100644 --- a/src/modules/library/screens/Library.tsx +++ b/src/modules/library/screens/Library.tsx @@ -84,7 +84,7 @@ export function Library() { const activeTab: (typeof tabs)[number] | undefined = tabs.find((tab) => tab.order === tabSearchParam) ?? tabs[0]; useLayoutEffect(() => { - setOptions(getCategoryMetadata(activeTab)); + setOptions(getCategoryMetadata(activeTab ?? { id: -1 })); }, [activeTab]); const { @@ -169,7 +169,7 @@ export function Library() { setTitle(navBarTitle, title); setAction( <> - {!isSelectModeActive && ( + {!isSelectModeActive && activeTab && ( <> diff --git a/src/modules/library/screens/LibraryDuplicates.tsx b/src/modules/library/screens/LibraryDuplicates.tsx index 77cf4f41..d98de3aa 100644 --- a/src/modules/library/screens/LibraryDuplicates.tsx +++ b/src/modules/library/screens/LibraryDuplicates.tsx @@ -17,7 +17,6 @@ import Box from '@mui/material/Box'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { NavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; import { useLocalStorage } from '@/modules/core/hooks/useStorage.tsx'; -import { GridLayout } from '@/modules/library/contexts/LibraryOptionsContext.tsx'; import { GridLayouts } from '@/modules/core/components/GridLayouts.tsx'; import { CheckboxInput } from '@/modules/core/components/inputs/CheckboxInput.tsx'; import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx'; @@ -33,6 +32,7 @@ import { IMangaGridProps } from '@/modules/manga/components/MangaGrid.tsx'; import { StyledGroupItemWrapper } from '@/modules/core/components/virtuoso/StyledGroupItemWrapper.tsx'; import { VirtuosoUtil } from '@/lib/virtuoso/Virtuoso.util.tsx'; import { LibraryDuplicatesWorkerInput, TMangaDuplicate, TMangaDuplicates } from '@/modules/library/Library.types.ts'; +import { GridLayout } from '@/modules/core/Core.types.ts'; export const LibraryDuplicates = () => { const { t } = useTranslation(); diff --git a/src/modules/manga/Manga.types.ts b/src/modules/manga/Manga.types.ts index 765510da..fd1f3e1a 100644 --- a/src/modules/manga/Manga.types.ts +++ b/src/modules/manga/Manga.types.ts @@ -8,12 +8,12 @@ import { LongPressPointerHandlers, LongPressResult } from 'use-long-press/lib/use-long-press.types'; import { PopupState } from 'material-ui-popup-state/hooks'; -import { GridLayout } from '@/modules/library/contexts/LibraryOptionsContext.tsx'; import { SelectableCollectionReturnType } from '@/modules/collection/hooks/useSelectableCollection.ts'; import { useManageMangaLibraryState } from '@/modules/manga/hooks/useManageMangaLibraryState.tsx'; import { ChapterType, MangaReaderFieldsFragment, MangaType, TrackRecordType } from '@/lib/graphql/generated/graphql.ts'; import { SingleModeProps } from '@/modules/manga/components/MangaActionMenuItems.tsx'; import { IReaderSettings } from '@/modules/reader/Reader.types'; +import { GridLayout } from '@/modules/core/Core.types.ts'; export type MangaCardMode = 'default' | 'source' | 'migrate.search' | 'migrate.select' | 'duplicate'; diff --git a/src/modules/manga/components/MangaGrid.tsx b/src/modules/manga/components/MangaGrid.tsx index 8ad16bab..f5a3e81f 100644 --- a/src/modules/manga/components/MangaGrid.tsx +++ b/src/modules/manga/components/MangaGrid.tsx @@ -24,7 +24,6 @@ import { useTranslation } from 'react-i18next'; import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx'; import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx'; import { MangaCard } from '@/modules/manga/components/cards/MangaCard.tsx'; -import { GridLayout } from '@/modules/library/contexts/LibraryOptionsContext.tsx'; import { useLocalStorage, useSessionStorage } from '@/modules/core/hooks/useStorage.tsx'; import { SelectableCollectionReturnType } from '@/modules/collection/hooks/useSelectableCollection.ts'; import { DEFAULT_FULL_FAB_HEIGHT } from '@/modules/core/components/buttons/StyledFab.tsx'; @@ -33,6 +32,7 @@ import { MangaCardProps } from '@/modules/manga/Manga.types.ts'; import { MangaType } from '@/lib/graphql/generated/graphql.ts'; import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx'; import { useNavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; +import { GridLayout } from '@/modules/core/Core.types.ts'; const GridContainer = React.forwardRef(({ children, ...props }, ref) => ( diff --git a/src/modules/manga/components/cards/MangaCard.tsx b/src/modules/manga/components/cards/MangaCard.tsx index a8c96d6c..a638c305 100644 --- a/src/modules/manga/components/cards/MangaCard.tsx +++ b/src/modules/manga/components/cards/MangaCard.tsx @@ -9,7 +9,7 @@ import PopupState, { bindMenu } from 'material-ui-popup-state'; import { useMemo, useState } from 'react'; import { useLongPress } from 'use-long-press'; -import { GridLayout, useLibraryOptionsContext } from '@/modules/library/contexts/LibraryOptionsContext.tsx'; +import { useLibraryOptionsContext } from '@/modules/library/contexts/LibraryOptionsContext.tsx'; import { MangaActionMenuItems, SingleModeProps } from '@/modules/manga/components/MangaActionMenuItems.tsx'; import { Menu } from '@/modules/core/components/menu/Menu.tsx'; import { MigrateDialog } from '@/modules/migration/components/MigrateDialog.tsx'; @@ -19,6 +19,7 @@ import { MangaListCard } from '@/modules/manga/components/cards/MangaListCard.ts import { MangaCardMode, MangaCardProps } from '@/modules/manga/Manga.types.ts'; import { ContinueReadingButton } from '@/modules/manga/components/ContinueReadingButton.tsx'; import { MangaBadges } from '@/modules/manga/components/MangaBadges.tsx'; +import { GridLayout } from '@/modules/core/Core.types.ts'; const getMangaLinkTo = ( mode: MangaCardMode, diff --git a/src/modules/manga/components/cards/MangaGridCard.tsx b/src/modules/manga/components/cards/MangaGridCard.tsx index 06318d8a..d7f1481b 100644 --- a/src/modules/manga/components/cards/MangaGridCard.tsx +++ b/src/modules/manga/components/cards/MangaGridCard.tsx @@ -17,11 +17,11 @@ import { styled } from '@mui/material/styles'; import { useRef } from 'react'; import { SpinnerImage } from '@/modules/core/components/SpinnerImage.tsx'; import { MangaOptionButton } from '@/modules/manga/components/MangaOptionButton.tsx'; -import { GridLayout } from '@/modules/library/contexts/LibraryOptionsContext.tsx'; import { Mangas } from '@/modules/manga/services/Mangas.ts'; import { SpecificMangaCardProps } from '@/modules/manga/Manga.types.ts'; import { TypographyMaxLines } from '@/modules/core/components/TypographyMaxLines.tsx'; import { MANGA_COVER_ASPECT_RATIO } from '@/modules/manga/Manga.constants.ts'; +import { GridLayout } from '@/modules/core/Core.types.ts'; const BottomGradient = styled('div')({ position: 'absolute', diff --git a/src/modules/metadata/Metadata.constants.ts b/src/modules/metadata/Metadata.constants.ts index f9cf3c69..94b49499 100644 --- a/src/modules/metadata/Metadata.constants.ts +++ b/src/modules/metadata/Metadata.constants.ts @@ -10,6 +10,55 @@ import { AppMetadataKeys, IMetadataMigration } from '@/modules/metadata/Metadata export const APP_METADATA_KEY_PREFIX = 'webUI_'; +const APP_METADATA_OBJECT: Record = { + staticNav: undefined, + showPageNumber: undefined, + loadNextOnEnding: undefined, + skipDupChapters: undefined, + fitPageToWindow: undefined, + scalePage: undefined, + readerType: undefined, + offsetFirstPage: undefined, + migration: undefined, + deleteChaptersManuallyMarkedRead: undefined, + deleteChaptersWhileReading: undefined, + deleteChaptersWithBookmark: undefined, + downloadAheadLimit: undefined, + showAddToLibraryCategorySelectDialog: undefined, + ignoreFilters: undefined, + removeMangaFromCategories: undefined, + showTabSize: undefined, + devices: undefined, + migrateChapters: undefined, + migrateCategories: undefined, + migrateTracking: undefined, + deleteChapters: undefined, + migrateSortSettings: undefined, + hideLibraryEntries: undefined, + updateProgressAfterReading: undefined, + updateProgressManualMarkRead: undefined, + webUIInformAvailableUpdate: undefined, + serverInformAvailableUpdate: undefined, + readerWidth: undefined, + savedSearches: undefined, + showContinueReadingButton: undefined, + showDownloadBadge: undefined, + showUnreadBadge: undefined, + gridLayout: undefined, + sortBy: undefined, + sortDesc: undefined, + hasDownloadedChapters: undefined, + hasBookmarkedChapters: undefined, + hasUnreadChapters: undefined, + hasDuplicateChapters: undefined, + hasTrackerBinding: undefined, + hasStatus: undefined, + customThemes: undefined, + mangaThumbnailBackdrop: undefined, +}; + +export const VALID_APP_METADATA_KEYS = Object.keys(APP_METADATA_OBJECT); + export const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = [ // downloads 'deleteChaptersManuallyMarkedRead', diff --git a/src/modules/metadata/Metadata.types.ts b/src/modules/metadata/Metadata.types.ts index 557820d9..22cfc39a 100644 --- a/src/modules/metadata/Metadata.types.ts +++ b/src/modules/metadata/Metadata.types.ts @@ -38,7 +38,12 @@ export type MetadataHolder = { export type AllowedMetadataValueTypes = string | boolean | number | undefined | null; +interface MetadataAppliedMigration { + migration: number; +} + export type AppMetadataKeys = + | keyof MetadataAppliedMigration | MetadataServerSettingKeys | MangaMetadataKeys | SearchMetadataKeys @@ -46,3 +51,5 @@ export type AppMetadataKeys = | CategoryMetadataKeys; export type MetadataKeyValuePair = [AppMetadataKeys, AllowedMetadataValueTypes]; + +export type MetadataHolderType = 'manga' | 'chapter' | 'category' | 'global' | 'source'; diff --git a/src/modules/metadata/services/MetadataApolloCacheHandler.ts b/src/modules/metadata/services/MetadataApolloCacheHandler.ts new file mode 100644 index 00000000..8e2f7c88 --- /dev/null +++ b/src/modules/metadata/services/MetadataApolloCacheHandler.ts @@ -0,0 +1,33 @@ +/* + * 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 { ReadFieldFunction } from '@apollo/client/cache/core/types/common'; +import { Reference } from '@apollo/client/utilities'; + +export const updateMetadataList = ( + key: string, + existingMetas: Reference[] | undefined, + readField: ReadFieldFunction, + createMetaRef: () => Reference | undefined, + deleted: boolean = false, +): (Reference | undefined)[] | undefined => { + if (!existingMetas) { + return existingMetas; + } + + if (deleted) { + return existingMetas.filter((metaRef: Reference) => readField('key', metaRef) !== key); + } + + const exists = existingMetas.some((metaRef: Reference) => readField('key', metaRef) === key); + if (exists) { + return existingMetas; + } + + return [...existingMetas, createMetaRef()]; +}; diff --git a/src/modules/metadata/services/MetadataMigrations.ts b/src/modules/metadata/services/MetadataMigrations.ts index d3793c05..fc68ae47 100644 --- a/src/modules/metadata/services/MetadataMigrations.ts +++ b/src/modules/metadata/services/MetadataMigrations.ts @@ -111,7 +111,8 @@ export const applyMetadataMigrations = (meta?: Metadata): Metadata | undefined = return undefined; } - const migrationToMetadata: [number, Metadata][] = [[0, meta]]; + const appliedMigrationId = Number.isNaN(Number(meta.migration)) ? 0 : Math.max(0, Number(meta.migration) - 1); + const migrationToMetadata: [number, Metadata][] = [[appliedMigrationId, meta]]; METADATA_MIGRATIONS.forEach((migration, index) => { const migrationId = index + 1; diff --git a/src/modules/metadata/services/MetadataReader.ts b/src/modules/metadata/services/MetadataReader.ts index 2f60fe01..56f6135d 100644 --- a/src/modules/metadata/services/MetadataReader.ts +++ b/src/modules/metadata/services/MetadataReader.ts @@ -6,16 +6,43 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { APP_METADATA_KEY_PREFIX, GLOBAL_METADATA_KEYS } from '@/modules/metadata/Metadata.constants.ts'; +import { useEffect } from 'react'; +import { + APP_METADATA_KEY_PREFIX, + GLOBAL_METADATA_KEYS, + METADATA_MIGRATIONS, + VALID_APP_METADATA_KEYS, +} from '@/modules/metadata/Metadata.constants.ts'; import { DEFAULT_DEVICE, getActiveDevice } from '@/modules/device/services/Device.ts'; import { applyMetadataMigrations } from '@/modules/metadata/services/MetadataMigrations.ts'; -import { convertValueFromMetadata } from '@/modules/metadata/services/MetadataConverter.ts'; +import { convertToGqlMeta, convertValueFromMetadata } from '@/modules/metadata/services/MetadataConverter.ts'; import { AllowedMetadataValueTypes, AppMetadataKeys, Metadata, MetadataHolder, + MetadataHolderType, + MetadataKeyValuePair, } from '@/modules/metadata/Metadata.types.ts'; +import { MangaIdInfo } from '@/modules/manga/Manga.types.ts'; +import { + requestDeleteCategoryMetadata, + requestDeleteChapterMetadata, + requestDeleteMangaMetadata, + requestDeleteServerMetadata, + requestDeleteSourceMetadata, + requestUpdateCategoryMetadata, + requestUpdateChapterMetadata, + requestUpdateMangaMetadata, + requestUpdateServerMetadata, + requestUpdateSourceMetadata, +} from '@/modules/metadata/services/MetadataUpdater.ts'; +import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; +import { ChapterIdInfo } from '@/modules/chapter/services/Chapters.ts'; +import { CategoryIdInfo } from '@/modules/category/Category.types.ts'; +import { SourceType } from '@/lib/graphql/generated/graphql.ts'; + +export const extractOriginalKey = (key: string): string => key.split('_').slice(-1)[0]; export const getMetadataKey = (key: string, appPrefix: string = APP_METADATA_KEY_PREFIX) => { const isGlobalMetadataKey = GLOBAL_METADATA_KEYS.includes(key as AppMetadataKeys); @@ -33,7 +60,9 @@ export const getMetadataValueFrom = { - const metadata = applyMigrations ? applyMetadataMigrations(meta) : meta; + const requiresMigration = Number(meta?.migration) !== METADATA_MIGRATIONS.length; + const doMigration = requiresMigration && applyMigrations; + const metadata = doMigration ? applyMetadataMigrations(meta) : meta; if ( metadata === undefined || @@ -46,24 +75,187 @@ export const getMetadataValueFrom = >>( - { meta }: MetadataHolder, +const getMetadataUpdateFunction = ( + type: MetadataHolderType, + metadataHolder: + | MetadataHolder + | (MangaIdInfo & MetadataHolder) + | (ChapterIdInfo & MetadataHolder) + | (CategoryIdInfo & MetadataHolder) + | (Pick & MetadataHolder), +): ((appliedMigration: [MetadataKeyValuePair]) => Promise) => { + switch (type) { + case 'global': + return (appliedMigration) => requestUpdateServerMetadata(appliedMigration); + case 'manga': + return (appliedMigration) => + requestUpdateMangaMetadata( + { id: (metadataHolder as MangaIdInfo).id, meta: convertToGqlMeta(metadataHolder.meta) }, + appliedMigration, + ); + case 'chapter': + return (appliedMigration) => + requestUpdateChapterMetadata( + { id: (metadataHolder as ChapterIdInfo).id, meta: convertToGqlMeta(metadataHolder.meta) }, + appliedMigration, + ); + case 'category': + return (appliedMigration) => + requestUpdateCategoryMetadata( + { id: (metadataHolder as CategoryIdInfo).id, meta: convertToGqlMeta(metadataHolder.meta) }, + appliedMigration, + ); + case 'source': + return (appliedMigration) => + requestUpdateSourceMetadata( + { + id: (metadataHolder as Pick).id, + meta: convertToGqlMeta(metadataHolder.meta), + }, + appliedMigration, + ); + default: + throw new Error(`Unexpected "type" (${type})`); + } +}; + +const getMetadataDeleteFunction = ( + type: MetadataHolderType, + metadataHolder: + | MetadataHolder + | (MangaIdInfo & MetadataHolder) + | (ChapterIdInfo & MetadataHolder) + | (CategoryIdInfo & MetadataHolder) + | (Pick & MetadataHolder), +): ((metadataToDelete: AppMetadataKeys[]) => Promise) => { + switch (type) { + case 'global': + return (appliedMigration) => requestDeleteServerMetadata(appliedMigration); + case 'manga': + return (appliedMigration) => + requestDeleteMangaMetadata( + { id: (metadataHolder as MangaIdInfo).id, meta: convertToGqlMeta(metadataHolder.meta) }, + appliedMigration, + ); + case 'chapter': + return (appliedMigration) => + requestDeleteChapterMetadata( + { id: (metadataHolder as ChapterIdInfo).id, meta: convertToGqlMeta(metadataHolder.meta) }, + appliedMigration, + ); + case 'category': + return (appliedMigration) => + requestDeleteCategoryMetadata( + { id: (metadataHolder as CategoryIdInfo).id, meta: convertToGqlMeta(metadataHolder.meta) }, + appliedMigration, + ); + case 'source': + return (appliedMigration) => + requestDeleteSourceMetadata( + { + id: (metadataHolder as Pick).id, + meta: convertToGqlMeta(metadataHolder.meta), + }, + appliedMigration, + ); + default: + throw new Error(`Unexpected "type" (${type})`); + } +}; + +/** + * Prevent spamming requests due to frequent metadata reads while the migration hasn't been commited to the server yet + */ +const commitedMigrations = new Set(); + +export function getMetadataFrom>>( + type: 'global', + metadataHolder: MetadataHolder, metadataWithDefaultValues: METADATA, applyMigrations?: boolean, -): METADATA => { + useEffectFn?: typeof useEffect, +): METADATA; +export function getMetadataFrom>>( + type: 'manga', + metadataHolder: MangaIdInfo & MetadataHolder, + metadataWithDefaultValues: METADATA, + applyMigrations?: boolean, + useEffectFn?: typeof useEffect, +): METADATA; +export function getMetadataFrom>>( + type: 'chapter', + metadataHolder: ChapterIdInfo & MetadataHolder, + metadataWithDefaultValues: METADATA, + applyMigrations?: boolean, + useEffectFn?: typeof useEffect, +): METADATA; +export function getMetadataFrom>>( + type: 'category', + metadataHolder: CategoryIdInfo & MetadataHolder, + metadataWithDefaultValues: METADATA, + applyMigrations?: boolean, + useEffectFn?: typeof useEffect, +): METADATA; +export function getMetadataFrom>>( + type: 'source', + metadataHolder: Pick & MetadataHolder, + metadataWithDefaultValues: METADATA, + applyMigrations?: boolean, + useEffectFn?: typeof useEffect, +): METADATA; +export function getMetadataFrom>>( + type: MetadataHolderType, + metadataHolder: + | MetadataHolder + | (MangaIdInfo & MetadataHolder) + | (ChapterIdInfo & MetadataHolder) + | (CategoryIdInfo & MetadataHolder) + | (Pick & MetadataHolder), + metadataWithDefaultValues: METADATA, + applyMigrations?: boolean, + useEffectFn: typeof useEffect = (fn: () => void) => fn(), +): METADATA { + const wasMigrated = + !!metadataHolder?.meta && + !!applyMigrations && + Number(getMetadataValueFrom(metadataHolder, 'migration')) !== METADATA_MIGRATIONS.length; const appMetadata = {} as METADATA; Object.entries(metadataWithDefaultValues).forEach(([key, defaultValue]) => { appMetadata[key as AppMetadataKeys] = getMetadataValueFrom( - { meta }, + metadataHolder, key as AppMetadataKeys, defaultValue, applyMigrations, ); }); + useEffectFn(() => { + const itemMigrationKey = `${type}_${type === 'global' ? '' : (metadataHolder as { id: any }).id}`; + const commitMigration = !commitedMigrations.has(itemMigrationKey); + if (wasMigrated && commitMigration) { + const metadataToDelete = Object.keys(metadataHolder.meta ?? {}) + .map(extractOriginalKey) + .filter((key) => !VALID_APP_METADATA_KEYS.includes(key)) as AppMetadataKeys[]; + + commitedMigrations.add(itemMigrationKey); + + getMetadataDeleteFunction( + type, + metadataHolder, + )(metadataToDelete) + .then(() => + getMetadataUpdateFunction(type, metadataHolder)([['migration', METADATA_MIGRATIONS.length]]), + ) + .catch((error) => { + defaultPromiseErrorHandler(`MetadataReader#getMetadataFromServer`)(error); + commitedMigrations.delete(itemMigrationKey); + }); + } + }); + return appMetadata; -}; +} export const getAppMetadataFrom = (meta: Metadata, appPrefix: string = APP_METADATA_KEY_PREFIX): Metadata => { const appMetadata: Metadata = {}; diff --git a/src/modules/metadata/services/MetadataUpdater.ts b/src/modules/metadata/services/MetadataUpdater.ts index 66430b0c..8d0fc5a6 100644 --- a/src/modules/metadata/services/MetadataUpdater.ts +++ b/src/modules/metadata/services/MetadataUpdater.ts @@ -15,12 +15,11 @@ import { AllowedMetadataValueTypes, AppMetadataKeys, GqlMetaHolder, + MetadataHolderType, MetadataKeyValuePair, } from '@/modules/metadata/Metadata.types.ts'; import { MangaIdInfo } from '@/modules/manga/Manga.types.ts'; -type MetadataHolderType = 'manga' | 'chapter' | 'category' | 'global' | 'source'; - export const requestUpdateMetadataValue = async ( metadataHolder: GqlMetaHolder, holderType: MetadataHolderType, @@ -80,3 +79,62 @@ export const requestUpdateSourceMetadata = async ( source: Pick & GqlMetaHolder, keysToValue: MetadataKeyValuePair[], ): Promise => requestUpdateMetadata(source, 'source', keysToValue); + +export const requestDeleteMetadataValue = async ( + metadataHolder: GqlMetaHolder, + holderType: MetadataHolderType, + key: AppMetadataKeys, +): Promise => { + const metadataKey = getMetadataKey(key); + + switch (holderType) { + case 'category': + await requestManager.deleteCategoryMeta((metadataHolder as CategoryIdInfo).id, metadataKey).response; + break; + case 'chapter': + await requestManager.deleteChapterMeta((metadataHolder as ChapterIdInfo).id, metadataKey).response; + break; + case 'global': + await requestManager.deleteGlobalMeta(metadataKey).response; + break; + case 'manga': + await requestManager.deleteMangaMeta((metadataHolder as MangaIdInfo).id, metadataKey).response; + break; + case 'source': + await requestManager.deleteSourceMeta((metadataHolder as Pick).id, metadataKey).response; + break; + default: + throw new Error(`requestDeleteMetadataValue: unknown holderType "${holderType}"`); + } +}; + +export async function requestDeleteMetadata( + metadataHolder: GqlMetaHolder, + holderType: MetadataHolderType, + keys: AppMetadataKeys[], +): Promise { + return Promise.all(keys.map((key) => requestDeleteMetadataValue(metadataHolder, holderType, key))); +} + +export const requestDeleteServerMetadata = async (keys: AppMetadataKeys[]): Promise => + requestDeleteMetadata({}, 'global', keys); + +export const requestDeleteMangaMetadata = async ( + manga: MangaIdInfo & GqlMetaHolder, + keys: AppMetadataKeys[], +): Promise => requestDeleteMetadata(manga, 'manga', keys); + +export const requestDeleteChapterMetadata = async ( + chapter: ChapterIdInfo & GqlMetaHolder, + keys: AppMetadataKeys[], +): Promise => requestDeleteMetadata(chapter, 'chapter', keys); + +export const requestDeleteCategoryMetadata = async ( + category: CategoryIdInfo & GqlMetaHolder, + keys: AppMetadataKeys[], +): Promise => requestDeleteMetadata(category, 'category', keys); + +export const requestDeleteSourceMetadata = async ( + source: Pick & GqlMetaHolder, + keys: AppMetadataKeys[], +): Promise => requestDeleteMetadata(source, 'source', keys); diff --git a/src/modules/migration/screens/Migrate.tsx b/src/modules/migration/screens/Migrate.tsx index 617dd99f..c4ba766b 100644 --- a/src/modules/migration/screens/Migrate.tsx +++ b/src/modules/migration/screens/Migrate.tsx @@ -16,12 +16,12 @@ import { LoadingPlaceholder } from '@/modules/core/components/placeholder/Loadin import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx'; import { GridLayouts } from '@/modules/core/components/GridLayouts.tsx'; import { useLocalStorage } from '@/modules/core/hooks/useStorage.tsx'; -import { GridLayout } from '@/modules/library/contexts/LibraryOptionsContext.tsx'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { GetSourceMigratableQuery, GetSourceMigratableQueryVariables } from '@/lib/graphql/generated/graphql.ts'; import { GET_SOURCE_MIGRATABLE } from '@/lib/graphql/queries/SourceQuery.ts'; import { SOURCE_BASE_FIELDS } from '@/lib/graphql/fragments/SourceFragments.ts'; import { BaseMangaGrid } from '@/modules/manga/components/BaseMangaGrid.tsx'; +import { GridLayout } from '@/modules/core/Core.types.ts'; export const Migrate = () => { const { t } = useTranslation(); diff --git a/src/modules/reader/Reader.types.ts b/src/modules/reader/Reader.types.ts index 176332fe..2cb99876 100644 --- a/src/modules/reader/Reader.types.ts +++ b/src/modules/reader/Reader.types.ts @@ -32,10 +32,6 @@ export interface IReaderSettings { readerWidth: number; } -export type UndefinedReaderSettings = { - [setting in keyof IReaderSettings]: IReaderSettings[setting] | undefined; -}; - export interface IReaderPage { index: number; src: string; diff --git a/src/modules/reader/screens/DefaultReaderSettings.tsx b/src/modules/reader/screens/DefaultReaderSettings.tsx index 4b131fe7..3e6c925f 100644 --- a/src/modules/reader/screens/DefaultReaderSettings.tsx +++ b/src/modules/reader/screens/DefaultReaderSettings.tsx @@ -9,19 +9,14 @@ import { useContext, useLayoutEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { requestUpdateServerMetadata } from '@/modules/metadata/services/MetadataUpdater.ts'; -import { - checkAndHandleMissingStoredReaderSettings, - useDefaultReaderSettings, -} from '@/modules/reader/services/ReaderSettingsMetadata.ts'; +import { useDefaultReaderSettings } from '@/modules/reader/services/ReaderSettingsMetadata.ts'; import { ReaderSettingsOptions } from '@/modules/reader/components/ReaderSettingsOptions.tsx'; import { makeToast } from '@/modules/core/utils/Toast.ts'; import { NavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx'; import { LoadingPlaceholder } from '@/modules/core/components/placeholder/LoadingPlaceholder.tsx'; -import { DEFAULT_READER_SETTINGS } from '@/modules/reader/Reader.constants.ts'; import { IReaderSettings } from '@/modules/reader/Reader.types.ts'; -import { convertToGqlMeta } from '@/modules/metadata/services/MetadataConverter.ts'; import { AllowedMetadataValueTypes } from '@/modules/metadata/Metadata.types.ts'; export function DefaultReaderSettings() { @@ -38,7 +33,6 @@ export function DefaultReaderSettings() { }, [t]); const { - metadata, settings, loading, request: { error, refetch }, @@ -66,12 +60,6 @@ export function DefaultReaderSettings() { ); } - checkAndHandleMissingStoredReaderSettings( - { meta: convertToGqlMeta(metadata)! }, - 'server', - DEFAULT_READER_SETTINGS, - ).catch(defaultPromiseErrorHandler('DefaultReaderSettings::checkAndHandleMissingStoredReaderSettings')); - return ( { if (!areDefaultSettingsLoading && !isMangaLoading) { - checkAndHandleMissingStoredReaderSettings(manga, 'manga', defaultSettings).catch( - defaultPromiseErrorHandler('Reader::checkAndHandleMissingStoredReaderSettings'), - ); setSettings(getReaderSettingsFor(manga, defaultSettings)); } }, [areDefaultSettingsLoading, isMangaLoading]); diff --git a/src/modules/reader/services/ReaderSettingsMetadata.ts b/src/modules/reader/services/ReaderSettingsMetadata.ts index fde0cca1..211ca26f 100644 --- a/src/modules/reader/services/ReaderSettingsMetadata.ts +++ b/src/modules/reader/services/ReaderSettingsMetadata.ts @@ -6,37 +6,69 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { requestManager } from '@/lib/requests/RequestManager.ts'; -import { - requestUpdateMangaMetadata, - requestUpdateServerMetadata, -} from '@/modules/metadata/services/MetadataUpdater.ts'; -import { MetaType } from '@/lib/graphql/generated/graphql.ts'; import { DEFAULT_READER_SETTINGS } from '@/modules/reader/Reader.constants.ts'; -import { IReaderSettings, UndefinedReaderSettings } from '@/modules/reader/Reader.types.ts'; +import { IReaderSettings } from '@/modules/reader/Reader.types.ts'; import { convertFromGqlMeta } from '@/modules/metadata/services/MetadataConverter.ts'; import { getMetadataFrom } from '@/modules/metadata/services/MetadataReader.ts'; -import { GqlMetaHolder, Metadata, MetadataKeyValuePair } from '@/modules/metadata/Metadata.types.ts'; +import { GqlMetaHolder, Metadata, MetadataHolder, MetadataHolderType } from '@/modules/metadata/Metadata.types.ts'; import { MangaIdInfo } from '@/modules/manga/Manga.types.ts'; -const getReaderSettingsWithDefaultValueFallback = ( - meta?: Metadata, +function getReaderSettingsWithDefaultValueFallback( + type: 'global', + metadataHolder: MetadataHolder, + defaultSettings?: DefaultSettings, + useEffectFn?: typeof useEffect, +): DefaultSettings; +function getReaderSettingsWithDefaultValueFallback( + type: 'manga', + metadataHolder: MangaIdInfo & MetadataHolder, + defaultSettings?: DefaultSettings, + useEffectFn?: typeof useEffect, +): DefaultSettings; +function getReaderSettingsWithDefaultValueFallback( + type: Extract, + metadataHolder: (MangaIdInfo & MetadataHolder) | MetadataHolder, defaultSettings: DefaultSettings = DEFAULT_READER_SETTINGS as DefaultSettings, - applyMetadataMigration: boolean = true, -): DefaultSettings => getMetadataFrom({ meta }, defaultSettings, applyMetadataMigration); + useEffectFn?: typeof useEffect, +): DefaultSettings { + return getMetadataFrom( + type as Parameters[0], + metadataHolder as Parameters[1], + defaultSettings, + true, + useEffectFn, + ); +} -export const getReaderSettingsFromMetadata = ( - meta?: Metadata, +const getSettings = ( + metaHolder: MangaIdInfo & GqlMetaHolder, defaultSettings?: IReaderSettings, - applyMetadataMigration?: boolean, -): IReaderSettings => getReaderSettingsWithDefaultValueFallback(meta, defaultSettings, applyMetadataMigration); + useEffectFn?: typeof useEffect, +) => + getReaderSettingsWithDefaultValueFallback( + 'manga', + { + ...metaHolder, + meta: convertFromGqlMeta(metaHolder.meta), + }, + defaultSettings, + useEffectFn, + ); export const getReaderSettingsFor = ( - { meta }: GqlMetaHolder = {}, - defaultSettings?: IReaderSettings, - applyMetadataMigration?: boolean, -): IReaderSettings => getReaderSettingsFromMetadata(convertFromGqlMeta(meta), defaultSettings, applyMetadataMigration); + metaHolder: MangaIdInfo & GqlMetaHolder, + defaultSettings: IReaderSettings, +): IReaderSettings => getSettings(metaHolder, defaultSettings); + +export const useGetReaderSettingsFor = ( + metaHolder: MangaIdInfo & GqlMetaHolder, + defaultSettings: IReaderSettings, +): IReaderSettings => { + const settings = getSettings(metaHolder, defaultSettings, useEffect); + return useMemo(() => settings, [metaHolder, defaultSettings]); +}; export const useDefaultReaderSettings = (): { metadata?: Metadata; @@ -47,7 +79,9 @@ export const useDefaultReaderSettings = (): { const request = requestManager.useGetGlobalMeta({ notifyOnNetworkStatusChange: true }); const { data, loading } = request; const metadata = useMemo(() => convertFromGqlMeta(data?.metas.nodes), [data?.metas.nodes]); - const settings = useMemo(() => getReaderSettingsWithDefaultValueFallback(metadata), [metadata]); + const metaHolder: MetadataHolder = useMemo(() => ({ meta: metadata }), [metadata]); + const tmpSettings = getReaderSettingsWithDefaultValueFallback('global', metaHolder, undefined, useEffect); + const settings = useMemo(() => tmpSettings, [metaHolder]); return useMemo( () => ({ @@ -59,55 +93,3 @@ export const useDefaultReaderSettings = (): { [metadata, settings, loading, request], ); }; - -/** - * Saves all missing reader settings from the metadata to the server - * - * @param metadataHolder - * @param metadataHolderType - * @param defaultSettings - */ -export const checkAndHandleMissingStoredReaderSettings = async ( - metadataHolder: Required | MetaType[], - metadataHolderType: 'manga' | 'server', - defaultSettings: IReaderSettings, -): Promise => { - const getMeta = () => (Array.isArray(metadataHolder) ? metadataHolder : metadataHolder.meta); - const meta = convertFromGqlMeta(getMeta())!; - const settingsToCheck = getReaderSettingsWithDefaultValueFallback( - meta, - { - staticNav: undefined, - showPageNumber: undefined, - loadNextOnEnding: undefined, - skipDupChapters: undefined, - fitPageToWindow: undefined, - scalePage: undefined, - readerType: undefined, - offsetFirstPage: undefined, - readerWidth: undefined, - }, - false, - ); - const newSettings = getReaderSettingsFor({ meta: getMeta() }, defaultSettings); - - const undefinedSettings = Object.entries(settingsToCheck).filter((setting) => setting[1] === undefined); - - const settingsToUpdate: MetadataKeyValuePair[] = []; - undefinedSettings.forEach((setting) => { - const key = setting[0] as keyof IReaderSettings; - - settingsToUpdate.push([key, newSettings[key]]); - }); - - if (!undefinedSettings.length) { - return; - } - - if (metadataHolderType === 'manga') { - await requestUpdateMangaMetadata(metadataHolder as MangaIdInfo & GqlMetaHolder, settingsToUpdate); - return; - } - - await requestUpdateServerMetadata(settingsToUpdate); -}; diff --git a/src/modules/settings/services/ServerSettingsMetadata.ts b/src/modules/settings/services/ServerSettingsMetadata.ts index d3ffded7..56ca9c52 100644 --- a/src/modules/settings/services/ServerSettingsMetadata.ts +++ b/src/modules/settings/services/ServerSettingsMetadata.ts @@ -6,7 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { useMemo } from 'react'; +import { useEffect, useMemo } from 'react'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { requestUpdateServerMetadata } from '@/modules/metadata/services/MetadataUpdater.ts'; import { jsonSaveParse } from '@/lib/HelperFunctions.ts'; @@ -48,10 +48,10 @@ export const convertMetadataToSettings = ( const getMetadataServerSettingsWithDefaultFallback = ( meta?: Metadata, defaultSettings: MetadataServerSettings = SERVER_SETTINGS_METADATA_DEFAULT, - applyMetadataMigration: boolean = true, + useEffectFn?: typeof useEffect, ): MetadataServerSettings => convertMetadataToSettings( - getMetadataFrom({ meta }, convertSettingsToMetadata(defaultSettings), applyMetadataMigration), + getMetadataFrom('global', { meta }, convertSettingsToMetadata(defaultSettings), true, useEffectFn), ); export const useMetadataServerSettings = (): { metadata?: Metadata; @@ -62,7 +62,8 @@ export const useMetadataServerSettings = (): { const request = requestManager.useGetGlobalMeta({ notifyOnNetworkStatusChange: true }); const { data, loading } = request; const metadata = useMemo(() => convertFromGqlMeta(data?.metas.nodes), [data?.metas.nodes]); - const settings = useMemo(() => getMetadataServerSettingsWithDefaultFallback(metadata), [metadata]); + const tmpSettings = getMetadataServerSettingsWithDefaultFallback(metadata, undefined, useEffect); + const settings = useMemo(() => tmpSettings, [metadata]); return useMemo(() => ({ metadata, settings, loading, request }), [metadata, settings, loading, request]); }; diff --git a/src/modules/source/components/SourceGridLayout.tsx b/src/modules/source/components/SourceGridLayout.tsx index b1928645..c7b0f4b9 100644 --- a/src/modules/source/components/SourceGridLayout.tsx +++ b/src/modules/source/components/SourceGridLayout.tsx @@ -6,9 +6,9 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { GridLayout } from '@/modules/library/contexts/LibraryOptionsContext.tsx'; import { GridLayouts } from '@/modules/core/components/GridLayouts.tsx'; import { useLocalStorage } from '@/modules/core/hooks/useStorage.tsx'; +import { GridLayout } from '@/modules/core/Core.types.ts'; export function SourceGridLayout() { const [sourceGridLayout, setSourceGridLayout] = useLocalStorage('source-grid-layout', GridLayout.Compact); diff --git a/src/modules/source/screens/SourceMangas.tsx b/src/modules/source/screens/SourceMangas.tsx index b8ba5fa9..7263fac1 100644 --- a/src/modules/source/screens/SourceMangas.tsx +++ b/src/modules/source/screens/SourceMangas.tsx @@ -25,7 +25,6 @@ import { AbortableApolloUseMutationPaginatedResponse, SPECIAL_ED_SOURCES, } from '@/lib/requests/RequestManager.ts'; -import { GridLayout } from '@/modules/library/contexts/LibraryOptionsContext.tsx'; import { SourceGridLayout } from '@/modules/source/components/SourceGridLayout.tsx'; import { AppbarSearch } from '@/modules/core/components/AppbarSearch.tsx'; import { SourceOptions } from '@/modules/source/components/SourceOptions.tsx'; @@ -35,13 +34,14 @@ import { GetSourceBrowseQueryVariables, GetSourceMangasFetchMutation, GetSourceMangasFetchMutationVariables, + SourceType, } from '@/lib/graphql/generated/graphql.ts'; import { NavBarContext } from '@/modules/navigation-bar/contexts/NavbarContext.tsx'; import { useMetadataServerSettings } from '@/modules/settings/services/ServerSettingsMetadata.ts'; import { useLocalStorage, useSessionStorage } from '@/modules/core/hooks/useStorage.tsx'; import { AppStorage } from '@/lib/storage/AppStorage.ts'; import { getGridSnapshotKey } from '@/modules/manga/components/MangaGrid.tsx'; -import { createUpdateSourceMetadata, getSourceMetadata } from '@/modules/source/services/SourceMetadata.ts'; +import { createUpdateSourceMetadata, useGetSourceMetadata } from '@/modules/source/services/SourceMetadata.ts'; import { makeToast } from '@/modules/core/utils/Toast.ts'; import { GET_SOURCE_BROWSE } from '@/lib/graphql/queries/SourceQuery.ts'; import { TranslationKey } from '@/Base.types.ts'; @@ -50,6 +50,9 @@ import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts' import { EmptyView } from '@/modules/core/components/placeholder/EmptyView.tsx'; import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder/EmptyViewAbsoluteCentered.tsx'; import { MangaIdInfo } from '@/modules/manga/Manga.types.ts'; +import { GridLayout } from '@/modules/core/Core.types.ts'; + +const DEFAULT_SOURCE: Pick = { id: '-1' }; const ContentTypeMenu = styled('div')(({ theme }) => ({ display: 'flex', @@ -285,8 +288,8 @@ export function SourceMangas() { const source = sourceData?.source; const filters = source?.filters ?? []; - const { savedSearches = {} } = useMemo(() => getSourceMetadata(source), [source, source?.meta]); - const updateSourceMetadata = createUpdateSourceMetadata<'savedSearches'>(source ?? { id: sourceId }, () => + const { savedSearches = {} } = useGetSourceMetadata(source ?? DEFAULT_SOURCE); + const updateSourceMetadata = createUpdateSourceMetadata<'savedSearches'>(source ?? { id: '-1' }, () => makeToast(t('global.error.label.failed_to_save_changes'), 'error'), ); diff --git a/src/modules/source/services/SourceMetadata.ts b/src/modules/source/services/SourceMetadata.ts index 06417e20..8e074372 100644 --- a/src/modules/source/services/SourceMetadata.ts +++ b/src/modules/source/services/SourceMetadata.ts @@ -6,6 +6,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { useEffect, useMemo } from 'react'; import { jsonSaveParse } from '@/lib/HelperFunctions.ts'; import { requestUpdateSourceMetadata } from '@/modules/metadata/services/MetadataUpdater.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; @@ -20,6 +21,10 @@ import { Metadata, } from '@/modules/metadata/Metadata.types.ts'; +const DEFAULT_SOURCE_METADATA: ISourceMetadata = { + savedSearches: undefined, +}; + const convertAppMetadataToGqlMetadata = ( metadata: Partial, ): Metadata => ({ @@ -27,18 +32,35 @@ const convertAppMetadataToGqlMetadata = ( savedSearches: metadata.savedSearches ? JSON.stringify(metadata.savedSearches) : undefined, }); -export const convertGqlMetadataToAppMetadata = ( +const convertGqlMetadataToAppMetadata = ( metadata: Partial>, ): ISourceMetadata => ({ ...(metadata as unknown as ISourceMetadata), savedSearches: jsonSaveParse(metadata.savedSearches as string) ?? undefined, }); -export const getSourceMetadata = ({ meta }: GqlMetaHolder = {}, applyMetadataMigration?: boolean): ISourceMetadata => +const getMetadata = ( + metaHolder: Pick & GqlMetaHolder, + useEffectFn?: typeof useEffect, +): ISourceMetadata => convertGqlMetadataToAppMetadata( - getMetadataFrom({ meta: convertFromGqlMeta(meta) }, { savedSearches: undefined }, applyMetadataMigration), + getMetadataFrom( + 'source', + { ...metaHolder, meta: convertFromGqlMeta(metaHolder.meta) }, + convertAppMetadataToGqlMetadata(DEFAULT_SOURCE_METADATA), + true, + useEffectFn, + ), ); +export const getSourceMetadata = (metaHolder: Pick & GqlMetaHolder): ISourceMetadata => + getMetadata(metaHolder); + +export const useGetSourceMetadata = (metaHolder: Pick & GqlMetaHolder): ISourceMetadata => { + const metadata = getMetadata(metaHolder, useEffect); + return useMemo(() => metadata, [metaHolder]); +}; + export const updateSourceMetadata = async < MetadataKeys extends SourceMetadataKeys = SourceMetadataKeys, MetadataKey extends MetadataKeys = MetadataKeys,