diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb1cd4c..f8bf123a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - (**Settings**) Add hikari connection pool database setting - (**Settings**) Add OPDS CBZ MIME-Type setting - (**Settings**) Add automatic backup data inclusion setting +- (**Settings**) Add "external image processing" settings for downloaded images - (**Browse**) Add "open in webview" button in source browse page ### Changed @@ -82,6 +83,7 @@ Thanks to everyone that contributed to this release - (**General**) Add support for the suwayomi WebView - (**Settings**) Add new OPDS server settings - (**Settings**) Add new "simple login" authentication setting +- (**Settings**) Add "download conversion" settings - (**Reader**) Add option to change auto scroll direction by using the scroll backward/forward hotkeys while auto scrolling is active - (**Manga**) Display score and total chapters, if available, in the tracker search results - (**Manga**) Add support for private track bindings diff --git a/public/locales/en.json b/public/locales/en.json index b65a371d..9a9ad724 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -214,10 +214,29 @@ "title": "Auto-download" }, "conversion": { + "call_timeout": "Call timeout", "compression_level": "Compression level", + "connect_timeout": "Connect timeout", "description": "In case no MIME-Type is defined, the \"default\" one will be used for the conversion.\nSet \"{{value}}\" as the target type to disable conversion for a MIME-Type", + "headers": { + "button": "Headers ({{count}})", + "name": "Name", + "title": "Headers", + "value": "Value" + }, "mime_type": "MIME-Type", "target": "MIME-Type target", + "target_modes": { + "image": { + "description": "Convert images to different formats", + "title": "Image" + }, + "title": "Target mode", + "url": { + "description": "Image conversion will be handled by an external service", + "title": "URL" + } + }, "title": "Download conversion" }, "delete_chapters": { @@ -397,6 +416,8 @@ "label": { "day_one": "Day", "day_other": "Days", + "second_one": "Second", + "second_other": "Seconds", "today": "Today", "today_at": "Today at {{timeString}}", "yesterday": "Yesterday", @@ -1241,6 +1262,9 @@ "value": "Delete backups that are older than $t(global.date.value.label.day)" } }, + "flags": { + "title": "Backup data" + }, "label": { "interval": "Backup interval", "time": "Backup time" @@ -1250,9 +1274,6 @@ "description": "The path to the directory on the server where automated backups should get saved in", "title": "Backup location" } - }, - "flags": { - "title": "Backup data" } }, "flag": { diff --git a/src/features/downloads/Downloads.constants.ts b/src/features/downloads/Downloads.constants.ts index 629c1a6b..e7cc16ee 100644 --- a/src/features/downloads/Downloads.constants.ts +++ b/src/features/downloads/Downloads.constants.ts @@ -6,6 +6,8 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ +import { d } from 'koration'; + export const DOWNLOAD_AHEAD = { min: 2, max: 10, @@ -18,3 +20,15 @@ export const DOWNLOAD_CONVERSION_COMPRESSION = { max: 1, step: 0.01, }; + +export const IMAGE_CONVERSION_CALL_TIMEOUT = { + min: d(10).seconds.inWholeSeconds, + max: d(10).minutes.inWholeSeconds, + step: d(10).seconds.inWholeSeconds, +}; + +export const IMAGE_CONVERSION_CONNECT_TIMEOUT = { + min: d(10).seconds.inWholeSeconds, + max: d(10).minutes.inWholeSeconds, + step: d(10).seconds.inWholeSeconds, +}; diff --git a/src/features/downloads/components/DownloadConversionSetting.tsx b/src/features/downloads/components/DownloadConversionSetting.tsx index 93029797..3df22d79 100644 --- a/src/features/downloads/components/DownloadConversionSetting.tsx +++ b/src/features/downloads/components/DownloadConversionSetting.tsx @@ -16,40 +16,163 @@ 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 } from '@/features/downloads/Downloads.constants.ts'; -import { SettingsDownloadConversion } from '@/lib/graphql/generated/graphql.ts'; +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/'; -const DEFAULT_FOCUS_INDEX = -1; + +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 => - compression == null || - (compression >= DOWNLOAD_CONVERSION_COMPRESSION.min && compression <= DOWNLOAD_CONVERSION_COMPRESSION.max); + isValidNumberSetting(compression, DOWNLOAD_CONVERSION_COMPRESSION.min, DOWNLOAD_CONVERSION_COMPRESSION.max); const isUnsetConversion = (mimeType: string, target: string): boolean => mimeType === '' && target === ''; -const containsInvalidConversion = (conversions: SettingsDownloadConversion[]): boolean => +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 }, index) => + ({ mimeType, compressionLevel, target, callTimeout, connectTimeout, headers, mode }, index) => isUnsetConversion(mimeType, target) || + isInvalidTarget(target, mode, mimeType) || !isValidCompressionLevel(compressionLevel) || - isDuplicateConversion(mimeType, index, conversions), + !isValidCallTimeoutSetting(callTimeout) || + !isValidConnectTimeoutSetting(connectTimeout) || + isDuplicateConversion(mimeType, index, conversions) || + hasDuplicateHeaders(headers), ); -const normalizeConversions = (conversions: SettingsDownloadConversion[]): SettingsDownloadConversion[] => +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), @@ -64,16 +187,17 @@ const toValidServerMimeType = (mimeType: string): string => { return `${MIME_TYPE_PREFIX}${mimeType}`; }; -const toValidServerConversions = (conversions: SettingsDownloadConversion[]): SettingsDownloadConversion[] => +const toValidServerConversions = (conversions: TSettingsDownloadConversion[]): SettingsDownloadConversion[] => conversions .filter(({ mimeType, target }) => !!mimeType && !!target) - .map((conversion) => ({ + .map(({ id, mode, ...conversion }) => ({ ...conversion, mimeType: toValidServerMimeType(conversion.mimeType), - target: `${MIME_TYPE_PREFIX}${conversion.target}`, + target: isUrlTargetMode(conversion.target) ? conversion.target : `${MIME_TYPE_PREFIX}${conversion.target}`, + headers: conversion.headers?.map(({ id: headerId, ...header }) => header) ?? null, })); -const maybeAddDefault = (conversions: SettingsDownloadConversion[]) => { +const maybeAddDefault = (conversions: TSettingsDownloadConversion[]): TSettingsDownloadConversion[] => { const isDefaultDefined = conversions.some(({ mimeType }) => isDefaultMimeType(mimeType)); return [ @@ -81,30 +205,50 @@ const maybeAddDefault = (conversions: SettingsDownloadConversion[]) => { ? [] : [ { + 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: SettingsDownloadConversion[], - tmpConversions: SettingsDownloadConversion[], + conversions: TSettingsDownloadConversion[], + tmpConversions: TSettingsDownloadConversion[], ): boolean => { if (conversions.length !== tmpConversions.length) { return true; } - return conversions.some(({ mimeType, target, compressionLevel }, index) => { + 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 + 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, + ) ); }); }; @@ -116,6 +260,7 @@ const MimeTypeTextField = ({ label, value, onUpdate, + mode, }: { shouldAutoFocus: boolean; isDefault: boolean; @@ -123,9 +268,14 @@ const MimeTypeTextField = ({ 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 ( {MIME_TYPE_PREFIX}, + startAdornment: ( + {isImageMode ? MIME_TYPE_PREFIX : null} + ), }, }} onChange={(e) => onUpdate(e.target.value.trim())} @@ -145,26 +297,19 @@ const MimeTypeTextField = ({ ); }; -const Conversion = ({ - shouldAutoFocusMimeTypeTextField, - conversion: { mimeType, target, compressionLevel }, - setFocusMimeTypeTextField, +const Header = ({ + id, + name, + value, onChange, isDuplicate, }: { - shouldAutoFocusMimeTypeTextField: boolean; - conversion: SettingsDownloadConversion; - setFocusMimeTypeTextField: (focus: boolean) => void; - onChange: (newConversion: SettingsDownloadConversion | null) => void; isDuplicate: boolean; -}) => { + onChange: (header: TSettingsDownloadConversionHeader | null) => void; +} & TSettingsDownloadConversionHeader) => { const { t } = useTranslation(); const theme = useTheme(); - const isCompressionLevelValid = isValidCompressionLevel(compressionLevel); - const isDefault = isDefaultMimeType(mimeType) && !isDuplicate; - const isDisabled = isDefault && !target && compressionLevel == null; - return ( - { - setFocusMimeTypeTextField(true); - + onChange({ - mimeType: value, - target, - compressionLevel, - }); - }} - /> - - - onChange({ - mimeType, - target: value, - compressionLevel, + id, + name: e.target.value, + value, }) } /> { + label={t('download.settings.conversion.headers.value')} + value={value} + onChange={(e) => onChange({ - mimeType, - target, - compressionLevel: e.target.value ? Number(e.target.value) : undefined, - }); - }} + id, + name, + value: e.target.value, + }) + } /> - + { - setFocusMimeTypeTextField(false); onChange(null); }} > @@ -254,6 +370,258 @@ const Conversion = ({ ); }; +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, @@ -263,12 +631,15 @@ export const DownloadConversionSetting = ({ }) => { const { t } = useTranslation(); - const [tmpConversions, setTmpConversions] = useState(normalizeConversions(maybeAddDefault(conversions))); - const [focusedMimeTypeTextFieldIndex, setFocusedMimeTypeTextFieldIndex] = useState(DEFAULT_FOCUS_INDEX); + const [tmpConversions, setTmpConversions] = useState( + normalizeConversions(maybeAddDefault(addStableIdToConversions(conversions))), + ); const hasInvalidConversion = containsInvalidConversion(tmpConversions); - - const hasChanged = didUpdateConversions(normalizeConversions(maybeAddDefault(conversions)), tmpConversions); + const hasChanged = didUpdateConversions( + normalizeConversions(maybeAddDefault(addStableIdToConversions(conversions))), + tmpConversions, + ); const onSubmit = async () => { try { @@ -286,18 +657,12 @@ export const DownloadConversionSetting = ({ const { mimeType } = conversion; const isDuplicate = isDuplicateConversion(mimeType, index, tmpConversions); - const shouldAutoFocusMimeTypeTextField = index === focusedMimeTypeTextFieldIndex; return ( - setFocusedMimeTypeTextFieldIndex(focus ? index : DEFAULT_FOCUS_INDEX) - } - shouldAutoFocusMimeTypeTextField={shouldAutoFocusMimeTypeTextField} onChange={(newConversion) => { setTmpConversions((prev) => maybeAddDefault( @@ -312,13 +677,16 @@ export const DownloadConversionSetting = ({