diff --git a/src/lib/metadata/metadata.ts b/src/lib/metadata/metadata.ts deleted file mode 100644 index b5429b68..00000000 --- a/src/lib/metadata/metadata.ts +++ /dev/null @@ -1,447 +0,0 @@ -/* - * 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 { - AllowedMetadataValueTypes, - AppMetadataKeys, - GqlMetaHolder, - IMetadataMigration, - Metadata, - MetadataHolder, - MetadataKeyValuePair, -} from '@/typings.ts'; -import { requestManager } from '@/lib/requests/requests/RequestManager.ts'; -import { MetaType, SourceType } from '@/lib/graphql/generated/graphql.ts'; -import { ChapterIdInfo } from '@/modules/chapter/services/Chapters.ts'; -import { MangaIdInfo } from '@/modules/manga/services/Mangas.ts'; -import { CategoryIdInfo } from '@/modules/category/Category.types.ts'; -import { DEFAULT_DEVICE, getActiveDevice } from '@/modules/device/services/Device.ts'; - -const APP_METADATA_KEY_PREFIX = 'webUI_'; - -const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = [ - // downloads - 'deleteChaptersManuallyMarkedRead', - 'deleteChaptersWhileReading', - 'deleteChaptersWithBookmark', - - // library - 'showAddToLibraryCategorySelectDialog', - 'ignoreFilters', - 'removeMangaFromCategories', - 'showTabSize', - - // library category options - // filter - 'hasDownloadedChapters', - 'hasBookmarkedChapters', - 'hasUnreadChapters', - 'hasDuplicateChapters', - 'hasTrackerBinding', - 'hasStatus', - // sort - 'sortBy', - 'sortDesc', - // display - 'showDownloadBadge', - 'showUnreadBadge', - 'showTabSize', - 'showContinueReadingButton', - - // client - 'devices', - - // migration - 'migrateChapters', - 'migrateCategories', - 'migrateTracking', - 'deleteChapters', - 'migrateSortSettings', - - // browse - 'hideLibraryEntries', - - // tracking - 'updateProgressAfterReading', - 'updateProgressManualMarkRead', - - // updates - 'webUIInformAvailableUpdate', - 'serverInformAvailableUpdate', - - // sources - 'savedSearches', - - // themes - 'customThemes', - 'mangaThumbnailBackdrop', -]; - -/** - * Once all changes have been done in the current branch, a new migration for all changes should be - * created. - * - * In case a value should be migrated for a specific key, and in the same migration the key is - * getting migrated, the key in the value migration is the "old" key (before the migration to the - * new key). - * - * Migration order (function "applyMetadataMigrations"): - * 1. app metadata key prefix - * 2. app metadata values - * 3. app metadata keys - * - * @example - * // changes: - * // commit: change key "X" to "Z" - * // commit: change key "Y" to "A" - * // commit: fix typo in reader setting "someTipo" - * // commit: add metadata migration - * - * // result: - * // old migrations: - * // const migrations = [ - * // { - * // keys: [{ oldKey: 'loadNextonEnding', newKey: 'loadNextOnEnding' }], - * // } - * // ]; - * - * // updated migrations - * const migrations = [ - * { - * keys: [{ oldKey: 'loadNextonEnding', newKey: 'loadNextOnEnding' }], - * }, - * { - * keys: [ - * { oldKey: 'X', newKey: 'Z' }, - * { oldKey: 'Y', newKey: 'A' }, - * ], - * // all stored values in the metadata (of this app) will get migrated - * values: [ - * { - * oldValue: 'someTipo', - * newValue: 'someTypo' - * }, - * ], - * // to migrate only the value of a specific key (in this case of "someKey"): - * // values: [ - * // { - * // key: 'someKey', - * // oldValue: 'someTipo', - * // newValue: 'someTypo', - * // }, - * // ], - * }, - * ]; - */ -const migrations: IMetadataMigration[] = [ - { - keys: [{ oldKey: 'loadNextonEnding', newKey: 'loadNextOnEnding' }], - }, - { - keys: [{ oldKey: 'deleteChaptersAutoMarkedRead', newKey: 'deleteChaptersWhileReading' }], - }, -]; - -const getAppKeyPrefixForMigration = (migrationId: number): string => { - const appKeyPrefix = migrations - .slice(0, migrationId) - .reverse() - .find((migration) => !!migration.appKeyPrefix); - - return appKeyPrefix?.appKeyPrefix?.newPrefix ?? APP_METADATA_KEY_PREFIX; -}; - -const getMetadataKey = (key: string, appPrefix: string = APP_METADATA_KEY_PREFIX) => { - const isGlobalMetadataKey = GLOBAL_METADATA_KEYS.includes(key as AppMetadataKeys); - const addActiveDevicePrefix = !isGlobalMetadataKey && getActiveDevice() !== DEFAULT_DEVICE; - - return `${appPrefix}${addActiveDevicePrefix ? `${getActiveDevice()}_` : ''}${key}`; -}; - -const doesMetadataKeyExistIn = (meta: Metadata | undefined, key: string, appPrefix?: string): boolean => - Object.prototype.hasOwnProperty.call(meta ?? {}, getMetadataKey(key, appPrefix)); - -const convertValueFromMetadata = ( - value: string, -): T => { - if (!Number.isNaN(+value)) { - return +value as T; - } - - if (value === 'true' || value === 'false') { - return (value === 'true') as T; - } - - if (value === 'undefined') { - return undefined as T; - } - - if (value === 'null') { - return null as T; - } - - return value as T; -}; - -export const convertFromGqlMeta = (gqlMetadata?: MetaType[]): Metadata | undefined => { - if (!gqlMetadata) { - return undefined; - } - - const metadata: Metadata = {}; - gqlMetadata.forEach(({ key, value }) => { - metadata[key] = value; - }); - - return metadata; -}; - -export const convertToGqlMeta = (metadata?: Metadata): MetaType[] | undefined => { - if (!metadata) { - return undefined; - } - - return Object.entries(metadata).map(([key, value]) => ({ key, value })); -}; - -const getAppMetadataFrom = (meta: Metadata, appPrefix: string = APP_METADATA_KEY_PREFIX): Metadata => { - const appMetadata: Metadata = {}; - - Object.entries(meta).forEach(([key, value]) => { - if (key.startsWith(appPrefix)) { - appMetadata[key] = value; - } - }); - - return appMetadata; -}; - -const applyAppKeyPrefixMigration = (meta: Metadata, migration: IMetadataMigration): Metadata => { - const migratedMetadata: Metadata = { ...meta }; - - if (!migration.appKeyPrefix) { - return migratedMetadata; - } - - const { oldPrefix, newPrefix } = migration.appKeyPrefix; - - const oldAppMetadata = getAppMetadataFrom(meta, oldPrefix); - const newAppMetadata = getAppMetadataFrom(meta, newPrefix); - - const missingMetadataKeys = Object.keys(oldAppMetadata).filter((key) => !Object.keys(newAppMetadata).includes(key)); - - const isMissingOldMetadata = missingMetadataKeys.length; - if (isMissingOldMetadata) { - missingMetadataKeys.forEach((oldKey) => { - const keyWithNewPrefix = oldKey.replace(oldPrefix, newPrefix); - migratedMetadata[keyWithNewPrefix] = oldAppMetadata[oldKey]; - }); - } - - return migratedMetadata; -}; - -const applyMetadataValueMigration = (meta: Metadata, migration: IMetadataMigration, appKeyPrefix: string): Metadata => { - const migratedMetadata: Metadata = { ...meta }; - - if (!migration.values) { - return migratedMetadata; - } - - const appMetadata = getAppMetadataFrom(meta, appKeyPrefix); - const metadataValueChanges = migration.values; - - metadataValueChanges.forEach(({ key, oldValue, newValue }) => { - const migrateValue = (metaKey: string) => { - if (meta[metaKey] === oldValue) { - migratedMetadata[metaKey] = newValue; - } - }; - - const migrateValueOfAllAppKeys = key === undefined; - if (migrateValueOfAllAppKeys) { - Object.keys(appMetadata).forEach((metaKey) => { - migrateValue(metaKey); - }); - return; - } - - if (!doesMetadataKeyExistIn(meta, key, appKeyPrefix)) { - return; - } - - migrateValue(getMetadataKey(key, appKeyPrefix)); - }); - - return migratedMetadata; -}; - -const applyMetadataKeyMigration = (meta: Metadata, migration: IMetadataMigration): Metadata => { - const migratedMetadata: Metadata = { ...meta }; - - if (!migration.keys) { - return migratedMetadata; - } - - const metadataKeyChanges = migration.keys; - - metadataKeyChanges.forEach(({ oldKey, newKey }) => { - if (!doesMetadataKeyExistIn(meta, oldKey)) { - return; - } - - if (doesMetadataKeyExistIn(meta, newKey)) { - return; - } - - migratedMetadata[getMetadataKey(newKey)] = meta[getMetadataKey(oldKey)]; - }); - - return migratedMetadata; -}; - -const applyMetadataMigrations = (meta?: Metadata): Metadata | undefined => { - if (!meta) { - return undefined; - } - - const migrationToMetadata: [number, Metadata][] = [[0, meta]]; - - migrations.forEach((migration, index) => { - const migrationId = index + 1; - const metadataToMigrate = migrationToMetadata[migrationId - 1][1]; - const appKeyPrefixMigrated = applyAppKeyPrefixMigration(metadataToMigrate, migration); - const metadataValuesMigrated = applyMetadataValueMigration( - appKeyPrefixMigrated, - migration, - getAppKeyPrefixForMigration(migrationId), - ); - const metadataKeysMigrated = applyMetadataKeyMigration(metadataValuesMigrated, migration); - - migrationToMetadata.push([migrationId, metadataKeysMigrated]); - }); - - const appliedMigration = migrationToMetadata.length > 1; - if (!appliedMigration) { - return { ...meta }; - } - - return migrationToMetadata.pop()![1]; -}; - -export const getMetadataValueFrom = ( - { meta }: MetadataHolder, - key: Key, - defaultValue?: Value, - applyMigrations: boolean = true, -): Value | undefined => { - const metadata = applyMigrations ? applyMetadataMigrations(meta) : meta; - - if (metadata === undefined || !doesMetadataKeyExistIn(metadata, key)) { - return defaultValue; - } - - return convertValueFromMetadata(metadata[getMetadataKey(key)]); -}; - -export const getMetadataFrom = >>( - { meta }: MetadataHolder, - metadataWithDefaultValues: METADATA, - applyMigrations?: boolean, -): METADATA => { - const appMetadata = {} as METADATA; - - Object.entries(metadataWithDefaultValues).forEach(([key, defaultValue]) => { - appMetadata[key as AppMetadataKeys] = getMetadataValueFrom( - { meta }, - key as AppMetadataKeys, - defaultValue, - applyMigrations, - ); - }); - - return appMetadata; -}; - -// @ts-ignore -// eslint-disable-next-line @typescript-eslint/no-unused-vars -const wrapMetadataWithMetaKey = (wrap: boolean, metadata: Metadata): MetadataHolder => { - if (wrap) { - return { - meta: { - ...metadata, - }, - }; - } - - return { - ...metadata, - }; -}; - -type MetadataHolderType = 'manga' | 'chapter' | 'category' | 'global' | 'source'; - -export const requestUpdateMetadataValue = async ( - metadataHolder: GqlMetaHolder, - holderType: MetadataHolderType, - key: AppMetadataKeys, - value: AllowedMetadataValueTypes, -): Promise => { - const metadataKey = getMetadataKey(key); - - switch (holderType) { - case 'category': - await requestManager.setCategoryMeta((metadataHolder as CategoryIdInfo).id, metadataKey, value).response; - break; - case 'chapter': - await requestManager.setChapterMeta((metadataHolder as ChapterIdInfo).id, metadataKey, value).response; - break; - case 'global': - await requestManager.setGlobalMetadata(metadataKey, value).response; - break; - case 'manga': - await requestManager.setMangaMeta((metadataHolder as MangaIdInfo).id, metadataKey, value).response; - break; - case 'source': - await requestManager.setSourceMeta((metadataHolder as Pick).id, metadataKey, value) - .response; - break; - default: - throw new Error(`requestUpdateMetadataValue: unknown holderType "${holderType}"`); - } -}; - -export const requestUpdateMetadata = async ( - metadataHolder: GqlMetaHolder, - holderType: MetadataHolderType, - keysToValues: MetadataKeyValuePair[], -): Promise => - Promise.all(keysToValues.map(([key, value]) => requestUpdateMetadataValue(metadataHolder, holderType, key, value))); - -export const requestUpdateServerMetadata = async (keysToValues: MetadataKeyValuePair[]): Promise => - requestUpdateMetadata({}, 'global', keysToValues); - -export const requestUpdateMangaMetadata = async ( - manga: MangaIdInfo & GqlMetaHolder, - keysToValues: MetadataKeyValuePair[], -): Promise => requestUpdateMetadata(manga, 'manga', keysToValues); - -export const requestUpdateChapterMetadata = async ( - chapter: ChapterIdInfo & GqlMetaHolder, - keysToValues: MetadataKeyValuePair[], -): Promise => requestUpdateMetadata(chapter, 'chapter', keysToValues); - -export const requestUpdateCategoryMetadata = async ( - category: CategoryIdInfo & GqlMetaHolder, - keysToValues: MetadataKeyValuePair[], -): Promise => requestUpdateMetadata(category, 'category', keysToValues); - -export const requestUpdateSourceMetadata = async ( - source: Pick & GqlMetaHolder, - keysToValue: MetadataKeyValuePair[], -): Promise => requestUpdateMetadata(source, 'source', keysToValue); diff --git a/src/modules/category/services/CategoryMetadata.ts b/src/modules/category/services/CategoryMetadata.ts index 750c3479..1b19ab75 100644 --- a/src/modules/category/services/CategoryMetadata.ts +++ b/src/modules/category/services/CategoryMetadata.ts @@ -6,13 +6,20 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { AllowedMetadataValueTypes, AppMetadataKeys, GqlMetaHolder, Metadata } from '@/typings.ts'; import { jsonSaveParse } from '@/lib/HelperFunctions.ts'; -import { convertFromGqlMeta, getMetadataFrom, requestUpdateCategoryMetadata } from '@/lib/metadata/metadata.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'; +import { getMetadataFrom } from '@/modules/metadata/services/MetadataReader.ts'; +import { + AllowedMetadataValueTypes, + AppMetadataKeys, + GqlMetaHolder, + Metadata, +} from '@/modules/metadata/Metadata.types.ts'; export const getDefaultCategoryMetadata = (): ICategoryMetadata => ({ // display options diff --git a/src/modules/metadata/Metadata.constants.ts b/src/modules/metadata/Metadata.constants.ts new file mode 100644 index 00000000..f9cf3c69 --- /dev/null +++ b/src/modules/metadata/Metadata.constants.ts @@ -0,0 +1,133 @@ +/* + * 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 { AppMetadataKeys, IMetadataMigration } from '@/modules/metadata/Metadata.types.ts'; + +export const APP_METADATA_KEY_PREFIX = 'webUI_'; + +export const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = [ + // downloads + 'deleteChaptersManuallyMarkedRead', + 'deleteChaptersWhileReading', + 'deleteChaptersWithBookmark', + + // library + 'showAddToLibraryCategorySelectDialog', + 'ignoreFilters', + 'removeMangaFromCategories', + 'showTabSize', + + // library category options + // filter + 'hasDownloadedChapters', + 'hasBookmarkedChapters', + 'hasUnreadChapters', + 'hasDuplicateChapters', + 'hasTrackerBinding', + 'hasStatus', + // sort + 'sortBy', + 'sortDesc', + // display + 'showDownloadBadge', + 'showUnreadBadge', + 'showTabSize', + 'showContinueReadingButton', + + // client + 'devices', + + // migration + 'migrateChapters', + 'migrateCategories', + 'migrateTracking', + 'deleteChapters', + 'migrateSortSettings', + + // browse + 'hideLibraryEntries', + + // tracking + 'updateProgressAfterReading', + 'updateProgressManualMarkRead', + + // updates + 'webUIInformAvailableUpdate', + 'serverInformAvailableUpdate', + + // sources + 'savedSearches', + + // themes + 'customThemes', + 'mangaThumbnailBackdrop', +]; +/** + * Once all changes have been done in the current branch, a new migration for all changes should be + * created. + * + * In case a value should be migrated for a specific key, and in the same migration the key is + * getting migrated, the key in the value migration is the "old" key (before the migration to the + * new key). + * + * Migration order (function "applyMetadataMigrations"): + * 1. app metadata key prefix + * 2. app metadata values + * 3. app metadata keys + * + * @example + * // changes: + * // commit: change key "X" to "Z" + * // commit: change key "Y" to "A" + * // commit: fix typo in reader setting "someTipo" + * // commit: add metadata migration + * + * // result: + * // old migrations: + * // const migrations = [ + * // { + * // keys: [{ oldKey: 'loadNextonEnding', newKey: 'loadNextOnEnding' }], + * // } + * // ]; + * + * // updated migrations + * const migrations = [ + * { + * keys: [{ oldKey: 'loadNextonEnding', newKey: 'loadNextOnEnding' }], + * }, + * { + * keys: [ + * { oldKey: 'X', newKey: 'Z' }, + * { oldKey: 'Y', newKey: 'A' }, + * ], + * // all stored values in the metadata (of this app) will get migrated + * values: [ + * { + * oldValue: 'someTipo', + * newValue: 'someTypo' + * }, + * ], + * // to migrate only the value of a specific key (in this case of "someKey"): + * // values: [ + * // { + * // key: 'someKey', + * // oldValue: 'someTipo', + * // newValue: 'someTypo', + * // }, + * // ], + * }, + * ]; + */ +export const METADATA_MIGRATIONS: IMetadataMigration[] = [ + { + keys: [{ oldKey: 'loadNextonEnding', newKey: 'loadNextOnEnding' }], + }, + { + keys: [{ oldKey: 'deleteChaptersAutoMarkedRead', newKey: 'deleteChaptersWhileReading' }], + }, +]; diff --git a/src/typings.ts b/src/modules/metadata/Metadata.types.ts similarity index 100% rename from src/typings.ts rename to src/modules/metadata/Metadata.types.ts index 93e5b301..d7772f15 100644 --- a/src/typings.ts +++ b/src/modules/metadata/Metadata.types.ts @@ -6,11 +6,11 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { MetaType } from '@/lib/graphql/generated/graphql.ts'; +import { MetadataServerSettingKeys, SearchMetadataKeys } from '@/modules/settings/Settings.types.ts'; import { MangaMetadataKeys } from '@/modules/manga/MangaCard.types.tsx'; import { SourceMetadataKeys } from '@/modules/source/Source.types.ts'; import { CategoryMetadataKeys } from '@/modules/category/Category.types.ts'; -import { MetadataServerSettingKeys, SearchMetadataKeys } from '@/modules/settings/Settings.types.ts'; +import { MetaType } from '@/lib/graphql/generated/graphql.ts'; export interface IMetadataMigration { appKeyPrefix?: { oldPrefix: string; newPrefix: string }; diff --git a/src/modules/metadata/services/MetadataConverter.ts b/src/modules/metadata/services/MetadataConverter.ts new file mode 100644 index 00000000..80f0609c --- /dev/null +++ b/src/modules/metadata/services/MetadataConverter.ts @@ -0,0 +1,53 @@ +/* + * 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 { MetaType } from '@/lib/graphql/generated/graphql.ts'; +import { AllowedMetadataValueTypes, Metadata } from '@/modules/metadata/Metadata.types.ts'; + +export const convertValueFromMetadata = ( + value: string, +): T => { + if (!Number.isNaN(+value)) { + return +value as T; + } + + if (value === 'true' || value === 'false') { + return (value === 'true') as T; + } + + if (value === 'undefined') { + return undefined as T; + } + + if (value === 'null') { + return null as T; + } + + return value as T; +}; + +export const convertFromGqlMeta = (gqlMetadata?: MetaType[]): Metadata | undefined => { + if (!gqlMetadata) { + return undefined; + } + + const metadata: Metadata = {}; + gqlMetadata.forEach(({ key, value }) => { + metadata[key] = value; + }); + + return metadata; +}; + +export const convertToGqlMeta = (metadata?: Metadata): MetaType[] | undefined => { + if (!metadata) { + return undefined; + } + + return Object.entries(metadata).map(([key, value]) => ({ key, value })); +}; diff --git a/src/modules/metadata/services/MetadataMigrations.ts b/src/modules/metadata/services/MetadataMigrations.ts new file mode 100644 index 00000000..d3793c05 --- /dev/null +++ b/src/modules/metadata/services/MetadataMigrations.ts @@ -0,0 +1,136 @@ +/* + * 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 { APP_METADATA_KEY_PREFIX, METADATA_MIGRATIONS } from '@/modules/metadata/Metadata.constants.ts'; +import { + doesMetadataKeyExistIn, + getAppMetadataFrom, + getMetadataKey, +} from '@/modules/metadata/services/MetadataReader.ts'; +import { IMetadataMigration, Metadata } from '@/modules/metadata/Metadata.types.ts'; + +export const getAppKeyPrefixForMigration = (migrationId: number): string => { + const appKeyPrefix = METADATA_MIGRATIONS.slice(0, migrationId) + .reverse() + .find((migration) => !!migration.appKeyPrefix); + + return appKeyPrefix?.appKeyPrefix?.newPrefix ?? APP_METADATA_KEY_PREFIX; +}; + +export const applyAppKeyPrefixMigration = (meta: Metadata, migration: IMetadataMigration): Metadata => { + const migratedMetadata: Metadata = { ...meta }; + + if (!migration.appKeyPrefix) { + return migratedMetadata; + } + + const { oldPrefix, newPrefix } = migration.appKeyPrefix; + + const oldAppMetadata = getAppMetadataFrom(meta, oldPrefix); + const newAppMetadata = getAppMetadataFrom(meta, newPrefix); + + const missingMetadataKeys = Object.keys(oldAppMetadata).filter((key) => !Object.keys(newAppMetadata).includes(key)); + + const isMissingOldMetadata = missingMetadataKeys.length; + if (isMissingOldMetadata) { + missingMetadataKeys.forEach((oldKey) => { + const keyWithNewPrefix = oldKey.replace(oldPrefix, newPrefix); + migratedMetadata[keyWithNewPrefix] = oldAppMetadata[oldKey]; + }); + } + + return migratedMetadata; +}; + +const applyMetadataValueMigration = (meta: Metadata, migration: IMetadataMigration, appKeyPrefix: string): Metadata => { + const migratedMetadata: Metadata = { ...meta }; + + if (!migration.values) { + return migratedMetadata; + } + + const appMetadata = getAppMetadataFrom(meta, appKeyPrefix); + const metadataValueChanges = migration.values; + + metadataValueChanges.forEach(({ key, oldValue, newValue }) => { + const migrateValue = (metaKey: string) => { + if (meta[metaKey] === oldValue) { + migratedMetadata[metaKey] = newValue; + } + }; + + const migrateValueOfAllAppKeys = key === undefined; + if (migrateValueOfAllAppKeys) { + Object.keys(appMetadata).forEach((metaKey) => { + migrateValue(metaKey); + }); + return; + } + + if (!doesMetadataKeyExistIn(meta, key, appKeyPrefix)) { + return; + } + + migrateValue(getMetadataKey(key, appKeyPrefix)); + }); + + return migratedMetadata; +}; + +const applyMetadataKeyMigration = (meta: Metadata, migration: IMetadataMigration): Metadata => { + const migratedMetadata: Metadata = { ...meta }; + + if (!migration.keys) { + return migratedMetadata; + } + + const metadataKeyChanges = migration.keys; + + metadataKeyChanges.forEach(({ oldKey, newKey }) => { + if (!doesMetadataKeyExistIn(meta, oldKey)) { + return; + } + + if (doesMetadataKeyExistIn(meta, newKey)) { + return; + } + + migratedMetadata[getMetadataKey(newKey)] = meta[getMetadataKey(oldKey)]; + }); + + return migratedMetadata; +}; + +export const applyMetadataMigrations = (meta?: Metadata): Metadata | undefined => { + if (!meta) { + return undefined; + } + + const migrationToMetadata: [number, Metadata][] = [[0, meta]]; + + METADATA_MIGRATIONS.forEach((migration, index) => { + const migrationId = index + 1; + const metadataToMigrate = migrationToMetadata[migrationId - 1][1]; + const appKeyPrefixMigrated = applyAppKeyPrefixMigration(metadataToMigrate, migration); + const metadataValuesMigrated = applyMetadataValueMigration( + appKeyPrefixMigrated, + migration, + getAppKeyPrefixForMigration(migrationId), + ); + const metadataKeysMigrated = applyMetadataKeyMigration(metadataValuesMigrated, migration); + + migrationToMetadata.push([migrationId, metadataKeysMigrated]); + }); + + const appliedMigration = migrationToMetadata.length > 1; + if (!appliedMigration) { + return { ...meta }; + } + + return migrationToMetadata.pop()![1]; +}; diff --git a/src/modules/metadata/services/MetadataReader.ts b/src/modules/metadata/services/MetadataReader.ts new file mode 100644 index 00000000..af6e2dbe --- /dev/null +++ b/src/modules/metadata/services/MetadataReader.ts @@ -0,0 +1,74 @@ +/* + * 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 { APP_METADATA_KEY_PREFIX, GLOBAL_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 { + AllowedMetadataValueTypes, + AppMetadataKeys, + Metadata, + MetadataHolder, +} from '@/modules/metadata/Metadata.types.ts'; + +export const getMetadataKey = (key: string, appPrefix: string = APP_METADATA_KEY_PREFIX) => { + const isGlobalMetadataKey = GLOBAL_METADATA_KEYS.includes(key as AppMetadataKeys); + const addActiveDevicePrefix = !isGlobalMetadataKey && getActiveDevice() !== DEFAULT_DEVICE; + + return `${appPrefix}${addActiveDevicePrefix ? `${getActiveDevice()}_` : ''}${key}`; +}; + +export const doesMetadataKeyExistIn = (meta: Metadata | undefined, key: string, appPrefix?: string): boolean => + Object.prototype.hasOwnProperty.call(meta ?? {}, getMetadataKey(key, appPrefix)); + +export const getMetadataValueFrom = ( + { meta }: MetadataHolder, + key: Key, + defaultValue?: Value, + applyMigrations: boolean = true, +): Value | undefined => { + const metadata = applyMigrations ? applyMetadataMigrations(meta) : meta; + + if (metadata === undefined || !doesMetadataKeyExistIn(metadata, key)) { + return defaultValue; + } + + return convertValueFromMetadata(metadata[getMetadataKey(key)]); +}; + +export const getMetadataFrom = >>( + { meta }: MetadataHolder, + metadataWithDefaultValues: METADATA, + applyMigrations?: boolean, +): METADATA => { + const appMetadata = {} as METADATA; + + Object.entries(metadataWithDefaultValues).forEach(([key, defaultValue]) => { + appMetadata[key as AppMetadataKeys] = getMetadataValueFrom( + { meta }, + key as AppMetadataKeys, + defaultValue, + applyMigrations, + ); + }); + + return appMetadata; +}; + +export const getAppMetadataFrom = (meta: Metadata, appPrefix: string = APP_METADATA_KEY_PREFIX): Metadata => { + const appMetadata: Metadata = {}; + + Object.entries(meta).forEach(([key, value]) => { + if (key.startsWith(appPrefix)) { + appMetadata[key] = value; + } + }); + + return appMetadata; +}; diff --git a/src/modules/metadata/services/MetadataUpdater.ts b/src/modules/metadata/services/MetadataUpdater.ts new file mode 100644 index 00000000..06b68543 --- /dev/null +++ b/src/modules/metadata/services/MetadataUpdater.ts @@ -0,0 +1,82 @@ +/* + * 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 { requestManager } from '@/lib/requests/requests/RequestManager.ts'; +import { SourceType } from '@/lib/graphql/generated/graphql.ts'; +import { ChapterIdInfo } from '@/modules/chapter/services/Chapters.ts'; +import { MangaIdInfo } from '@/modules/manga/services/Mangas.ts'; +import { CategoryIdInfo } from '@/modules/category/Category.types.ts'; +import { getMetadataKey } from '@/modules/metadata/services/MetadataReader.ts'; +import { + AllowedMetadataValueTypes, + AppMetadataKeys, + GqlMetaHolder, + MetadataKeyValuePair, +} from '@/modules/metadata/Metadata.types.ts'; + +type MetadataHolderType = 'manga' | 'chapter' | 'category' | 'global' | 'source'; + +export const requestUpdateMetadataValue = async ( + metadataHolder: GqlMetaHolder, + holderType: MetadataHolderType, + key: AppMetadataKeys, + value: AllowedMetadataValueTypes, +): Promise => { + const metadataKey = getMetadataKey(key); + + switch (holderType) { + case 'category': + await requestManager.setCategoryMeta((metadataHolder as CategoryIdInfo).id, metadataKey, value).response; + break; + case 'chapter': + await requestManager.setChapterMeta((metadataHolder as ChapterIdInfo).id, metadataKey, value).response; + break; + case 'global': + await requestManager.setGlobalMetadata(metadataKey, value).response; + break; + case 'manga': + await requestManager.setMangaMeta((metadataHolder as MangaIdInfo).id, metadataKey, value).response; + break; + case 'source': + await requestManager.setSourceMeta((metadataHolder as Pick).id, metadataKey, value) + .response; + break; + default: + throw new Error(`requestUpdateMetadataValue: unknown holderType "${holderType}"`); + } +}; + +export const requestUpdateMetadata = async ( + metadataHolder: GqlMetaHolder, + holderType: MetadataHolderType, + keysToValues: MetadataKeyValuePair[], +): Promise => + Promise.all(keysToValues.map(([key, value]) => requestUpdateMetadataValue(metadataHolder, holderType, key, value))); + +export const requestUpdateServerMetadata = async (keysToValues: MetadataKeyValuePair[]): Promise => + requestUpdateMetadata({}, 'global', keysToValues); + +export const requestUpdateMangaMetadata = async ( + manga: MangaIdInfo & GqlMetaHolder, + keysToValues: MetadataKeyValuePair[], +): Promise => requestUpdateMetadata(manga, 'manga', keysToValues); + +export const requestUpdateChapterMetadata = async ( + chapter: ChapterIdInfo & GqlMetaHolder, + keysToValues: MetadataKeyValuePair[], +): Promise => requestUpdateMetadata(chapter, 'chapter', keysToValues); + +export const requestUpdateCategoryMetadata = async ( + category: CategoryIdInfo & GqlMetaHolder, + keysToValues: MetadataKeyValuePair[], +): Promise => requestUpdateMetadata(category, 'category', keysToValues); + +export const requestUpdateSourceMetadata = async ( + source: Pick & GqlMetaHolder, + keysToValue: MetadataKeyValuePair[], +): Promise => requestUpdateMetadata(source, 'source', keysToValue); diff --git a/src/modules/reader/components/ReaderNavBar.tsx b/src/modules/reader/components/ReaderNavBar.tsx index 585f04b9..4404be17 100644 --- a/src/modules/reader/components/ReaderNavBar.tsx +++ b/src/modules/reader/components/ReaderNavBar.tsx @@ -27,7 +27,6 @@ import ListItem from '@mui/material/ListItem'; import ListItemText from '@mui/material/ListItemText'; import Collapse from '@mui/material/Collapse'; import { useTranslation } from 'react-i18next'; -import { AllowedMetadataValueTypes } from '@/typings.ts'; import { ReaderSettingsOptions } from '@/modules/reader/components/ReaderSettingsOptions.tsx'; import { useBackButton } from '@/modules/core/hooks/useBackButton.ts'; import { Select } from '@/modules/core/components/inputs/Select.tsx'; @@ -39,6 +38,7 @@ import { useResizeObserver } from '@/modules/core/hooks/useResizeObserver.tsx'; import { CustomIconButton } from '@/modules/core/components/buttons/CustomIconButton.tsx'; import { IReaderSettings } from '@/modules/reader/Reader.types.ts'; import { DirectionOffset } from '@/Base.types.ts'; +import { AllowedMetadataValueTypes } from '@/modules/metadata/Metadata.types.ts'; const Root = styled('div')({ zIndex: 10, diff --git a/src/modules/reader/components/ReaderSettingsOptions.tsx b/src/modules/reader/components/ReaderSettingsOptions.tsx index 54a92462..9f81e77b 100644 --- a/src/modules/reader/components/ReaderSettingsOptions.tsx +++ b/src/modules/reader/components/ReaderSettingsOptions.tsx @@ -12,11 +12,11 @@ import ListItemText from '@mui/material/ListItemText'; import Switch from '@mui/material/Switch'; import MenuItem from '@mui/material/MenuItem'; import { useTranslation } from 'react-i18next'; -import { AllowedMetadataValueTypes } from '@/typings.ts'; import { NumberSetting } from '@/modules/core/components/settings/NumberSetting.tsx'; import { isHorizontalReaderType } from '@/modules/reader/components/page/Page.tsx'; import { Select } from '@/modules/core/components/inputs/Select.tsx'; import { IReaderSettings } from '@/modules/reader/Reader.types.ts'; +import { AllowedMetadataValueTypes } from '@/modules/metadata/Metadata.types.ts'; interface IProps extends IReaderSettings { setSettingValue: (key: keyof IReaderSettings, value: AllowedMetadataValueTypes, persist?: boolean) => void; diff --git a/src/modules/reader/screens/DefaultReaderSettings.tsx b/src/modules/reader/screens/DefaultReaderSettings.tsx index 77ed696e..77566c8d 100644 --- a/src/modules/reader/screens/DefaultReaderSettings.tsx +++ b/src/modules/reader/screens/DefaultReaderSettings.tsx @@ -8,8 +8,7 @@ import { useContext, useLayoutEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { AllowedMetadataValueTypes } from '@/typings.ts'; -import { convertToGqlMeta, requestUpdateServerMetadata } from '@/lib/metadata/metadata.ts'; +import { requestUpdateServerMetadata } from '@/modules/metadata/services/MetadataUpdater.ts'; import { checkAndHandleMissingStoredReaderSettings, useDefaultReaderSettings, @@ -22,6 +21,8 @@ import { EmptyViewAbsoluteCentered } from '@/modules/core/components/placeholder 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() { const { t } = useTranslation(); diff --git a/src/modules/reader/screens/Reader.tsx b/src/modules/reader/screens/Reader.tsx index 4c6e171d..10fb8a4b 100644 --- a/src/modules/reader/screens/Reader.tsx +++ b/src/modules/reader/screens/Reader.tsx @@ -11,14 +11,13 @@ import { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, u import { useLocation, useNavigate, useParams } from 'react-router-dom'; import Box from '@mui/material/Box'; import { useTranslation } from 'react-i18next'; -import { AllowedMetadataValueTypes } from '@/typings.ts'; import { requestManager } from '@/lib/requests/requests/RequestManager.ts'; import { checkAndHandleMissingStoredReaderSettings, getReaderSettingsFor, useDefaultReaderSettings, } from '@/modules/reader/services/ReaderSettingsMetadata.ts'; -import { requestUpdateMangaMetadata } from '@/lib/metadata/metadata.ts'; +import { requestUpdateMangaMetadata } from '@/modules/metadata/services/MetadataUpdater.ts'; import { HorizontalPager } from '@/modules/reader/components/pager/HorizontalPager.tsx'; import { PageNumber } from '@/modules/reader/components/page/PageNumber.tsx'; import { PagedPager } from '@/modules/reader/components/pager/PagedPager.tsx'; @@ -45,6 +44,7 @@ import { CHAPTER_READER_FIELDS } from '@/lib/graphql/fragments/ChapterFragments. import { MediaQuery } from '@/lib/ui/MediaQuery.tsx'; import { IReaderSettings, ReaderType } from '@/modules/reader/Reader.types.ts'; import { DirectionOffset } from '@/Base.types.ts'; +import { AllowedMetadataValueTypes } from '@/modules/metadata/Metadata.types.ts'; type TChapter = GetChaptersReaderQuery['chapters']['nodes'][number]; diff --git a/src/modules/reader/services/ReaderSettingsMetadata.ts b/src/modules/reader/services/ReaderSettingsMetadata.ts index ce230194..f4dd6714 100644 --- a/src/modules/reader/services/ReaderSettingsMetadata.ts +++ b/src/modules/reader/services/ReaderSettingsMetadata.ts @@ -6,18 +6,18 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { GqlMetaHolder, Metadata, MetadataKeyValuePair } from '@/typings.ts'; import { requestManager } from '@/lib/requests/requests/RequestManager.ts'; import { - convertFromGqlMeta, - getMetadataFrom, requestUpdateMangaMetadata, requestUpdateServerMetadata, -} from '@/lib/metadata/metadata.ts'; +} from '@/modules/metadata/services/MetadataUpdater.ts'; import { MetaType } from '@/lib/graphql/generated/graphql.ts'; import { MangaIdInfo } from '@/modules/manga/services/Mangas.ts'; import { DEFAULT_READER_SETTINGS } from '@/modules/reader/Reader.constants.ts'; import { IReaderSettings, UndefinedReaderSettings } 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'; const getReaderSettingsWithDefaultValueFallback = ( meta?: Metadata, diff --git a/src/modules/settings/services/ServerSettingsMetadata.ts b/src/modules/settings/services/ServerSettingsMetadata.ts index 49d97d28..b57211ab 100644 --- a/src/modules/settings/services/ServerSettingsMetadata.ts +++ b/src/modules/settings/services/ServerSettingsMetadata.ts @@ -6,15 +6,17 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { AllowedMetadataValueTypes, AppMetadataKeys, Metadata } from '@/typings.ts'; import { requestManager } from '@/lib/requests/requests/RequestManager.ts'; -import { convertFromGqlMeta, getMetadataFrom, requestUpdateServerMetadata } from '@/lib/metadata/metadata.ts'; +import { requestUpdateServerMetadata } from '@/modules/metadata/services/MetadataUpdater.ts'; import { jsonSaveParse } from '@/lib/HelperFunctions.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { MetadataMigrationSettings } from '@/modules/migration/Migration.types.ts'; import { MetadataThemeSettings } from '@/modules/theme/AppTheme.types.ts'; import { SERVER_SETTINGS_METADATA_DEFAULT } from '@/modules/settings/Settings.constants.ts'; import { MetadataServerSettingKeys, MetadataServerSettings } from '@/modules/settings/Settings.types.ts'; +import { convertFromGqlMeta } from '@/modules/metadata/services/MetadataConverter.ts'; +import { getMetadataFrom } from '@/modules/metadata/services/MetadataReader.ts'; +import { AllowedMetadataValueTypes, AppMetadataKeys, Metadata } from '@/modules/metadata/Metadata.types.ts'; export const convertSettingsToMetadata = ( settings: Partial, diff --git a/src/modules/source/services/SourceMetadata.ts b/src/modules/source/services/SourceMetadata.ts index ecad78be..06417e20 100644 --- a/src/modules/source/services/SourceMetadata.ts +++ b/src/modules/source/services/SourceMetadata.ts @@ -6,12 +6,19 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ -import { AllowedMetadataValueTypes, AppMetadataKeys, GqlMetaHolder, Metadata } from '@/typings.ts'; import { jsonSaveParse } from '@/lib/HelperFunctions.ts'; -import { convertFromGqlMeta, getMetadataFrom, requestUpdateSourceMetadata } from '@/lib/metadata/metadata.ts'; +import { requestUpdateSourceMetadata } from '@/modules/metadata/services/MetadataUpdater.ts'; import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { SourceType } from '@/lib/graphql/generated/graphql.ts'; import { ISourceMetadata, SourceMetadataKeys } from '@/modules/source/Source.types.ts'; +import { convertFromGqlMeta } from '@/modules/metadata/services/MetadataConverter.ts'; +import { getMetadataFrom } from '@/modules/metadata/services/MetadataReader.ts'; +import { + AllowedMetadataValueTypes, + AppMetadataKeys, + GqlMetaHolder, + Metadata, +} from '@/modules/metadata/Metadata.types.ts'; const convertAppMetadataToGqlMetadata = ( metadata: Partial,