Rename folder "modules" to "features"
This commit is contained in:
655
src/features/metadata/Metadata.constants.ts
Normal file
655
src/features/metadata/Metadata.constants.ts
Normal 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}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
58
src/features/metadata/Metadata.types.ts
Normal file
58
src/features/metadata/Metadata.types.ts
Normal 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';
|
||||
45
src/features/metadata/Metadata.utils.ts
Normal file
45
src/features/metadata/Metadata.utils.ts
Normal 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));
|
||||
33
src/features/metadata/services/MetadataApolloCacheHandler.ts
Normal file
33
src/features/metadata/services/MetadataApolloCacheHandler.ts
Normal 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()];
|
||||
};
|
||||
43
src/features/metadata/services/MetadataConverter.ts
Normal file
43
src/features/metadata/services/MetadataConverter.ts
Normal 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 }));
|
||||
};
|
||||
388
src/features/metadata/services/MetadataMigrations.ts
Normal file
388
src/features/metadata/services/MetadataMigrations.ts
Normal 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;
|
||||
};
|
||||
106
src/features/metadata/services/MetadataReader.ts
Normal file
106
src/features/metadata/services/MetadataReader.ts
Normal 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;
|
||||
}
|
||||
265
src/features/metadata/services/MetadataUpdater.ts
Normal file
265
src/features/metadata/services/MetadataUpdater.ts
Normal 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})`);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user