From d20ca69dc02fa9fe4d9056b02bc61c0c34217123 Mon Sep 17 00:00:00 2001 From: schroda <50052685+schroda@users.noreply.github.com> Date: Mon, 22 Dec 2025 21:33:23 +0100 Subject: [PATCH] Extract "image processing" components --- .../settings/ImageProcessing.utils.ts | 201 ++++++ src/features/settings/Settings.constants.ts | 26 +- src/features/settings/Settings.types.ts | 28 +- .../settings/components/images/Header.tsx | 90 +++ .../settings/components/images/Headers.tsx | 60 ++ .../components/images/MimeTypeTextField.tsx | 57 ++ .../settings/components/images/Processing.tsx | 254 +++++++ .../screens/ImageProcessingSetting.tsx | 633 +----------------- 8 files changed, 735 insertions(+), 614 deletions(-) create mode 100644 src/features/settings/ImageProcessing.utils.ts create mode 100644 src/features/settings/components/images/Header.tsx create mode 100644 src/features/settings/components/images/Headers.tsx create mode 100644 src/features/settings/components/images/MimeTypeTextField.tsx create mode 100644 src/features/settings/components/images/Processing.tsx diff --git a/src/features/settings/ImageProcessing.utils.ts b/src/features/settings/ImageProcessing.utils.ts new file mode 100644 index 00000000..99b0bb1d --- /dev/null +++ b/src/features/settings/ImageProcessing.utils.ts @@ -0,0 +1,201 @@ +/* + * 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 { d } from 'koration'; +import { + ImageProcessingTargetMode, + TSettingsDownloadConversion, + TSettingsDownloadConversionHeader, +} from '@/features/settings/Settings.types.ts'; +import { + DEFAULT_MIME_TYPE, + IMAGE_PROCESSING_CALL_TIMEOUT, + IMAGE_PROCESSING_COMPRESSION, + IMAGE_PROCESSING_CONNECT_TIMEOUT, + MIME_TYPE_PREFIX, +} from '@/features/settings/Settings.constants.ts'; +import { + SettingsDownloadConversion, + SettingsDownloadConversionHeader, + SettingsDownloadConversionType, +} from '@/lib/graphql/generated/graphql.ts'; + +let COUNTER = 0; + +const normalizeMimeType = (mimeType: string): string => mimeType.replace(MIME_TYPE_PREFIX, ''); + +export const isDefaultMimeType = (mimeType: string): boolean => + normalizeMimeType(mimeType.toLowerCase().trim()) === DEFAULT_MIME_TYPE; + +export const isDuplicateHeader = ( + header: string, + index: number, + headers: SettingsDownloadConversionType['headers'], +): boolean => headers?.slice(0, index).some(({ name }) => name === header) ?? false; + +const hasDuplicateHeaders = (headers: SettingsDownloadConversionType['headers']): boolean => + headers?.some((header, index) => isDuplicateHeader(header.name, index, headers)) ?? false; + +export const isDuplicateConversion = ( + mimeType: string, + index: number, + conversions: SettingsDownloadConversion[], +): boolean => conversions.slice(0, index).some((conversion) => conversion.mimeType === mimeType); + +export const isUrlTargetMode = (target: string): boolean => target !== '' && !!target.match(/^https?:\/\/.+/); + +const isValidNumberSetting = (value: number | null | undefined, min: number, max: number): boolean => + value == null || (value >= min && value <= max); + +export const isValidCallTimeoutSetting = (timeout: string | null | undefined): boolean => + isValidNumberSetting( + timeout ? d(timeout).seconds.inWholeSeconds : null, + IMAGE_PROCESSING_CALL_TIMEOUT.min, + IMAGE_PROCESSING_CALL_TIMEOUT.max, + ); + +export const isValidConnectTimeoutSetting = (timeout: string | null | undefined): boolean => + isValidNumberSetting( + timeout ? d(timeout).seconds.inWholeSeconds : null, + IMAGE_PROCESSING_CONNECT_TIMEOUT.min, + IMAGE_PROCESSING_CONNECT_TIMEOUT.max, + ); + +export const isValidCompressionLevel = (compression: number | null | undefined): boolean => + isValidNumberSetting(compression, IMAGE_PROCESSING_COMPRESSION.min, IMAGE_PROCESSING_COMPRESSION.max); + +const isUnsetConversion = (mimeType: string, target: string): boolean => mimeType === '' && target === ''; + +const isInvalidTarget = (target: string, mode: ImageProcessingTargetMode, mimeType: string): boolean => { + if (isDefaultMimeType(mimeType) && !target) { + return false; + } + + return mode === ImageProcessingTargetMode.URL && !isUrlTargetMode(target); +}; + +export const containsInvalidConversion = (conversions: TSettingsDownloadConversion[]): boolean => + conversions.some( + ({ mimeType, compressionLevel, target, callTimeout, connectTimeout, headers, mode }, index) => + isUnsetConversion(mimeType, target) || + isInvalidTarget(target, mode, mimeType) || + !isValidCompressionLevel(compressionLevel) || + !isValidCallTimeoutSetting(callTimeout) || + !isValidConnectTimeoutSetting(connectTimeout) || + isDuplicateConversion(mimeType, index, conversions) || + hasDuplicateHeaders(headers), + ); + +export const getTargetMode = (target: string): ImageProcessingTargetMode => { + if (isUrlTargetMode(target)) { + return ImageProcessingTargetMode.URL; + } + + return ImageProcessingTargetMode.IMAGE; +}; + +export const addStableIdToHeaders = ( + headers: (SettingsDownloadConversionHeader | TSettingsDownloadConversionHeader)[], +): TSettingsDownloadConversionHeader[] => + headers.map((header) => ({ + // eslint-disable-next-line no-plusplus + id: (header as TSettingsDownloadConversionHeader).id ?? COUNTER++, + ...header, + })); + +export const addStableIdToConversions = ( + conversions: (SettingsDownloadConversion | TSettingsDownloadConversion)[], +): TSettingsDownloadConversion[] => + conversions.map((conversion) => ({ + // eslint-disable-next-line no-plusplus + id: (conversion as TSettingsDownloadConversion).id ?? COUNTER++, + ...conversion, + mode: getTargetMode(conversion.target), + headers: conversion.headers ? addStableIdToHeaders(conversion.headers) : null, + })); + +export const normalizeConversions = (conversions: TSettingsDownloadConversion[]): TSettingsDownloadConversion[] => + conversions.map((conversion) => ({ + ...conversion, + mimeType: normalizeMimeType(conversion.mimeType), + target: normalizeMimeType(conversion.target), + })); + +const toValidServerMimeType = (mimeType: string): string => { + if (isDefaultMimeType(mimeType)) { + return DEFAULT_MIME_TYPE; + } + + return `${MIME_TYPE_PREFIX}${mimeType}`; +}; + +export const toValidServerConversions = (conversions: TSettingsDownloadConversion[]): SettingsDownloadConversion[] => + conversions + .filter(({ mimeType, target }) => !!mimeType && !!target) + .map(({ id, mode, ...conversion }) => ({ + ...conversion, + mimeType: toValidServerMimeType(conversion.mimeType), + target: isUrlTargetMode(conversion.target) ? conversion.target : `${MIME_TYPE_PREFIX}${conversion.target}`, + headers: conversion.headers?.map(({ id: headerId, ...header }) => header) ?? null, + })); + +export const maybeAddDefault = (conversions: TSettingsDownloadConversion[]): TSettingsDownloadConversion[] => { + const isDefaultDefined = conversions.some(({ mimeType }) => isDefaultMimeType(mimeType)); + + return [ + ...(isDefaultDefined + ? [] + : [ + { + id: -1, + mode: ImageProcessingTargetMode.IMAGE, + mimeType: DEFAULT_MIME_TYPE, + target: '', + compressionLevel: null, + headers: null, + callTimeout: null, + connectTimeout: null, + }, + ]), + ...conversions, + ]; +}; + +const normalizeHeaders = ( + headers: SettingsDownloadConversionType['headers'], +): SettingsDownloadConversionType['headers'] => headers?.filter(({ name }) => name !== ''); + +export const didUpdateConversions = ( + conversions: TSettingsDownloadConversion[], + tmpConversions: TSettingsDownloadConversion[], +): boolean => { + if (conversions.length !== tmpConversions.length) { + return true; + } + + return conversions.some(({ mimeType, target, compressionLevel, callTimeout, connectTimeout, headers }, index) => { + const tmpConversion = tmpConversions[index]; + + const orgHeaders = normalizeHeaders(headers); + const tmpHeaders = normalizeHeaders(tmpConversion.headers); + + return ( + normalizeMimeType(mimeType) !== tmpConversion.mimeType || + normalizeMimeType(target) !== tmpConversion.target || + compressionLevel !== tmpConversion.compressionLevel || + callTimeout !== tmpConversion.callTimeout || + connectTimeout !== tmpConversion.connectTimeout || + (orgHeaders?.length ?? 0) !== (tmpHeaders?.length ?? 0) || + !!orgHeaders?.some( + (header, headerIndex) => + header.name !== tmpHeaders?.[headerIndex]?.name || + header.value !== tmpHeaders?.[headerIndex]?.value, + ) + ); + }); +}; diff --git a/src/features/settings/Settings.constants.ts b/src/features/settings/Settings.constants.ts index c1e4314e..b7abacf7 100644 --- a/src/features/settings/Settings.constants.ts +++ b/src/features/settings/Settings.constants.ts @@ -9,7 +9,11 @@ import { d } from 'koration'; import { DEFAULT_DEVICE } from '@/features/device/services/Device.ts'; import { DEFAULT_SORT_SETTINGS } from '@/features/migration/Migration.constants.ts'; -import { GlobalUpdateSkipEntriesSettings, MetadataServerSettings } from '@/features/settings/Settings.types.ts'; +import { + GlobalUpdateSkipEntriesSettings, + ImageProcessingTargetMode, + MetadataServerSettings, +} from '@/features/settings/Settings.types.ts'; import { GridLayout, TranslationKey } from '@/base/Base.types.ts'; import { getDefaultLanguages } from '@/base/utils/Languages.ts'; import { SelectSettingValue, SelectSettingValueDisplayInfo } from '@/base/components/settings/SelectSetting.tsx'; @@ -272,3 +276,23 @@ export const IMAGE_PROCESSING_CONNECT_TIMEOUT = { max: d(10).minutes.inWholeSeconds, step: d(10).seconds.inWholeSeconds, }; + +const IMAGE_PROCESSING_TARGET_MODES = Object.values(ImageProcessingTargetMode); +const IMAGE_PROCESSING_TARGET_MODES_TO_TRANSLATION_KEY: { + [flavor in ImageProcessingTargetMode]: SelectSettingValueDisplayInfo; +} = { + [ImageProcessingTargetMode.IMAGE]: { + text: 'download.settings.conversion.target_modes.image.title', + description: 'download.settings.conversion.target_modes.image.description', + }, + [ImageProcessingTargetMode.URL]: { + text: 'download.settings.conversion.target_modes.url.title', + description: 'download.settings.conversion.target_modes.image.description', + }, +}; +export const IMAGE_PROCESSING_TARGET_MODES_SELECT_VALUES: SelectSettingValue[] = + IMAGE_PROCESSING_TARGET_MODES.map((mode) => [mode, IMAGE_PROCESSING_TARGET_MODES_TO_TRANSLATION_KEY[mode]]); + +export const IMAGE_PROCESSING_INPUT_WIDTH = 250; +export const DEFAULT_MIME_TYPE = 'default'; +export const MIME_TYPE_PREFIX = 'image/'; diff --git a/src/features/settings/Settings.types.ts b/src/features/settings/Settings.types.ts index 54f252ae..d2b14005 100644 --- a/src/features/settings/Settings.types.ts +++ b/src/features/settings/Settings.types.ts @@ -14,7 +14,12 @@ import { MetadataBrowseSettings } from '@/features/browse/Browse.types.ts'; import { MetadataTrackingSettings } from '@/features/tracker/Tracker.types.ts'; import { MetadataUpdateSettings } from '@/features/app-updates/AppUpdateChecker.types.ts'; import { MetadataThemeSettings } from '@/features/theme/AppTheme.types.ts'; -import { GetServerSettingsQuery } from '@/lib/graphql/generated/graphql.ts'; +import { + GetServerSettingsQuery, + Maybe, + SettingsDownloadConversion, + SettingsDownloadConversionHeader, +} from '@/lib/graphql/generated/graphql.ts'; import { MetadataHistorySettings } from '@/features/history/History.types.ts'; export type MetadataServerSettingKeys = keyof MetadataServerSettings; @@ -53,3 +58,24 @@ export type GlobalUpdateSkipEntriesSettings = Pick< >; export type LibrarySettingsType = Pick; + +export enum ImageProcessingTargetMode { + IMAGE = 'image', + URL = 'url', +} + +export type TSettingsDownloadConversionHeader = SettingsDownloadConversionHeader & { + /** + * The conversion object does not have a stable key, which causes issues when editing the settings + */ + id: number; +}; + +export type TSettingsDownloadConversion = Omit & { + /** + * The conversion object does not have a stable key, which causes issues when editing the settings + */ + id: number; + mode: ImageProcessingTargetMode; + headers?: Maybe; +}; diff --git a/src/features/settings/components/images/Header.tsx b/src/features/settings/components/images/Header.tsx new file mode 100644 index 00000000..aef49e2d --- /dev/null +++ b/src/features/settings/components/images/Header.tsx @@ -0,0 +1,90 @@ +/* + * 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 { useTranslation } from 'react-i18next'; +import TextField from '@mui/material/TextField'; +import Stack from '@mui/material/Stack'; +import DeleteIcon from '@mui/icons-material/Delete'; +import IconButton from '@mui/material/IconButton'; +import { useTheme } from '@mui/material/styles'; +import { CustomTooltip } from '@/base/components/CustomTooltip.tsx'; +import { IMAGE_PROCESSING_INPUT_WIDTH } from '@/features/settings/Settings.constants.ts'; +import { TSettingsDownloadConversionHeader } from '@/features/settings/Settings.types'; + +export const Header = ({ + id, + name, + value, + onChange, + isDuplicate, +}: { + isDuplicate: boolean; + onChange: (header: TSettingsDownloadConversionHeader | null) => void; +} & TSettingsDownloadConversionHeader) => { + const { t } = useTranslation(); + const theme = useTheme(); + + return ( + + + + onChange({ + id, + name: e.target.value, + value, + }) + } + /> + + onChange({ + id, + name, + value: e.target.value, + }) + } + /> + + + { + onChange(null); + }} + > + + + + + ); +}; diff --git a/src/features/settings/components/images/Headers.tsx b/src/features/settings/components/images/Headers.tsx new file mode 100644 index 00000000..d511bd53 --- /dev/null +++ b/src/features/settings/components/images/Headers.tsx @@ -0,0 +1,60 @@ +/* + * 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 { useTranslation } from 'react-i18next'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import Collapse from '@mui/material/Collapse'; +import { Maybe } from '@/lib/graphql/generated/graphql.ts'; +import { addStableIdToHeaders, isDuplicateHeader } from '@/features/settings/ImageProcessing.utils.ts'; +import { Header } from '@/features/settings/components/images/Header.tsx'; +import { TSettingsDownloadConversionHeader } from '@/features/settings/Settings.types.ts'; + +export const Headers = ({ + open, + headers, + onChange, +}: { + open: boolean; + headers: Maybe; + onChange: (headers: Maybe) => void; +}) => { + const { t } = useTranslation(); + + return ( + + + {t('download.settings.conversion.headers.title')} + {headers?.map((header, index) => ( +
{ + const isDeletion = updatedHeader == null; + if (isDeletion) { + onChange(headers?.toSpliced(index, 1)); + return; + } + + onChange((headers ?? []).toSpliced(index, 1, updatedHeader)); + }} + /> + ))} + + + + ); +}; diff --git a/src/features/settings/components/images/MimeTypeTextField.tsx b/src/features/settings/components/images/MimeTypeTextField.tsx new file mode 100644 index 00000000..c4b48c40 --- /dev/null +++ b/src/features/settings/components/images/MimeTypeTextField.tsx @@ -0,0 +1,57 @@ +/* + * 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 { useTranslation } from 'react-i18next'; +import TextField from '@mui/material/TextField'; +import InputAdornment from '@mui/material/InputAdornment'; +import { ImageProcessingTargetMode } from '@/features/settings/Settings.types.ts'; +import { IMAGE_PROCESSING_INPUT_WIDTH, MIME_TYPE_PREFIX } from '@/features/settings/Settings.constants.ts'; + +export const MimeTypeTextField = ({ + shouldAutoFocus, + isDefault, + isDuplicate, + label, + value, + onUpdate, + mode, +}: { + shouldAutoFocus: boolean; + isDefault: boolean; + isDuplicate: boolean; + label: string; + value: string; + onUpdate: (value: string) => void; + mode: ImageProcessingTargetMode; +}) => { + const { t } = useTranslation(); + + const isImageMode = mode === ImageProcessingTargetMode.IMAGE; + const isValidUrl = !value.length || value.match(/^https?:\/\//); + const isValid = !isDuplicate && (isImageMode || isValidUrl); + + return ( + {isImageMode ? MIME_TYPE_PREFIX : null} + ), + }, + }} + onChange={(e) => onUpdate(e.target.value.trim())} + /> + ); +}; diff --git a/src/features/settings/components/images/Processing.tsx b/src/features/settings/components/images/Processing.tsx new file mode 100644 index 00000000..754c7df6 --- /dev/null +++ b/src/features/settings/components/images/Processing.tsx @@ -0,0 +1,254 @@ +/* + * 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 { useTranslation } from 'react-i18next'; +import { useState } from 'react'; +import TextField from '@mui/material/TextField'; +import Stack from '@mui/material/Stack'; +import DeleteIcon from '@mui/icons-material/Delete'; +import IconButton from '@mui/material/IconButton'; +import InputAdornment from '@mui/material/InputAdornment'; +import Button from '@mui/material/Button'; +import { useTheme } from '@mui/material/styles'; +import MenuItem from '@mui/material/MenuItem'; +import InputLabel from '@mui/material/InputLabel'; +import FormControl from '@mui/material/FormControl'; +import { d } from 'koration'; +import { useElementSize } from '@mantine/hooks'; +import { Headers } from '@/features/settings/components/images/Headers.tsx'; +import { CustomTooltip } from '@/base/components/CustomTooltip.tsx'; +import { TranslationKey } from '@/base/Base.types.ts'; +import { + IMAGE_PROCESSING_CALL_TIMEOUT, + IMAGE_PROCESSING_COMPRESSION, + IMAGE_PROCESSING_CONNECT_TIMEOUT, + IMAGE_PROCESSING_INPUT_WIDTH, + IMAGE_PROCESSING_TARGET_MODES_SELECT_VALUES, +} from '@/features/settings/Settings.constants.ts'; +import { Select } from '@/base/components/inputs/Select.tsx'; +import { TypographyMaxLines } from '@/base/components/texts/TypographyMaxLines.tsx'; +import { MimeTypeTextField } from '@/features/settings/components/images/MimeTypeTextField.tsx'; +import { ImageProcessingTargetMode, TSettingsDownloadConversion } from '@/features/settings/Settings.types.ts'; +import { + isDefaultMimeType, + isValidCallTimeoutSetting, + isValidCompressionLevel, + isValidConnectTimeoutSetting, +} from '@/features/settings/ImageProcessing.utils.ts'; + +export const Processing = ({ + conversion, + conversion: { mode, mimeType, target, compressionLevel, headers, callTimeout, connectTimeout }, + onChange, + isDuplicate, +}: { + conversion: TSettingsDownloadConversion; + onChange: (newConversion: TSettingsDownloadConversion | null) => void; + isDuplicate: boolean; +}) => { + const { t } = useTranslation(); + const theme = useTheme(); + + const { ref: textFieldRef, height: textFieldHeight } = useElementSize(); + + const [areHeadersCollapsed, setAreHeadersCollapsed] = useState(true); + + const isImageMode = mode === ImageProcessingTargetMode.IMAGE; + + const isCallTimeoutValid = isValidCallTimeoutSetting(callTimeout); + const isConnectTimeoutValid = isValidConnectTimeoutSetting(connectTimeout); + const isCompressionLevelValid = isValidCompressionLevel(compressionLevel); + const isDefault = isDefaultMimeType(mimeType) && !isDuplicate; + const isDisabled = isDefault && !target && compressionLevel == null; + + return ( + + + + { + onChange({ + ...conversion, + mimeType: value, + }); + }} + /> + + + + {t('download.settings.conversion.target_modes.title')} + + + + + onChange({ + ...conversion, + target: value, + }) + } + /> + {isImageMode ? ( + { + onChange({ + ...conversion, + compressionLevel: e.target.value ? Number(e.target.value) : null, + }); + }} + /> + ) : ( + <> + + {t('global.date.label.second_other')} + + ), + }, + }} + onChange={(e) => { + onChange({ + ...conversion, + callTimeout: e.target.value + ? d(Number(e.target.value)).seconds.toISOString() + : null, + }); + }} + /> + + {t('global.date.label.second_other')} + + ), + }, + }} + onChange={(e) => { + onChange({ + ...conversion, + connectTimeout: e.target.value + ? d(Number(e.target.value)).seconds.toISOString() + : null, + }); + }} + /> + + + )} + + + { + onChange(null); + }} + > + + + + + + onChange({ + ...conversion, + headers: updatedHeaders, + }) + } + /> + + ); +}; diff --git a/src/features/settings/screens/ImageProcessingSetting.tsx b/src/features/settings/screens/ImageProcessingSetting.tsx index 87f10260..6395d6ac 100644 --- a/src/features/settings/screens/ImageProcessingSetting.tsx +++ b/src/features/settings/screens/ImageProcessingSetting.tsx @@ -8,36 +8,9 @@ import { useTranslation } from 'react-i18next'; import { useState } from 'react'; -import TextField from '@mui/material/TextField'; import Stack from '@mui/material/Stack'; -import DeleteIcon from '@mui/icons-material/Delete'; -import IconButton from '@mui/material/IconButton'; -import InputAdornment from '@mui/material/InputAdornment'; import Typography from '@mui/material/Typography'; import Button from '@mui/material/Button'; -import { useTheme } from '@mui/material/styles'; -import MenuItem from '@mui/material/MenuItem'; -import InputLabel from '@mui/material/InputLabel'; -import FormControl from '@mui/material/FormControl'; -import { d } from 'koration'; -import Collapse from '@mui/material/Collapse'; -import { useElementSize } from '@mantine/hooks'; -import { TypographyMaxLines } from '@/base/components/texts/TypographyMaxLines.tsx'; -import { CustomTooltip } from '@/base/components/CustomTooltip.tsx'; -import { - Maybe, - SettingsDownloadConversion, - SettingsDownloadConversionHeader, - SettingsDownloadConversionType, -} from '@/lib/graphql/generated/graphql.ts'; -import { SelectSettingValue, SelectSettingValueDisplayInfo } from '@/base/components/settings/SelectSetting.tsx'; -import { Select } from '@/base/components/inputs/Select.tsx'; -import { TranslationKey } from '@/base/Base.types.ts'; -import { - IMAGE_PROCESSING_CALL_TIMEOUT, - IMAGE_PROCESSING_COMPRESSION, - IMAGE_PROCESSING_CONNECT_TIMEOUT, -} from '@/features/settings/Settings.constants.ts'; import { requestManager } from '@/lib/requests/RequestManager.ts'; import { ServerSettings } from '@/features/settings/Settings.types.ts'; import { makeToast } from '@/base/utils/Toast.ts'; @@ -47,589 +20,16 @@ import { EmptyViewAbsoluteCentered } from '@/base/components/feedback/EmptyViewA import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts'; import { assertIsDefined } from '@/base/Asserts.ts'; import { useAppTitle } from '@/features/navigation-bar/hooks/useAppTitle.ts'; - -export type TSettingsDownloadConversionHeader = SettingsDownloadConversionHeader & { - /** - * The conversion object does not have a stable key, which causes issues when editing the settings - */ - id: number; -}; - -export type TSettingsDownloadConversion = Omit & { - /** - * The conversion object does not have a stable key, which causes issues when editing the settings - */ - id: number; - mode: TargetMode; - headers?: Maybe; -}; - -let COUNTER = 0; - -const INPUT_WIDTH = 250; - -const DEFAULT_MIME_TYPE = 'default'; -const MIME_TYPE_PREFIX = 'image/'; - -enum TargetMode { - IMAGE = 'image', - URL = 'url', -} - -const TARGET_MODES = Object.values(TargetMode); -const TARGET_MODES_TO_TRANSLATION_KEY: { [flavor in TargetMode]: SelectSettingValueDisplayInfo } = { - [TargetMode.IMAGE]: { - text: 'download.settings.conversion.target_modes.image.title', - description: 'download.settings.conversion.target_modes.image.description', - }, - [TargetMode.URL]: { - text: 'download.settings.conversion.target_modes.url.title', - description: 'download.settings.conversion.target_modes.image.description', - }, -}; -export const TARGET_MODES_SELECT_VALUES: SelectSettingValue[] = TARGET_MODES.map((mode) => [ - mode, - TARGET_MODES_TO_TRANSLATION_KEY[mode], -]); - -const normalizeMimeType = (mimeType: string): string => mimeType.replace(MIME_TYPE_PREFIX, ''); - -const isDefaultMimeType = (mimeType: string): boolean => - normalizeMimeType(mimeType.toLowerCase().trim()) === DEFAULT_MIME_TYPE; - -const isDuplicateHeader = ( - header: string, - index: number, - headers: SettingsDownloadConversionType['headers'], -): boolean => headers?.slice(0, index).some(({ name }) => name === header) ?? false; - -const hasDuplicateHeaders = (headers: SettingsDownloadConversionType['headers']): boolean => - headers?.some((header, index) => isDuplicateHeader(header.name, index, headers)) ?? false; - -const isDuplicateConversion = (mimeType: string, index: number, conversions: SettingsDownloadConversion[]): boolean => - conversions.slice(0, index).some((conversion) => conversion.mimeType === mimeType); - -export const isUrlTargetMode = (target: string): boolean => target !== '' && !!target.match(/^https?:\/\//); - -const isValidNumberSetting = (value: number | null | undefined, min: number, max: number): boolean => - value == null || (value >= min && value <= max); - -const isValidCallTimeoutSetting = (timeout: string | null | undefined): boolean => - isValidNumberSetting( - timeout ? d(timeout).seconds.inWholeSeconds : null, - IMAGE_PROCESSING_CALL_TIMEOUT.min, - IMAGE_PROCESSING_CALL_TIMEOUT.max, - ); - -const isValidConnectTimeoutSetting = (timeout: string | null | undefined): boolean => - isValidNumberSetting( - timeout ? d(timeout).seconds.inWholeSeconds : null, - IMAGE_PROCESSING_CONNECT_TIMEOUT.min, - IMAGE_PROCESSING_CONNECT_TIMEOUT.max, - ); - -const isValidCompressionLevel = (compression: number | null | undefined): boolean => - isValidNumberSetting(compression, IMAGE_PROCESSING_COMPRESSION.min, IMAGE_PROCESSING_COMPRESSION.max); - -const isUnsetConversion = (mimeType: string, target: string): boolean => mimeType === '' && target === ''; - -const isInvalidTarget = (target: string, mode: TargetMode, mimeType: string): boolean => { - if (isDefaultMimeType(mimeType) && !target) { - return false; - } - - return mode === TargetMode.URL && !isUrlTargetMode(target); -}; - -const containsInvalidConversion = (conversions: TSettingsDownloadConversion[]): boolean => - conversions.some( - ({ mimeType, compressionLevel, target, callTimeout, connectTimeout, headers, mode }, index) => - isUnsetConversion(mimeType, target) || - isInvalidTarget(target, mode, mimeType) || - !isValidCompressionLevel(compressionLevel) || - !isValidCallTimeoutSetting(callTimeout) || - !isValidConnectTimeoutSetting(connectTimeout) || - isDuplicateConversion(mimeType, index, conversions) || - hasDuplicateHeaders(headers), - ); - -const getTargetMode = (target: string): TargetMode => { - if (isUrlTargetMode(target)) { - return TargetMode.URL; - } - - return TargetMode.IMAGE; -}; - -export const addStableIdToHeaders = ( - headers: (SettingsDownloadConversionHeader | TSettingsDownloadConversionHeader)[], -): TSettingsDownloadConversionHeader[] => - headers.map((header) => ({ - // eslint-disable-next-line no-plusplus - id: (header as TSettingsDownloadConversionHeader).id ?? COUNTER++, - ...header, - })); - -export const addStableIdToConversions = ( - conversions: (SettingsDownloadConversion | TSettingsDownloadConversion)[], -): TSettingsDownloadConversion[] => - conversions.map((conversion) => ({ - // eslint-disable-next-line no-plusplus - id: (conversion as TSettingsDownloadConversion).id ?? COUNTER++, - ...conversion, - mode: getTargetMode(conversion.target), - headers: conversion.headers ? addStableIdToHeaders(conversion.headers) : null, - })); - -const normalizeConversions = (conversions: TSettingsDownloadConversion[]): TSettingsDownloadConversion[] => - conversions.map((conversion) => ({ - ...conversion, - mimeType: normalizeMimeType(conversion.mimeType), - target: normalizeMimeType(conversion.target), - })); - -const toValidServerMimeType = (mimeType: string): string => { - if (isDefaultMimeType(mimeType)) { - return DEFAULT_MIME_TYPE; - } - - return `${MIME_TYPE_PREFIX}${mimeType}`; -}; - -const toValidServerConversions = (conversions: TSettingsDownloadConversion[]): SettingsDownloadConversion[] => - conversions - .filter(({ mimeType, target }) => !!mimeType && !!target) - .map(({ id, mode, ...conversion }) => ({ - ...conversion, - mimeType: toValidServerMimeType(conversion.mimeType), - target: isUrlTargetMode(conversion.target) ? conversion.target : `${MIME_TYPE_PREFIX}${conversion.target}`, - headers: conversion.headers?.map(({ id: headerId, ...header }) => header) ?? null, - })); - -const maybeAddDefault = (conversions: TSettingsDownloadConversion[]): TSettingsDownloadConversion[] => { - const isDefaultDefined = conversions.some(({ mimeType }) => isDefaultMimeType(mimeType)); - - return [ - ...(isDefaultDefined - ? [] - : [ - { - id: -1, - mode: TargetMode.IMAGE, - mimeType: DEFAULT_MIME_TYPE, - target: '', - compressionLevel: null, - headers: null, - callTimeout: null, - connectTimeout: null, - }, - ]), - ...conversions, - ]; -}; - -const normalizeHeaders = ( - headers: SettingsDownloadConversionType['headers'], -): SettingsDownloadConversionType['headers'] => headers?.filter(({ name }) => name !== ''); - -const didUpdateConversions = ( - conversions: TSettingsDownloadConversion[], - tmpConversions: TSettingsDownloadConversion[], -): boolean => { - if (conversions.length !== tmpConversions.length) { - return true; - } - - return conversions.some(({ mimeType, target, compressionLevel, callTimeout, connectTimeout, headers }, index) => { - const tmpConversion = tmpConversions[index]; - - const orgHeaders = normalizeHeaders(headers); - const tmpHeaders = normalizeHeaders(tmpConversion.headers); - - return ( - normalizeMimeType(mimeType) !== tmpConversion.mimeType || - normalizeMimeType(target) !== tmpConversion.target || - compressionLevel !== tmpConversion.compressionLevel || - callTimeout !== tmpConversion.callTimeout || - connectTimeout !== tmpConversion.connectTimeout || - (orgHeaders?.length ?? 0) !== (tmpHeaders?.length ?? 0) || - !!orgHeaders?.some( - (header, headerIndex) => - header.name !== tmpHeaders?.[headerIndex]?.name || - header.value !== tmpHeaders?.[headerIndex]?.value, - ) - ); - }); -}; - -const MimeTypeTextField = ({ - shouldAutoFocus, - isDefault, - isDuplicate, - label, - value, - onUpdate, - mode, -}: { - shouldAutoFocus: boolean; - isDefault: boolean; - isDuplicate: boolean; - label: string; - value: string; - onUpdate: (value: string) => void; - mode: TargetMode; -}) => { - const { t } = useTranslation(); - - const isImageMode = mode === TargetMode.IMAGE; - const isValidUrl = value === '' || isUrlTargetMode(value); - const isValid = !isDuplicate && (isImageMode || isValidUrl); - - return ( - {isImageMode ? MIME_TYPE_PREFIX : null} - ), - }, - }} - onChange={(e) => onUpdate(e.target.value.trim())} - /> - ); -}; - -const Header = ({ - id, - name, - value, - onChange, - isDuplicate, -}: { - isDuplicate: boolean; - onChange: (header: TSettingsDownloadConversionHeader | null) => void; -} & TSettingsDownloadConversionHeader) => { - const { t } = useTranslation(); - const theme = useTheme(); - - return ( - - - - onChange({ - id, - name: e.target.value, - value, - }) - } - /> - - onChange({ - id, - name, - value: e.target.value, - }) - } - /> - - - { - onChange(null); - }} - > - - - - - ); -}; - -const Headers = ({ - open, - headers, - onChange, -}: { - open: boolean; - headers: Maybe | undefined; - onChange: (headers: Maybe) => void; -}) => { - const { t } = useTranslation(); - - return ( - - - {t('download.settings.conversion.headers.title')} - {headers?.map((header, index) => ( -
{ - const isDeletion = updatedHeader == null; - if (isDeletion) { - onChange(headers?.toSpliced(index, 1)); - return; - } - - onChange((headers ?? []).toSpliced(index, 1, updatedHeader)); - }} - /> - ))} - - - - ); -}; - -const Conversion = ({ - conversion, - conversion: { mimeType, target, compressionLevel, headers, callTimeout, connectTimeout }, - onChange, - isDuplicate, -}: { - conversion: TSettingsDownloadConversion; - onChange: (newConversion: TSettingsDownloadConversion | null) => void; - isDuplicate: boolean; -}) => { - const { t } = useTranslation(); - const theme = useTheme(); - - const { ref: textFieldRef, height: textFieldHeight } = useElementSize(); - - const [targetMode, setTargetMode] = useState(getTargetMode(target)); - const [areHeadersCollapsed, setAreHeadersCollapsed] = useState(true); - - const isImageMode = targetMode === TargetMode.IMAGE; - - const isCallTimeoutValid = isValidCallTimeoutSetting(callTimeout); - const isConnectTimeoutValid = isValidConnectTimeoutSetting(connectTimeout); - const isCompressionLevelValid = isValidCompressionLevel(compressionLevel); - const isDefault = isDefaultMimeType(mimeType) && !isDuplicate; - const isDisabled = isDefault && !target && compressionLevel == null; - - return ( - - - - { - onChange({ - ...conversion, - mimeType: value, - }); - }} - /> - - - - {t('download.settings.conversion.target_modes.title')} - - - - - onChange({ - ...conversion, - target: value, - }) - } - /> - {isImageMode ? ( - { - onChange({ - ...conversion, - compressionLevel: e.target.value ? Number(e.target.value) : null, - }); - }} - /> - ) : ( - <> - - {t('global.date.label.second_other')} - - ), - }, - }} - onChange={(e) => { - onChange({ - ...conversion, - callTimeout: e.target.value - ? d(Number(e.target.value)).seconds.toISOString() - : null, - }); - }} - /> - - {t('global.date.label.second_other')} - - ), - }, - }} - onChange={(e) => { - onChange({ - ...conversion, - connectTimeout: e.target.value - ? d(Number(e.target.value)).seconds.toISOString() - : null, - }); - }} - /> - - - )} - - - { - onChange(null); - }} - > - - - - - - onChange({ - ...conversion, - headers: updatedHeaders, - }) - } - /> - - ); -}; +import { + addStableIdToConversions, + containsInvalidConversion, + didUpdateConversions, + isDuplicateConversion, + maybeAddDefault, + normalizeConversions, + toValidServerConversions, +} from '@/features/settings/ImageProcessing.utils.ts'; +import { Processing } from '@/features/settings/components/images/Processing.tsx'; export const ImageProcessingSetting = () => { const { t } = useTranslation(); @@ -694,7 +94,7 @@ export const ImageProcessingSetting = () => { const isDuplicate = isDuplicateConversion(mimeType, index, tmpConversions); return ( - { onClick={() => { setTmpConversions((prev) => [ ...prev, - ...addStableIdToConversions([{ mimeType: '', target: '' }]), + ...addStableIdToConversions([ + { + mimeType: '', + target: '', + compressionLevel: null, + headers: null, + callTimeout: null, + connectTimeout: null, + }, + ]), ]); }} >