/* * 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 { useEffect } from 'react'; import { METADATA_MIGRATIONS, VALID_APP_METADATA_KEYS } from '@/modules/metadata/Metadata.constants.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'; import { doesMetadataKeyExistIn, extractOriginalKey, getMetadataKey } from '@/modules/metadata/Metadata.utils.ts'; import { applyMetadataMigrations } from '@/modules/metadata/services/MetadataMigrations.ts'; const getMetadataValueFrom = ( metadata: Metadata | undefined, key: Key, defaultValue?: Value, prefixes?: string[], ): Value | undefined => { if ( metadata === undefined || !doesMetadataKeyExistIn(metadata, key, prefixes) || metadata[getMetadataKey(key, prefixes)] === undefined ) { return defaultValue; } return convertValueFromMetadata(metadata[getMetadataKey(key, prefixes)]); }; 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, prefixes?: string[], useEffectFn?: typeof useEffect, ): METADATA; export function getMetadataFrom>>( type: 'manga', metadataHolder: MangaIdInfo & MetadataHolder, metadataWithDefaultValues: METADATA, prefixes?: string[], useEffectFn?: typeof useEffect, ): METADATA; export function getMetadataFrom>>( type: 'chapter', metadataHolder: ChapterIdInfo & MetadataHolder, metadataWithDefaultValues: METADATA, prefixes?: string[], useEffectFn?: typeof useEffect, ): METADATA; export function getMetadataFrom>>( type: 'category', metadataHolder: CategoryIdInfo & MetadataHolder, metadataWithDefaultValues: METADATA, prefixes?: string[], useEffectFn?: typeof useEffect, ): METADATA; export function getMetadataFrom>>( type: 'source', metadataHolder: Pick & MetadataHolder, metadataWithDefaultValues: METADATA, prefixes?: string[], useEffectFn?: typeof useEffect, ): METADATA; export function getMetadataFrom>>( type: MetadataHolderType, metadataHolder: | MetadataHolder | (MangaIdInfo & MetadataHolder) | (ChapterIdInfo & MetadataHolder) | (CategoryIdInfo & MetadataHolder) | (Pick & MetadataHolder), metadataWithDefaultValues: METADATA, prefixes?: string[], useEffectFn: typeof useEffect = (fn: () => void) => fn(), ): METADATA { const wasMigrated = !!metadataHolder?.meta && Number(getMetadataValueFrom(metadataHolder.meta, 'migration', 0)) !== METADATA_MIGRATIONS.length; const migratedMetadata = applyMetadataMigrations(metadataHolder.meta); const appMetadata = {} as METADATA; Object.entries(metadataWithDefaultValues).forEach(([key, defaultValue]) => { appMetadata[key as AppMetadataKeys] = getMetadataValueFrom( migratedMetadata, key as AppMetadataKeys, defaultValue, prefixes, ); }); 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; }