Rename folder "modules" to "features"

This commit is contained in:
schroda
2025-08-15 22:02:58 +02:00
parent 7e6ced1d09
commit 1b4bf22542
415 changed files with 1859 additions and 1852 deletions

View File

@@ -0,0 +1,655 @@
/*
* 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/.
*/
// eslint-disable-next-line import/no-extraneous-dependencies,no-restricted-imports
import deepmerge from '@mui/utils/deepmerge';
import { AppMetadataKeys, IMetadataMigration } from '@/features/metadata/Metadata.types.ts';
import {
IReaderSettings,
ProgressBarPosition,
ReaderCustomFilter,
ReaderPageScaleMode,
ReadingMode,
} from '@/features/reader/types/Reader.types.ts';
import {
AUTO_SCROLL_SPEED,
CUSTOM_FILTER,
DEFAULT_READER_SETTINGS,
IMAGE_PRE_LOAD_AMOUNT,
PAGE_GAP,
PROGRESS_BAR_SIZE,
SCROLL_AMOUNT,
} from '@/features/reader/constants/ReaderSettings.constants.tsx';
import { coerceIn, jsonSaveParse } from '@/lib/HelperFunctions.ts';
import { DOWNLOAD_AHEAD } from '@/features/downloads/Downloads.constants.ts';
import { MANGA_GRID_WIDTH } from '@/features/settings/Settings.constants.ts';
import { NullAndUndefined } from '@/Base.types.ts';
import { SortSettings } from '@/features/migration/Migration.types.ts';
import { ISourceMetadata } from '@/features/source/Source.types.ts';
import { LibraryOptions } from '@/features/library/Library.types.ts';
import { MetadataThemeSettings } from '@/features/theme/AppTheme.types.ts';
import { TapZoneInvertMode } from '@/features/reader/types/TapZoneLayout.types.ts';
export const APP_METADATA_KEY_PREFIX = 'webUI';
const convertToTypeNullAndUndefined = <T>(value: string, convertToType: (value: string) => T): NullAndUndefined<T> => {
if (value === 'null') return null;
if (value === 'undefined') return undefined;
return convertToType(value);
};
const convertToString = (value: string): string => value;
const convertToNumber = (value: string): number => +value;
const convertToBoolean = (value: string): boolean => value === 'true';
const convertToStringNullAndUndefined = (value: string) => convertToTypeNullAndUndefined(value, convertToString);
const convertToBooleanNullAndUndefined = (value: string) => convertToTypeNullAndUndefined(value, convertToBoolean);
const convertToObject = <T>(value: string, defaultValue: T): T => {
const convertedValue = jsonSaveParse<T>(value) ?? defaultValue;
if (Array.isArray(convertedValue)) {
return convertedValue;
}
if (defaultValue == null) {
return convertedValue;
}
return deepmerge(defaultValue, convertedValue);
};
export const APP_METADATA: Record<
AppMetadataKeys,
{
convert: (value: string, defaultValue: any) => any;
toConstrainedValue?: (value: any) => any;
}
> = {
migration: {
convert: convertToNumber,
},
deleteChaptersManuallyMarkedRead: {
convert: convertToBoolean,
},
deleteChaptersWhileReading: {
convert: convertToNumber,
},
deleteChaptersWithBookmark: {
convert: convertToBoolean,
},
downloadAheadLimit: {
convert: convertToNumber,
toConstrainedValue: (value: number) => {
const isDisabled = value === 0;
if (isDisabled) {
return value;
}
return coerceIn(value, DOWNLOAD_AHEAD.min, DOWNLOAD_AHEAD.max);
},
},
showAddToLibraryCategorySelectDialog: {
convert: convertToBoolean,
},
ignoreFilters: {
convert: convertToBoolean,
},
removeMangaFromCategories: {
convert: convertToBoolean,
},
showTabSize: {
convert: convertToBoolean,
},
devices: {
convert: convertToObject<string[]>,
},
migrateChapters: {
convert: convertToBoolean,
},
migrateCategories: {
convert: convertToBoolean,
},
migrateTracking: {
convert: convertToBoolean,
},
deleteChapters: {
convert: convertToBoolean,
},
migrateSortSettings: {
convert: convertToObject<SortSettings>,
},
hideLibraryEntries: {
convert: convertToBoolean,
},
updateProgressAfterReading: {
convert: convertToBoolean,
},
updateProgressManualMarkRead: {
convert: convertToBoolean,
},
webUIInformAvailableUpdate: {
convert: convertToBoolean,
},
serverInformAvailableUpdate: {
convert: convertToBoolean,
},
readerWidth: {
convert: convertToObject<IReaderSettings['readerWidth']>,
},
savedSearches: {
convert: convertToObject<ISourceMetadata['savedSearches']>,
},
showContinueReadingButton: {
convert: convertToBoolean,
},
showDownloadBadge: {
convert: convertToBoolean,
},
showUnreadBadge: {
convert: convertToBoolean,
},
gridLayout: {
convert: convertToNumber, // GridLayout (enum)
},
sortBy: {
convert: convertToStringNullAndUndefined, // LibrarySortMode
},
sortDesc: {
convert: convertToBooleanNullAndUndefined,
},
hasDownloadedChapters: {
convert: convertToBooleanNullAndUndefined,
},
hasBookmarkedChapters: {
convert: convertToBooleanNullAndUndefined,
},
hasUnreadChapters: {
convert: convertToBooleanNullAndUndefined,
},
hasReadChapters: {
convert: convertToBooleanNullAndUndefined,
},
hasDuplicateChapters: {
convert: convertToBooleanNullAndUndefined,
},
hasTrackerBinding: {
convert: convertToObject<LibraryOptions['hasTrackerBinding']>,
},
hasStatus: {
convert: convertToObject<LibraryOptions['hasStatus']>,
},
customThemes: {
convert: convertToObject<MetadataThemeSettings['customThemes']>,
},
mangaThumbnailBackdrop: {
convert: convertToBoolean,
},
mangaDynamicColorSchemes: {
convert: convertToBoolean,
},
tapZoneLayout: {
convert: convertToNumber, // TapZoneLayouts (enum)
},
tapZoneInvertMode: {
convert: convertToObject<TapZoneInvertMode>, // TapZoneInvertMode (object)
},
readingDirection: {
convert: convertToNumber, // ReadingDirection (enum)
},
progressBarType: {
convert: convertToNumber, // ProgressBarType (enum)
},
progressBarSize: {
convert: convertToNumber,
toConstrainedValue: (value: number) => coerceIn(value, PROGRESS_BAR_SIZE.min, PROGRESS_BAR_SIZE.max),
},
progressBarPosition: {
convert: convertToNumber, // ProgressBarPosition (enum)
},
progressBarPositionAutoVertical: {
convert: convertToNumber, // TProgressBarPositionAutoVertical (enum)
},
readingMode: {
convert: convertToNumber, // ReadingMode (enum)
},
pageScaleMode: {
convert: convertToNumber, // ReaderPageScaleMode (enum)
},
shouldOffsetDoubleSpreads: {
convert: convertToBoolean,
},
exitMode: {
convert: convertToNumber, // ReaderExitMode (enum)
},
customFilter: {
convert: convertToObject<ReaderCustomFilter>,
toConstrainedValue: (value: ReaderCustomFilter): ReaderCustomFilter => ({
...value,
brightness: {
...value.brightness,
value: coerceIn(value.brightness.value, CUSTOM_FILTER.brightness.min, CUSTOM_FILTER.brightness.max),
},
contrast: {
...value.contrast,
value: coerceIn(value.contrast.value, CUSTOM_FILTER.contrast.min, CUSTOM_FILTER.contrast.max),
},
saturate: {
...value.saturate,
value: coerceIn(value.saturate.value, CUSTOM_FILTER.saturate.min, CUSTOM_FILTER.saturate.max),
},
hue: {
...value.hue,
value: coerceIn(value.hue.value, CUSTOM_FILTER.hue.min, CUSTOM_FILTER.hue.max),
},
rgba: {
...value.rgba,
value: {
...value.rgba.value,
red: coerceIn(value.rgba.value.red, CUSTOM_FILTER.rgba.red.min, CUSTOM_FILTER.rgba.red.max),
green: coerceIn(value.rgba.value.green, CUSTOM_FILTER.rgba.green.min, CUSTOM_FILTER.rgba.green.max),
blue: coerceIn(value.rgba.value.blue, CUSTOM_FILTER.rgba.blue.min, CUSTOM_FILTER.rgba.blue.max),
alpha: coerceIn(value.rgba.value.alpha, CUSTOM_FILTER.rgba.alpha.min, CUSTOM_FILTER.rgba.alpha.max),
},
},
}),
},
shouldSkipDupChapters: {
convert: convertToBoolean,
},
shouldSkipFilteredChapters: {
convert: convertToBoolean,
},
isStaticNav: {
convert: convertToBoolean,
},
overlayMode: {
convert: convertToNumber, // ReaderOverlayMode (enum)
},
shouldStretchPage: {
convert: convertToBoolean,
},
shouldShowPageNumber: {
convert: convertToBoolean,
},
backgroundColor: {
convert: convertToNumber, // ReaderBackgroundColor (enum)
},
pageGap: {
convert: convertToNumber,
toConstrainedValue: (value: number) => coerceIn(value, PAGE_GAP.min, PAGE_GAP.max),
},
hotkeys: {
convert: convertToObject<IReaderSettings['hotkeys']>,
},
imagePreLoadAmount: {
convert: convertToNumber,
toConstrainedValue: (value: number) => coerceIn(value, IMAGE_PRE_LOAD_AMOUNT.min, IMAGE_PRE_LOAD_AMOUNT.max),
},
shouldUseAutoWebtoonMode: {
convert: convertToBoolean,
},
autoScroll: {
convert: convertToObject<IReaderSettings['autoScroll']>,
toConstrainedValue: (value: IReaderSettings['autoScroll']): IReaderSettings['autoScroll'] => ({
...value,
value: coerceIn(value.value, AUTO_SCROLL_SPEED.min, AUTO_SCROLL_SPEED.max),
}),
},
shouldShowReadingModePreview: {
convert: convertToBoolean,
},
shouldShowTapZoneLayoutPreview: {
convert: convertToBoolean,
},
shouldInformAboutMissingChapter: {
convert: convertToBoolean,
},
shouldInformAboutScanlatorChange: {
convert: convertToBoolean,
},
hideHistory: {
convert: convertToBoolean,
},
scrollAmount: {
convert: convertToNumber, // ReaderScrollAmount (enum)
toConstrainedValue: (value: number) => coerceIn(value, SCROLL_AMOUNT.min, SCROLL_AMOUNT.max),
},
reverse: {
convert: convertToBoolean,
},
bookmarked: {
convert: convertToBooleanNullAndUndefined,
},
downloaded: {
convert: convertToBooleanNullAndUndefined,
},
unread: {
convert: convertToBooleanNullAndUndefined,
},
showChapterNumber: {
convert: convertToBoolean,
},
extensionLanguages: {
convert: convertToObject<string[]>,
},
sourceLanguages: {
convert: convertToObject<string[]>,
},
showNsfw: {
convert: convertToBoolean,
},
shouldUseInfiniteScroll: {
convert: convertToBoolean,
},
shouldShowTransitionPage: {
convert: convertToBoolean,
},
appTheme: {
convert: convertToString,
},
themeMode: {
convert: convertToString, // ThemeMode (enum)
},
shouldUsePureBlackMode: {
convert: convertToBoolean,
},
mangaGridItemWidth: {
convert: convertToNumber,
toConstrainedValue: (value: number) => coerceIn(value, MANGA_GRID_WIDTH.min, MANGA_GRID_WIDTH.max),
},
isPinned: {
convert: convertToBoolean,
},
isEnabled: {
convert: convertToBoolean,
},
lastUsedSourceId: {
convert: convertToString,
},
shouldShowOnlySourcesWithResults: {
convert: convertToBoolean,
},
excludedScanlators: {
convert: convertToObject<string[]>,
},
} as const;
export const VALID_APP_METADATA_KEYS = Object.keys(APP_METADATA);
export const GLOBAL_METADATA_KEYS: AppMetadataKeys[] = [
// metadata applied migration id
'migration',
// downloads
'deleteChaptersManuallyMarkedRead',
'deleteChaptersWhileReading',
'deleteChaptersWithBookmark',
// library
'showAddToLibraryCategorySelectDialog',
'ignoreFilters',
'removeMangaFromCategories',
'showTabSize',
// library category options
// filter
'hasDownloadedChapters',
'hasBookmarkedChapters',
'hasUnreadChapters',
'hasReadChapters',
'hasDuplicateChapters',
'hasTrackerBinding',
'hasStatus',
// sort
'sortBy',
'sortDesc',
// display
'showDownloadBadge',
'showUnreadBadge',
'showTabSize',
'showContinueReadingButton',
// client
'devices',
// migration
'migrateChapters',
'migrateCategories',
'migrateTracking',
'deleteChapters',
'migrateSortSettings',
// browse
'hideLibraryEntries',
'extensionLanguages',
'sourceLanguages',
'showNsfw',
'lastUsedSourceId',
'shouldShowOnlySourcesWithResults',
// history
'hideHistory',
// tracking
'updateProgressAfterReading',
'updateProgressManualMarkRead',
// updates
'webUIInformAvailableUpdate',
'serverInformAvailableUpdate',
// sources
'savedSearches',
'isPinned',
'isEnabled',
// themes
'customThemes',
'mangaThumbnailBackdrop',
'mangaDynamicColorSchemes',
// reader
'exitMode',
'shouldSkipDupChapters',
'shouldSkipFilteredChapters',
'hotkeys',
'shouldShowTransitionPage',
// manga
// chapter list options
'reverse',
'bookmarked',
'downloaded',
'unread',
'showChapterNumber',
'excludedScanlators',
];
/**
* 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
* 4. app metadata keys deletion
*
* @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' }],
},
{
keys: [
{
oldKey: 'readerType',
newKey: 'readingMode',
},
{
oldKey: 'offsetFirstPage',
newKey: 'shouldOffsetDoubleSpreads',
},
{
oldKey: 'fitPageToWindow',
newKey: 'pageScaleMode',
},
{
oldKey: 'scalePage',
newKey: 'shouldStretchPage',
},
{
oldKey: 'skipDupChapters',
newKey: 'shouldSkipDupChapters',
},
{
oldKey: 'showPageNumber',
newKey: 'shouldShowPageNumber',
},
{
oldKey: 'staticNav',
newKey: 'isStaticNav',
},
],
values: [
// START: readerType
{
key: 'readerType',
oldValue: 'ContinuesVertical',
newValue: `${ReadingMode.CONTINUOUS_VERTICAL}`,
},
{
key: 'readerType',
oldValue: 'Webtoon',
newValue: `${ReadingMode.WEBTOON}`,
},
{
key: 'readerType',
oldValue: 'SingleRTL',
newValue: `${ReadingMode.SINGLE_PAGE}`,
},
{
key: 'readerType',
oldValue: 'SingleLTR',
newValue: `${ReadingMode.SINGLE_PAGE}`,
},
{
key: 'readerType',
oldValue: 'DoubleRTL',
newValue: `${ReadingMode.DOUBLE_PAGE}`,
},
{
key: 'readerType',
oldValue: 'DoubleLTR',
newValue: `${ReadingMode.DOUBLE_PAGE}`,
},
{
key: 'readerType',
oldValue: 'ContinuesHorizontalLTR',
newValue: `${ReadingMode.CONTINUOUS_HORIZONTAL}`,
},
{
key: 'readerType',
oldValue: 'ContinuesHorizontalRTL',
newValue: `${ReadingMode.CONTINUOUS_HORIZONTAL}`,
},
// END: readerType
// START: fitPageToWindow
{
key: 'fitPageToWindow',
oldValue: 'false',
newValue: `${ReaderPageScaleMode.ORIGINAL}`,
},
{
key: 'fitPageToWindow',
oldValue: 'true',
newValue: `${ReaderPageScaleMode.SCREEN}`,
},
// END: fitPageToWindow
{
key: 'readerWidth',
oldValue: /^[0-9]+$/g,
newValue: (oldValue) => JSON.stringify({ value: Number(oldValue), enabled: true }),
},
],
},
{
deleteKeys: ['pageScaleMode', 'shouldStretchPage', 'readerWidth'],
},
{
values: [
{
key: 'progressBarPositionAutoVertical',
oldValue: '-1',
newValue: `${DEFAULT_READER_SETTINGS.progressBarPositionAutoVertical}`,
},
{
key: 'progressBarPosition',
oldValue: '0',
newValue: `${ProgressBarPosition.BOTTOM}`,
},
{
key: 'progressBarPosition',
oldValue: '1',
newValue: `${ProgressBarPosition.LEFT}`,
},
{
key: 'progressBarPosition',
oldValue: '2',
newValue: `${ProgressBarPosition.RIGHT}`,
},
],
},
];

View File

@@ -0,0 +1,58 @@
/*
* 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 { MetadataServerSettingKeys, SearchMetadataKeys } from '@/features/settings/Settings.types.ts';
import { MangaMetadataKeys } from '@/features/manga/Manga.types.ts';
import { SourceMetadataKeys } from '@/features/source/Source.types.ts';
import { CategoryMetadataKeys } from '@/features/category/Category.types.ts';
import { MetaType } from '@/lib/graphql/generated/graphql.ts';
import { IReaderSettings } from '@/features/reader/types/Reader.types.ts';
export interface IMetadataMigration {
appKeyPrefix?: { oldPrefix: string; newPrefix: string };
values?: {
/**
* In case the migration should only be applied to a specific metadata key.
* Otherwise, all metadata keys will get migrated.
*/
key?: string;
oldValue: string | RegExp | undefined;
newValue: string | ((oldValue: string) => string);
}[];
keys?: { oldKey: string; newKey: string }[];
deleteKeys?: string[];
}
export type Metadata<Keys extends string = string, Values = string> = {
[key in Keys]: Values;
};
export type GqlMetaHolder = { meta?: MetaType[] };
export type MetadataHolder<Keys extends string = string, Values = string> = {
meta?: Metadata<Keys, Values>;
};
export type AllowedMetadataValueTypes = string | boolean | number | undefined | null;
interface MetadataAppliedMigration {
migration: number;
}
export type AppMetadataKeys =
| keyof MetadataAppliedMigration
| MetadataServerSettingKeys
| MangaMetadataKeys
| keyof IReaderSettings
| SearchMetadataKeys
| SourceMetadataKeys
| CategoryMetadataKeys;
export type MetadataKeyValuePair = [AppMetadataKeys, AllowedMetadataValueTypes];
export type MetadataHolderType = 'manga' | 'chapter' | 'category' | 'global' | 'source';

View File

@@ -0,0 +1,45 @@
/*
* 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 '@/features/metadata/Metadata.constants.ts';
import { AppMetadataKeys, Metadata } from '@/features/metadata/Metadata.types.ts';
import { getActiveDevice } from '@/features/device/services/Device.ts';
export const extractOriginalKey = (key: string) => key.split('_').slice(-1)[0];
/**
* Returns the key with the provided prefixes.
*
* In case the key is not a global key ({@link GLOBAL_METADATA_KEYS}), the active device name ({@link getActiveDevice})
* will be added as the second prefix.
*
* **Important**
*
* "default" (case-insensitive) is a reserved value and will be handled as if there is no prefix to add
*
* The format is <prefixes>\_<key>
*
* e.g.: <base_prefix>\_[device_name]\_[optional_prefix1]\_[optional_prefix2]\_<key>
*/
export const getMetadataKey = (key: string, prefixes: string[] = [], appPrefix: string = APP_METADATA_KEY_PREFIX) => {
const isGlobalMetadataKey = GLOBAL_METADATA_KEYS.includes(key as AppMetadataKeys);
const addActiveDevicePrefix = !isGlobalMetadataKey;
const finalPrefix = [appPrefix, ...(addActiveDevicePrefix ? [getActiveDevice()] : []), ...prefixes].filter(
(prefix) => prefix.toLowerCase() !== 'default',
);
return `${finalPrefix.join('_')}_${key}`;
};
export const doesMetadataKeyExistIn = (
meta: Metadata | undefined,
key: string,
prefixes?: string[],
appPrefix?: string,
): boolean => Object.prototype.hasOwnProperty.call(meta ?? {}, getMetadataKey(key, prefixes, appPrefix));

View File

@@ -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()];
};

View File

@@ -0,0 +1,43 @@
/*
* 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, AppMetadataKeys, Metadata } from '@/features/metadata/Metadata.types.ts';
import { APP_METADATA } from '@/features/metadata/Metadata.constants.ts';
export const convertValueFromMetadata = <T extends AllowedMetadataValueTypes = AllowedMetadataValueTypes>(
key: string,
value: string,
defaultValue: any,
): T => APP_METADATA[key as AppMetadataKeys].convert(value, defaultValue);
export const convertFromGqlMeta = (
gqlMetadata?: MetaType[],
filter: (key: string) => boolean = () => true,
): Metadata | undefined => {
if (!gqlMetadata) {
return undefined;
}
const metadata: Metadata = {};
gqlMetadata.forEach(({ key, value }) => {
if (filter(key)) {
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 }));
};

View File

@@ -0,0 +1,388 @@
/*
* 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 {
APP_METADATA_KEY_PREFIX,
METADATA_MIGRATIONS,
VALID_APP_METADATA_KEYS,
} from '@/features/metadata/Metadata.constants.ts';
import {
AppMetadataKeys,
IMetadataMigration,
Metadata,
MetadataHolder,
MetadataHolderType,
MetadataKeyValuePair,
} from '@/features/metadata/Metadata.types.ts';
import { extractOriginalKey, getMetadataKey } from '@/features/metadata/Metadata.utils.ts';
import { MangaIdInfo } from '@/features/manga/Manga.types.ts';
import { CategoryIdInfo } from '@/features/category/Category.types.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { getMetadataDeleteFunction, getMetadataUpdateFunction } from '@/features/metadata/services/MetadataUpdater.ts';
import { SourceIdInfo } from '@/features/source/Source.types.ts';
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
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;
};
const getAppMetadataFrom = (
meta: Metadata,
prefixes: string[] = [],
appPrefix: string = APP_METADATA_KEY_PREFIX,
): Metadata => {
const appMetadata: Metadata = {};
Object.entries(meta).forEach(([key, value]) => {
if (key.startsWith([appPrefix, ...prefixes].join('_'))) {
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, undefined, oldPrefix);
Object.keys(oldAppMetadata).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, undefined, appKeyPrefix);
const metadataValueChanges = migration.values;
metadataValueChanges.forEach(({ key, oldValue, newValue }) => {
const migrateValue = (metaKey: string) => {
if (
(oldValue === undefined && meta[metaKey] === oldValue) ||
(oldValue !== undefined && meta[metaKey].match(oldValue))
) {
migratedMetadata[metaKey] = typeof newValue === 'function' ? newValue(meta[metaKey]) : newValue;
}
};
const migrateValueOfAllAppKeys = key === undefined;
Object.keys(appMetadata).forEach((metaKey) => {
if (migrateValueOfAllAppKeys || metaKey.endsWith(key)) {
migrateValue(metaKey);
}
});
});
return migratedMetadata;
};
const applyMetadataKeyMigration = (meta: Metadata, migration: IMetadataMigration, appKeyPrefix: string): Metadata => {
const migratedMetadata: Metadata = { ...meta };
if (!migration.keys) {
return migratedMetadata;
}
const appMetadata = getAppMetadataFrom(meta, undefined, appKeyPrefix);
migration.keys.forEach(({ oldKey, newKey }) => {
Object.keys(appMetadata).forEach((key) => {
if (!key.endsWith(oldKey)) {
return;
}
const prefixes = key.split('_');
const prefix = prefixes.slice(0, prefixes.length - 1).join('_');
const newKeyWithOldKeysPrefix = `${prefix}_${newKey}`;
migratedMetadata[newKeyWithOldKeysPrefix] = appMetadata[key];
});
});
return migratedMetadata;
};
const applyMetadataDeleteKeysMigration = (
meta: Metadata,
migration: IMetadataMigration,
appKeyPrefix: string,
): Metadata => {
const migratedMetadata: Metadata = {};
if (!migration.deleteKeys) {
return { ...meta };
}
const appMetadata = getAppMetadataFrom(meta, undefined, appKeyPrefix);
Object.keys(appMetadata).forEach((key) => {
if (migration.deleteKeys?.includes(extractOriginalKey(key))) {
return;
}
migratedMetadata[key] = appMetadata[key];
});
return migratedMetadata;
};
const getOutdatedMetadataKeys = (metadata: Metadata | undefined, migrationId: number): AppMetadataKeys[] => {
if (!metadata) {
return [];
}
const oldAppKeyPrefixes = METADATA_MIGRATIONS.slice(migrationId).reduce((acc, migration) => {
const oldPrefix = migration.appKeyPrefix?.oldPrefix;
if (!oldPrefix) {
return acc;
}
return [...acc, oldPrefix];
}, [] as string[]);
const keyToDeleteInMigrations = METADATA_MIGRATIONS.slice(migrationId).reduce(
(acc, migration) => [...acc, ...(migration.deleteKeys ?? [])],
[] as string[],
);
return Object.keys(metadata).filter((key) => {
const appKeyPrefixOfKey = key.split('_')[0];
const isMetadataKeyOfApp = [...oldAppKeyPrefixes, APP_METADATA_KEY_PREFIX].includes(appKeyPrefixOfKey);
if (!isMetadataKeyOfApp) {
return false;
}
const isOldKeyPrefix = oldAppKeyPrefixes.includes(appKeyPrefixOfKey);
if (isOldKeyPrefix) {
return true;
}
const extractedKey = extractOriginalKey(key);
if (keyToDeleteInMigrations.includes(extractedKey)) {
return true;
}
return !VALID_APP_METADATA_KEYS.includes(extractedKey);
}) as AppMetadataKeys[];
};
const getNewMetadataKeys = (
metadata: Metadata | undefined,
migratedMetadata: Metadata,
metadataKeyToDelete: string[],
migrationId: number,
): string[] => {
if (!metadata) {
return [];
}
const newKeys = METADATA_MIGRATIONS.slice(migrationId).reduce((acc, migration) => {
if (!migration.keys) {
return acc;
}
const keys = migration.keys.map(({ newKey }) => newKey).filter((key) => key !== undefined);
return [...acc, ...keys];
}, [] as string[]);
return Object.keys(migratedMetadata).filter((metadataKey) => {
const key = extractOriginalKey(metadataKey);
const isNewKeyOfAMigration = newKeys.includes(key);
const isOldKeyOfAMigration = metadataKeyToDelete.includes(key);
const isAlreadyCommited = Object.hasOwn(metadata, metadataKey);
return !isAlreadyCommited && isNewKeyOfAMigration && !isOldKeyOfAMigration;
});
};
const getMetadataKeysWithUpdatedValues = (
metadata: Metadata | undefined,
newAndDeletedMetadataKeys: string[],
migrationId: number,
): string[] => {
if (!metadata) {
return [];
}
const keysWithUpdatedValues = METADATA_MIGRATIONS.slice(migrationId).reduce((acc, migration) => {
const keysWithUpdatedValuesOfMigration = Object.keys(metadata).filter((metadataKey) =>
migration.values?.some(({ key: migrationKey, oldValue }) => {
const isMigrationForAllKeys = !migrationKey;
const doesValueMatch = metadata[metadataKey] === oldValue;
if (isMigrationForAllKeys) {
return doesValueMatch;
}
return metadataKey.endsWith(migrationKey) && doesValueMatch;
}),
);
return [...acc, ...keysWithUpdatedValuesOfMigration];
}, [] as string[]);
return [
...new Set([
...keysWithUpdatedValues.filter(
(keyWithUpdatedValue) => !newAndDeletedMetadataKeys.includes(keyWithUpdatedValue),
),
]),
];
};
/**
* Prevent spamming requests due to frequent metadata reads while the migration hasn't been commited to the server yet
*/
const commitedMigrations = new Set<string>();
const commitMigratedMetadata = (
type: MetadataHolderType,
metadataHolder:
| MetadataHolder
| (MangaIdInfo & MetadataHolder)
| (ChapterIdInfo & MetadataHolder)
| (CategoryIdInfo & MetadataHolder)
| (SourceIdInfo & MetadataHolder)
| undefined,
migratedMetadata: Metadata,
useEffectFn: typeof useEffect = (fn: () => void) => fn(),
): void => {
const metadata = metadataHolder?.meta;
const migrationId = Number(metadata?.[getMetadataKey('migration')] ?? 1);
const metadataKeysToDelete = getOutdatedMetadataKeys(metadata, migrationId);
const newMetadataKeys = getNewMetadataKeys(metadata, migratedMetadata, metadataKeysToDelete, migrationId);
const metadataKeysWithUpdatedValues = getMetadataKeysWithUpdatedValues(
metadata,
[...metadataKeysToDelete, ...newMetadataKeys],
migrationId,
);
const metadataToUpdate = [...newMetadataKeys, ...metadataKeysWithUpdatedValues].map((key) => [
key,
migratedMetadata[key],
]) as MetadataKeyValuePair[];
const updateMetadata = getMetadataUpdateFunction(type, metadataHolder ?? { id: -1, meta: {} });
const deleteMetadata = getMetadataDeleteFunction(type, metadataHolder ?? { id: -1, meta: {} });
useEffectFn(() => {
(async () => {
const itemMigrationKey = `${type}_${type === 'global' ? '' : (metadataHolder as { id: any } | undefined)?.id}`;
const commitMigration = !commitedMigrations.has(itemMigrationKey);
if (!commitMigration) {
return;
}
const isMetadataAlreadyMigrated = !metadata || migrationId === METADATA_MIGRATIONS.length;
const isCommitRequired =
!isMetadataAlreadyMigrated && (!!metadataKeysToDelete.length || !!metadataToUpdate.length);
if (!isCommitRequired) {
return;
}
commitedMigrations.add(itemMigrationKey);
try {
await updateMetadata([...metadataToUpdate], undefined, true);
await deleteMetadata(metadataKeysToDelete, undefined, true);
await updateMetadata([['migration', METADATA_MIGRATIONS.length]]);
} catch (e) {
commitedMigrations.delete(itemMigrationKey);
defaultPromiseErrorHandler(
`MetadataMigrations#commitMigrateMetadata(${type}, ${(metadataHolder as { id: any })?.id})`,
)(e);
}
})();
});
};
export const applyMetadataMigrations = (
type: MetadataHolderType,
metadataHolder?:
| MetadataHolder
| (MangaIdInfo & MetadataHolder)
| (ChapterIdInfo & MetadataHolder)
| (CategoryIdInfo & MetadataHolder)
| (SourceIdInfo & MetadataHolder),
useEffectFn: typeof useEffect = (fn: () => void) => fn(),
): Metadata | undefined => {
const meta = { ...(metadataHolder?.meta ?? {}) };
const migrationIdKey = getMetadataKey('migration');
const appliedMigrationId = Number.isNaN(Number(meta[migrationIdKey]))
? 0
: Math.max(0, Number(meta[migrationIdKey]));
const migrationToMetadata: [number, Metadata][] = [[0, meta]];
METADATA_MIGRATIONS.forEach((migration, index) => {
const migrationId = index + 1;
const metadataToMigrate = migrationToMetadata[migrationId - 1][1];
const isMigrationRequired = appliedMigrationId < migrationId;
if (!isMigrationRequired) {
migrationToMetadata.push([migrationId, metadataToMigrate]);
return;
}
const appKeyPrefixForMigration = getAppKeyPrefixForMigration(migrationId);
const appKeyPrefixMigrated = applyAppKeyPrefixMigration(metadataToMigrate, migration);
const metadataValuesMigrated = applyMetadataValueMigration(
appKeyPrefixMigrated,
migration,
appKeyPrefixForMigration,
);
const metadataKeysMigrated = applyMetadataKeyMigration(
metadataValuesMigrated,
migration,
appKeyPrefixForMigration,
);
const metadataKeysDeletedMigrated = applyMetadataDeleteKeysMigration(
metadataKeysMigrated,
migration,
appKeyPrefixForMigration,
);
migrationToMetadata.push([migrationId, metadataKeysDeletedMigrated]);
});
const migratedMetadata = migrationToMetadata.pop()?.[1] ?? meta;
commitMigratedMetadata(type, metadataHolder, migratedMetadata, useEffectFn);
return migratedMetadata;
};

View File

@@ -0,0 +1,106 @@
/*
* 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 { convertValueFromMetadata } from '@/features/metadata/services/MetadataConverter.ts';
import {
AllowedMetadataValueTypes,
AppMetadataKeys,
Metadata,
MetadataHolder,
MetadataHolderType,
} from '@/features/metadata/Metadata.types.ts';
import { MangaIdInfo } from '@/features/manga/Manga.types.ts';
import { CategoryIdInfo } from '@/features/category/Category.types.ts';
import { doesMetadataKeyExistIn, getMetadataKey } from '@/features/metadata/Metadata.utils.ts';
import { applyMetadataMigrations } from '@/features/metadata/services/MetadataMigrations.ts';
import { SourceIdInfo } from '@/features/source/Source.types.ts';
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
import { APP_METADATA } from '@/features/metadata/Metadata.constants.ts';
const getMetadataValueFrom = <Key extends AppMetadataKeys, Value extends AllowedMetadataValueTypes>(
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;
}
const convertedValue = convertValueFromMetadata(key, metadata[getMetadataKey(key, prefixes)], defaultValue);
return APP_METADATA[key].toConstrainedValue?.(convertedValue) ?? convertedValue;
};
export function getMetadataFrom<METADATA extends Partial<Metadata<AppMetadataKeys, any>>>(
type: 'global',
metadataHolder: MetadataHolder,
metadataWithDefaultValues: METADATA,
prefixes?: string[],
useEffectFn?: typeof useEffect,
): METADATA;
export function getMetadataFrom<METADATA extends Partial<Metadata<AppMetadataKeys, any>>>(
type: 'manga',
metadataHolder: MangaIdInfo & MetadataHolder,
metadataWithDefaultValues: METADATA,
prefixes?: string[],
useEffectFn?: typeof useEffect,
): METADATA;
export function getMetadataFrom<METADATA extends Partial<Metadata<AppMetadataKeys, any>>>(
type: 'chapter',
metadataHolder: ChapterIdInfo & MetadataHolder,
metadataWithDefaultValues: METADATA,
prefixes?: string[],
useEffectFn?: typeof useEffect,
): METADATA;
export function getMetadataFrom<METADATA extends Partial<Metadata<AppMetadataKeys, any>>>(
type: 'category',
metadataHolder: CategoryIdInfo & MetadataHolder,
metadataWithDefaultValues: METADATA,
prefixes?: string[],
useEffectFn?: typeof useEffect,
): METADATA;
export function getMetadataFrom<METADATA extends Partial<Metadata<AppMetadataKeys, any>>>(
type: 'source',
metadataHolder: SourceIdInfo & MetadataHolder,
metadataWithDefaultValues: METADATA,
prefixes?: string[],
useEffectFn?: typeof useEffect,
): METADATA;
export function getMetadataFrom<METADATA extends Partial<Metadata<AppMetadataKeys, any>>>(
type: MetadataHolderType,
metadataHolder:
| MetadataHolder
| (MangaIdInfo & MetadataHolder)
| (ChapterIdInfo & MetadataHolder)
| (CategoryIdInfo & MetadataHolder)
| (SourceIdInfo & MetadataHolder),
metadataWithDefaultValues: METADATA,
prefixes?: string[],
useEffectFn: typeof useEffect = (fn: () => void) => fn(),
): METADATA {
const migratedMetadata = applyMetadataMigrations(type, metadataHolder, useEffectFn);
const appMetadata = {} as METADATA;
Object.entries(metadataWithDefaultValues).forEach(([key, defaultValue]) => {
appMetadata[key as AppMetadataKeys] = getMetadataValueFrom(
migratedMetadata,
key as AppMetadataKeys,
defaultValue,
prefixes,
);
});
return appMetadata;
}

View File

@@ -0,0 +1,265 @@
/*
* 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/RequestManager.ts';
import { CategoryIdInfo } from '@/features/category/Category.types.ts';
import {
AllowedMetadataValueTypes,
AppMetadataKeys,
GqlMetaHolder,
MetadataHolder,
MetadataHolderType,
MetadataKeyValuePair,
} from '@/features/metadata/Metadata.types.ts';
import { MangaIdInfo } from '@/features/manga/Manga.types.ts';
import { getMetadataKey } from '@/features/metadata/Metadata.utils.ts';
import { convertToGqlMeta } from '@/features/metadata/services/MetadataConverter.ts';
import { SourceIdInfo } from '@/features/source/Source.types.ts';
import { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';
const requestUpdateMetadataValue = async (
metadataHolder: GqlMetaHolder,
holderType: MetadataHolderType,
key: AppMetadataKeys,
value: AllowedMetadataValueTypes,
keyPrefixes?: string[],
isMetadataKey: boolean = false,
): Promise<void> => {
const metadataKey = isMetadataKey ? key : getMetadataKey(key, keyPrefixes);
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 SourceIdInfo).id, metadataKey, value).response;
break;
default:
throw new Error(`requestUpdateMetadataValue: unknown holderType "${holderType}"`);
}
};
const requestUpdateMetadata = async (
metadataHolder: GqlMetaHolder,
holderType: MetadataHolderType,
keysToValues: MetadataKeyValuePair[],
keyPrefixes?: string[],
isMetadataKey?: boolean,
): Promise<void[]> =>
Promise.all(
keysToValues.map(([key, value]) =>
requestUpdateMetadataValue(metadataHolder, holderType, key, value, keyPrefixes, isMetadataKey),
),
);
export const requestUpdateServerMetadata = async (
keysToValues: MetadataKeyValuePair[],
keyPrefixes?: string[],
isMetadataKey?: boolean,
): Promise<void[]> => requestUpdateMetadata({}, 'global', keysToValues, keyPrefixes, isMetadataKey);
export const requestUpdateMangaMetadata = async (
manga: MangaIdInfo & GqlMetaHolder,
keysToValues: MetadataKeyValuePair[],
keyPrefixes?: string[],
isMetadataKey?: boolean,
): Promise<void[]> => requestUpdateMetadata(manga, 'manga', keysToValues, keyPrefixes, isMetadataKey);
export const requestUpdateChapterMetadata = async (
chapter: ChapterIdInfo & GqlMetaHolder,
keysToValues: MetadataKeyValuePair[],
keyPrefixes?: string[],
isMetadataKey?: boolean,
): Promise<void[]> => requestUpdateMetadata(chapter, 'chapter', keysToValues, keyPrefixes, isMetadataKey);
export const requestUpdateCategoryMetadata = async (
category: CategoryIdInfo & GqlMetaHolder,
keysToValues: MetadataKeyValuePair[],
keyPrefixes?: string[],
isMetadataKey?: boolean,
): Promise<void[]> => requestUpdateMetadata(category, 'category', keysToValues, keyPrefixes, isMetadataKey);
export const requestUpdateSourceMetadata = async (
source: SourceIdInfo & GqlMetaHolder,
keysToValue: MetadataKeyValuePair[],
keyPrefixes?: string[],
isMetadataKey?: boolean,
): Promise<void[]> => requestUpdateMetadata(source, 'source', keysToValue, keyPrefixes, isMetadataKey);
export const getMetadataUpdateFunction = (
type: MetadataHolderType,
metadataHolder:
| MetadataHolder
| (MangaIdInfo & MetadataHolder)
| (ChapterIdInfo & MetadataHolder)
| (CategoryIdInfo & MetadataHolder)
| (SourceIdInfo & MetadataHolder),
): ((keyValuePair: MetadataKeyValuePair[], keyPrefixes?: string[], isMetadataKey?: boolean) => Promise<void[]>) => {
switch (type) {
case 'global':
return (...args) => requestUpdateServerMetadata(...args);
case 'manga':
return (...args) =>
requestUpdateMangaMetadata(
{ id: (metadataHolder as MangaIdInfo).id, meta: convertToGqlMeta(metadataHolder.meta) },
...args,
);
case 'chapter':
return (...args) =>
requestUpdateChapterMetadata(
{ id: (metadataHolder as ChapterIdInfo).id, meta: convertToGqlMeta(metadataHolder.meta) },
...args,
);
case 'category':
return (...args) =>
requestUpdateCategoryMetadata(
{ id: (metadataHolder as CategoryIdInfo).id, meta: convertToGqlMeta(metadataHolder.meta) },
...args,
);
case 'source':
return (...args) =>
requestUpdateSourceMetadata(
{
id: (metadataHolder as SourceIdInfo).id,
meta: convertToGqlMeta(metadataHolder.meta),
},
...args,
);
default:
throw new Error(`Unexpected "type" (${type})`);
}
};
export const requestDeleteMetadataValue = async (
metadataHolder: GqlMetaHolder,
holderType: MetadataHolderType,
key: AppMetadataKeys,
keyPrefixes?: string[],
isMetadataKey: boolean = false,
): Promise<void> => {
const metadataKey = isMetadataKey ? key : getMetadataKey(key, keyPrefixes);
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 SourceIdInfo).id, metadataKey).response;
break;
default:
throw new Error(`requestDeleteMetadataValue: unknown holderType "${holderType}"`);
}
};
async function requestDeleteMetadata(
metadataHolder: GqlMetaHolder,
holderType: MetadataHolderType,
keys: AppMetadataKeys[],
keyPrefixes?: string[],
isMetadataKey?: boolean,
): Promise<void[]> {
return Promise.all(
keys.map((key) => requestDeleteMetadataValue(metadataHolder, holderType, key, keyPrefixes, isMetadataKey)),
);
}
export const requestDeleteServerMetadata = async (
keys: AppMetadataKeys[],
keyPrefixes?: string[],
isMetadataKey?: boolean,
): Promise<void[]> => requestDeleteMetadata({}, 'global', keys, keyPrefixes, isMetadataKey);
export const requestDeleteMangaMetadata = async (
manga: MangaIdInfo & GqlMetaHolder,
keys: AppMetadataKeys[],
keyPrefixes?: string[],
isMetadataKey?: boolean,
): Promise<void[]> => requestDeleteMetadata(manga, 'manga', keys, keyPrefixes, isMetadataKey);
export const requestDeleteChapterMetadata = async (
chapter: ChapterIdInfo & GqlMetaHolder,
keys: AppMetadataKeys[],
keyPrefixes?: string[],
isMetadataKey?: boolean,
): Promise<void[]> => requestDeleteMetadata(chapter, 'chapter', keys, keyPrefixes, isMetadataKey);
export const requestDeleteCategoryMetadata = async (
category: CategoryIdInfo & GqlMetaHolder,
keys: AppMetadataKeys[],
keyPrefixes?: string[],
isMetadataKey?: boolean,
): Promise<void[]> => requestDeleteMetadata(category, 'category', keys, keyPrefixes, isMetadataKey);
export const requestDeleteSourceMetadata = async (
source: SourceIdInfo & GqlMetaHolder,
keys: AppMetadataKeys[],
keyPrefixes?: string[],
isMetadataKey?: boolean,
): Promise<void[]> => requestDeleteMetadata(source, 'source', keys, keyPrefixes, isMetadataKey);
export const getMetadataDeleteFunction = (
type: MetadataHolderType,
metadataHolder:
| MetadataHolder
| (MangaIdInfo & MetadataHolder)
| (ChapterIdInfo & MetadataHolder)
| (CategoryIdInfo & MetadataHolder)
| (SourceIdInfo & MetadataHolder),
): ((metadataToDelete: AppMetadataKeys[], keyPrefixes?: string[], isMetadataKey?: boolean) => Promise<void[]>) => {
switch (type) {
case 'global':
return (...args) => requestDeleteServerMetadata(...args);
case 'manga':
return (...args) =>
requestDeleteMangaMetadata(
{ id: (metadataHolder as MangaIdInfo).id, meta: convertToGqlMeta(metadataHolder.meta) },
...args,
);
case 'chapter':
return (...args) =>
requestDeleteChapterMetadata(
{ id: (metadataHolder as ChapterIdInfo).id, meta: convertToGqlMeta(metadataHolder.meta) },
...args,
);
case 'category':
return (...args) =>
requestDeleteCategoryMetadata(
{ id: (metadataHolder as CategoryIdInfo).id, meta: convertToGqlMeta(metadataHolder.meta) },
...args,
);
case 'source':
return (...args) =>
requestDeleteSourceMetadata(
{
id: (metadataHolder as SourceIdInfo).id,
meta: convertToGqlMeta(metadataHolder.meta),
},
...args,
);
default:
throw new Error(`Unexpected "type" (${type})`);
}
};