Files
suwayomi-material-you-webui/src/util/metadata.ts
schroda ad0f072651 Feature/automatic chapter deletion more options (#484)
* Add missing translations keys

* [Codegen] Update to latest server schema changes

* [VersionMapping] Require server version "r1431" for preview

* Rename "deleteChaptersAutoMarkedRead" metadata setting

* Add more options to auto delete chapters while reading

Makes it possible to delete the last to fifth to last read chapter instead of only being able to delete the last read chapter
2023-12-05 22:20:13 +01:00

373 lines
11 KiB
TypeScript

/*
* Copyright (C) Contributors to the Suwayomi project
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
import {
AllowedMetadataValueTypes,
AppMetadataKeys,
GqlMetaHolder,
IMetadataMigration,
Metadata,
MetadataHolder,
MetadataKeyValuePair,
TCategory,
TChapter,
TManga,
} from '@/typings';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import { MetaType } from '@/lib/graphql/generated/graphql.ts';
const APP_METADATA_KEY_PREFIX = 'webUI_';
/**
* Once all changes have been done in the current branch, a new migration for all changes should be
* created.
*
* In case a value should be migrated for a specific key, and in the same migration the key is
* getting migrated, the key in the value migration is the "old" key (before the migration to the
* new key).
*
* Migration order (function "applyMetadataMigrations"):
* 1. app metadata key prefix
* 2. app metadata values
* 3. app metadata keys
*
* @example
* // changes:
* // commit: change key "X" to "Z"
* // commit: change key "Y" to "A"
* // commit: fix typo in reader setting "someTipo"
* // commit: add metadata migration
*
* // result:
* // old migrations:
* // const migrations = [
* // {
* // keys: [{ oldKey: 'loadNextonEnding', newKey: 'loadNextOnEnding' }],
* // }
* // ];
*
* // updated migrations
* const migrations = [
* {
* keys: [{ oldKey: 'loadNextonEnding', newKey: 'loadNextOnEnding' }],
* },
* {
* keys: [
* { oldKey: 'X', newKey: 'Z' },
* { oldKey: 'Y', newKey: 'A' },
* ],
* // all stored values in the metadata (of this app) will get migrated
* values: [
* {
* oldValue: 'someTipo',
* newValue: 'someTypo'
* },
* ],
* // to migrate only the value of a specific key (in this case of "someKey"):
* // values: [
* // {
* // key: 'someKey',
* // oldValue: 'someTipo',
* // newValue: 'someTypo',
* // },
* // ],
* },
* ];
*/
const migrations: IMetadataMigration[] = [
{
keys: [{ oldKey: 'loadNextonEnding', newKey: 'loadNextOnEnding' }],
},
{
keys: [{ oldKey: 'deleteChaptersAutoMarkedRead', newKey: 'deleteChaptersWhileReading' }],
},
];
const getAppKeyPrefixForMigration = (migrationId: number): string => {
const appKeyPrefix = migrations
.slice(0, migrationId)
.reverse()
.find((migration) => !!migration.appKeyPrefix);
return appKeyPrefix?.appKeyPrefix?.newPrefix ?? APP_METADATA_KEY_PREFIX;
};
const getMetadataKey = (key: string, appPrefix: string = APP_METADATA_KEY_PREFIX) => `${appPrefix}${key}`;
const doesMetadataKeyExistIn = (meta: Metadata | undefined, key: string, appPrefix?: string): boolean =>
Object.prototype.hasOwnProperty.call(meta ?? {}, getMetadataKey(key, appPrefix));
const convertValueFromMetadata = <T extends AllowedMetadataValueTypes = AllowedMetadataValueTypes>(
value: string,
): T => {
if (!Number.isNaN(+value)) {
return +value as T;
}
if (value === 'true' || value === 'false') {
return (value === 'true') as T;
}
if (value === 'undefined') {
return undefined as T;
}
return value as T;
};
export const convertFromGqlMeta = (gqlMetadata?: MetaType[]): Metadata | undefined => {
if (!gqlMetadata) {
return undefined;
}
const metadata: Metadata = {};
gqlMetadata.forEach(({ key, value }) => {
metadata[key] = value;
});
return metadata;
};
export const convertToGqlMeta = (metadata?: Metadata): MetaType[] | undefined => {
if (!metadata) {
return undefined;
}
return Object.entries(metadata).map(([key, value]) => ({ key, value }));
};
const getAppMetadataFrom = (meta: Metadata, appPrefix: string = APP_METADATA_KEY_PREFIX): Metadata => {
const appMetadata: Metadata = {};
Object.entries(meta).forEach(([key, value]) => {
if (key.startsWith(appPrefix)) {
appMetadata[key] = value;
}
});
return appMetadata;
};
const applyAppKeyPrefixMigration = (meta: Metadata, migration: IMetadataMigration): Metadata => {
const migratedMetadata: Metadata = { ...meta };
if (!migration.appKeyPrefix) {
return migratedMetadata;
}
const { oldPrefix, newPrefix } = migration.appKeyPrefix;
const oldAppMetadata = getAppMetadataFrom(meta, oldPrefix);
const newAppMetadata = getAppMetadataFrom(meta, newPrefix);
const missingMetadataKeys = Object.keys(oldAppMetadata).filter((key) => !Object.keys(newAppMetadata).includes(key));
const isMissingOldMetadata = missingMetadataKeys.length;
if (isMissingOldMetadata) {
missingMetadataKeys.forEach((oldKey) => {
const keyWithNewPrefix = oldKey.replace(oldPrefix, newPrefix);
migratedMetadata[keyWithNewPrefix] = oldAppMetadata[oldKey];
});
}
return migratedMetadata;
};
const applyMetadataValueMigration = (meta: Metadata, migration: IMetadataMigration, appKeyPrefix: string): Metadata => {
const migratedMetadata: Metadata = { ...meta };
if (!migration.values) {
return migratedMetadata;
}
const appMetadata = getAppMetadataFrom(meta, appKeyPrefix);
const metadataValueChanges = migration.values;
metadataValueChanges.forEach(({ key, oldValue, newValue }) => {
const migrateValue = (metaKey: string) => {
if (meta[metaKey] === oldValue) {
migratedMetadata[metaKey] = newValue;
}
};
const migrateValueOfAllAppKeys = key === undefined;
if (migrateValueOfAllAppKeys) {
Object.keys(appMetadata).forEach((metaKey) => {
migrateValue(metaKey);
});
return;
}
if (!doesMetadataKeyExistIn(meta, key, appKeyPrefix)) {
return;
}
migrateValue(getMetadataKey(key, appKeyPrefix));
});
return migratedMetadata;
};
const applyMetadataKeyMigration = (meta: Metadata, migration: IMetadataMigration): Metadata => {
const migratedMetadata: Metadata = { ...meta };
if (!migration.keys) {
return migratedMetadata;
}
const metadataKeyChanges = migration.keys;
metadataKeyChanges.forEach(({ oldKey, newKey }) => {
if (!doesMetadataKeyExistIn(meta, oldKey)) {
return;
}
if (doesMetadataKeyExistIn(meta, newKey)) {
return;
}
migratedMetadata[getMetadataKey(newKey)] = meta[getMetadataKey(oldKey)];
});
return migratedMetadata;
};
const applyMetadataMigrations = (meta?: Metadata): Metadata | undefined => {
if (!meta) {
return undefined;
}
const migrationToMetadata: [number, Metadata][] = [[0, meta]];
migrations.forEach((migration, index) => {
const migrationId = index + 1;
const metadataToMigrate = migrationToMetadata[migrationId - 1][1];
const appKeyPrefixMigrated = applyAppKeyPrefixMigration(metadataToMigrate, migration);
const metadataValuesMigrated = applyMetadataValueMigration(
appKeyPrefixMigrated,
migration,
getAppKeyPrefixForMigration(migrationId),
);
const metadataKeysMigrated = applyMetadataKeyMigration(metadataValuesMigrated, migration);
migrationToMetadata.push([migrationId, metadataKeysMigrated]);
});
const appliedMigration = migrationToMetadata.length > 1;
if (!appliedMigration) {
return { ...meta };
}
return migrationToMetadata.pop()![1];
};
export const getMetadataValueFrom = <Key extends AppMetadataKeys, Value extends AllowedMetadataValueTypes>(
{ meta }: MetadataHolder,
key: Key,
defaultValue?: Value,
applyMigrations: boolean = true,
): Value | undefined => {
const metadata = applyMigrations ? applyMetadataMigrations(meta) : meta;
if (metadata === undefined || !doesMetadataKeyExistIn(metadata, key)) {
return defaultValue;
}
return convertValueFromMetadata(metadata[getMetadataKey(key)]);
};
export const getMetadataFrom = <METADATA extends Partial<Metadata<AppMetadataKeys, AllowedMetadataValueTypes>>>(
{ meta }: MetadataHolder,
metadataWithDefaultValues: METADATA,
applyMigrations?: boolean,
): METADATA => {
const appMetadata = {} as METADATA;
Object.entries(metadataWithDefaultValues).forEach(([key, defaultValue]) => {
appMetadata[key as AppMetadataKeys] = getMetadataValueFrom(
{ meta },
key as AppMetadataKeys,
defaultValue,
applyMigrations,
);
});
return appMetadata;
};
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const wrapMetadataWithMetaKey = (wrap: boolean, metadata: Metadata): MetadataHolder => {
if (wrap) {
return {
meta: {
...metadata,
},
};
}
return {
...metadata,
};
};
type MetadataHolderType = 'manga' | 'chapter' | 'category' | 'global';
export const requestUpdateMetadataValue = async (
metadataHolder: GqlMetaHolder,
holderType: MetadataHolderType,
key: AppMetadataKeys,
value: AllowedMetadataValueTypes,
): Promise<void> => {
const metadataKey = getMetadataKey(key);
switch (holderType) {
case 'category':
await requestManager.setCategoryMeta((metadataHolder as TCategory).id, metadataKey, value).response;
break;
case 'chapter':
await requestManager.setChapterMeta((metadataHolder as TChapter).id, metadataKey, value).response;
break;
case 'global':
await requestManager.setGlobalMetadata(metadataKey, value).response;
break;
case 'manga':
await requestManager.setMangaMeta((metadataHolder as TManga).id, metadataKey, value).response;
break;
default:
throw new Error(`requestUpdateMetadataValue: unknown holderType "${holderType}"`);
}
};
export const requestUpdateMetadata = async (
metadataHolder: GqlMetaHolder,
holderType: MetadataHolderType,
keysToValues: MetadataKeyValuePair[],
): Promise<void[]> =>
Promise.all(keysToValues.map(([key, value]) => requestUpdateMetadataValue(metadataHolder, holderType, key, value)));
export const requestUpdateServerMetadata = async (
serverMetadata: MetaType[],
keysToValues: MetadataKeyValuePair[],
): Promise<void[]> => requestUpdateMetadata({ meta: serverMetadata }, 'global', keysToValues);
export const requestUpdateMangaMetadata = async (
manga: TManga,
keysToValues: MetadataKeyValuePair[],
): Promise<void[]> => requestUpdateMetadata(manga, 'manga', keysToValues);
export const requestUpdateChapterMetadata = async (
chapter: TChapter,
keysToValues: MetadataKeyValuePair[],
): Promise<void[]> => requestUpdateMetadata(chapter, 'chapter', keysToValues);
export const requestUpdateCategoryMetadata = async (
category: TCategory,
keysToValues: MetadataKeyValuePair[],
): Promise<void[]> => requestUpdateMetadata(category, 'category', keysToValues);