/* * 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 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 { DOWNLOAD_CONVERSION_COMPRESSION, IMAGE_CONVERSION_CALL_TIMEOUT, IMAGE_CONVERSION_CONNECT_TIMEOUT, } from '@/features/downloads/Downloads.constants.ts'; 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'; 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_CONVERSION_CALL_TIMEOUT.min, IMAGE_CONVERSION_CALL_TIMEOUT.max, ); const isValidConnectTimeoutSetting = (timeout: string | null | undefined): boolean => isValidNumberSetting( timeout ? d(timeout).seconds.inWholeSeconds : null, IMAGE_CONVERSION_CONNECT_TIMEOUT.min, IMAGE_CONVERSION_CONNECT_TIMEOUT.max, ); const isValidCompressionLevel = (compression: number | null | undefined): boolean => isValidNumberSetting(compression, DOWNLOAD_CONVERSION_COMPRESSION.min, DOWNLOAD_CONVERSION_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, }) } /> ); }; export const DownloadConversionSetting = ({ conversions, updateSetting, }: { conversions: SettingsDownloadConversion[]; updateSetting: (conversions: SettingsDownloadConversion[]) => Promise; }) => { const { t } = useTranslation(); const [tmpConversions, setTmpConversions] = useState( normalizeConversions(maybeAddDefault(addStableIdToConversions(conversions))), ); const hasInvalidConversion = containsInvalidConversion(tmpConversions); const hasChanged = didUpdateConversions( normalizeConversions(maybeAddDefault(addStableIdToConversions(conversions))), tmpConversions, ); const onSubmit = async () => { try { await updateSetting(toValidServerConversions(tmpConversions)); } catch (e) { // ignore error } }; return ( {t('download.settings.conversion.description', { value: 'none' })} {tmpConversions.map((conversion, index) => { const { mimeType } = conversion; const isDuplicate = isDuplicateConversion(mimeType, index, tmpConversions); return ( { setTmpConversions((prev) => maybeAddDefault( prev.toSpliced(index, 1, ...(newConversion ? [newConversion] : [])), ), ); }} /> ); })} ); };