Files
suwayomi-material-you-webui/src/features/metadata/Metadata.constants.ts
Sean Smith 8b8da30335 feat: add Source filter to library filtering system (#1131)
* feat: add Source filter to library filtering system

Add a new Source filter section to the library options panel that allows
users to filter their library manga by source (e.g. MangaDex, MangaSee).

- Add hasSource to LibraryOptions type and category metadata
- Add source filtering logic in useGetVisibleLibraryMangas
- Add Source section with three-state checkboxes in filter UI
- Show loading spinner while source list loads
- Include source filter in active filter indicator

Closes #1057

* refactor: address review feedback for source filter

- Remove skip option, let query always run
- Use uniqBy from lodash/fp for deduplication
- Remove loading spinner (data available quickly)
- Wrap source section in length check

* docs: add source filter to changelog

---------

Co-authored-by: Copilot <noreply@github.com>
2026-07-21 00:09:06 +02:00

767 lines
23 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 deepmerge from '@mui/utils/deepmerge';
import type { AppMetadataKeys, IMetadataMigration } from '@/features/metadata/Metadata.types.ts';
import type { IReaderSettings, ReaderCustomFilter, SafeAreaInset } from '@/features/reader/Reader.types.ts';
import { ProgressBarPosition, ReaderPageScaleMode, ReadingMode } from '@/features/reader/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/settings/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 type { SortSettings } from '@/features/migration/Migration.types.ts';
import type { ISourceMetadata } from '@/features/source/Source.types.ts';
import type { LibraryOptions } from '@/features/library/Library.types.ts';
import type { MetadataThemeSettings } from '@/features/theme/AppTheme.types.ts';
import type { TapZoneInvertMode } from '@/features/reader/tap-zones/TapZoneLayout.types.ts';
import { detectLocale, getISOLanguage } from '@/lib/ISOLanguageUtil.ts';
import type { I18nResourceCode } from '@/i18n';
import { i18nResources } from '@/i18n';
import { toUniqueLanguageCodes } from '@/base/utils/Languages.ts';
import { assertIsDefined } from '@/base/Asserts.ts';
const MATCH_ARRAY_NUMBERS = /^\[\d+(?:,\d+)*]$/g;
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, defaultValue: number): number => {
if (Number.isNaN(+value)) {
return defaultValue;
}
return +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,
},
migrateMetadata: {
convert: convertToBoolean,
},
migrateSortSettings: {
convert: convertToObject<SortSettings>,
},
hideLibraryEntries: {
convert: convertToBoolean,
},
updateProgressAfterReading: {
convert: convertToBoolean,
},
updateProgressManualMarkRead: {
convert: convertToBoolean,
},
webUIInformAvailableUpdate: {
convert: convertToBoolean,
},
webUIInformVersionUpdated: {
convert: convertToBoolean,
},
serverInformAvailableUpdate: {
convert: convertToBoolean,
},
serverInformVersionUpdated: {
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']>,
},
hasSource: {
convert: convertToObject<LibraryOptions['hasSource']>,
},
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)
},
useAutoBackgroundColorContinuousMode: {
convert: convertToBoolean,
},
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,
},
shouldWakeLockScreen: {
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,
},
browseLanguages: {
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[]>,
},
locale: {
convert: convertToString,
toConstrainedValue: (value: string) => {
const locale = getISOLanguage(value)?.isoCode;
if (!locale || !i18nResources.includes(locale as I18nResourceCode)) {
return detectLocale();
}
return locale;
},
},
safeAreaInset: {
convert: convertToObject<SafeAreaInset>,
},
} 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',
'hasSource',
// sort
'sortBy',
'sortDesc',
// display
'showDownloadBadge',
'showUnreadBadge',
'showTabSize',
'showContinueReadingButton',
// client
'devices',
// migration
'migrateChapters',
'migrateCategories',
'migrateTracking',
'deleteChapters',
'migrateMetadata',
'migrateSortSettings',
// browse
'hideLibraryEntries',
'browseLanguages',
'showNsfw',
'lastUsedSourceId',
'shouldShowOnlySourcesWithResults',
// history
'hideHistory',
// tracking
'updateProgressAfterReading',
'updateProgressManualMarkRead',
// updates
'webUIInformAvailableUpdate',
'webUIInformVersionUpdated',
'serverInformAvailableUpdate',
'serverInformVersionUpdated',
// sources
'savedSearches',
'isPinned',
'isEnabled',
// themes
'locale',
'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).
*
* For new metadata migrations, the value migrations have already been applied while the key migrations are still outstanding (same as for the value migrations)
*
* Migration order (function "applyMetadataMigrations"):
* 1. app metadata key prefix
* 2. app metadata values
* 3. create new app metadata with values from existing metadata
* 4. app metadata keys
* 5. 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}`,
},
],
},
{
values: [
{
key: 'sourceLanguages',
oldValue: /^\[.*]$/g,
newValue: (sourceLanguagesValue, key, oldMetadata) => {
const extensionLanguagesKey = key.replace('sourceLanguages', 'extensionLanguages');
const extensionLanguagesValue = oldMetadata[extensionLanguagesKey];
const convertedSourceLanguages = convertToObject<string[]>(sourceLanguagesValue, []);
const convertedExtensionLanguages = convertToObject<string[]>(extensionLanguagesValue, []);
return JSON.stringify(
toUniqueLanguageCodes([...convertedSourceLanguages, ...convertedExtensionLanguages]),
);
},
},
],
keys: [{ oldKey: 'sourceLanguages', newKey: 'browseLanguages' }],
deleteKeys: ['sourceLanguages', 'extensionLanguages'],
},
{
values: [
...(() =>
['pageScaleMode', 'readingDirection', 'readingMode', 'tapZoneLayout'].map((key) => ({
key,
oldValue: MATCH_ARRAY_NUMBERS,
newValue: (value) => {
const array = jsonSaveParse<number[]>(value);
assertIsDefined(array);
return JSON.stringify(array[0]);
},
})) satisfies IMetadataMigration['values'])(),
],
},
{
values: [
{
key: 'browseLanguages',
oldValue: /^\[.*]$/g,
newValue: (browseLanguagesValue) => {
const convertedBrowseLanguages = convertToObject<string[]>(browseLanguagesValue, []);
const convertedToValidIsoCodes = convertedBrowseLanguages.map(
(language) => getISOLanguage(language)?.isoCode ?? language,
);
return JSON.stringify(convertedToValidIsoCodes);
},
},
],
},
];
export const ALL_APP_METADATA_KEY_PREFIXES: string[] = [
...METADATA_MIGRATIONS.reduce<Set<string>>((acc, migration) => {
if (migration.appKeyPrefix?.oldPrefix) {
acc.add(migration.appKeyPrefix.oldPrefix);
}
return acc;
}, new Set()),
APP_METADATA_KEY_PREFIX,
];