Improve image processing settings url target adding search parameters

This commit is contained in:
schroda
2025-12-28 02:07:52 +01:00
parent cf24b388ba
commit f9973e0ef3
8 changed files with 135 additions and 46 deletions

View File

@@ -10,7 +10,7 @@ import { d } from 'koration';
import {
ImageProcessingTargetMode,
TSettingsDownloadConversion,
TSettingsDownloadConversionHeader,
TSettingsDownloadConversionKeyValueItem,
} from '@/features/settings/Settings.types.ts';
import {
DEFAULT_MIME_TYPE,
@@ -21,10 +21,13 @@ import {
TARGET_DISABLED,
} from '@/features/settings/Settings.constants.ts';
import {
Maybe,
SettingsDownloadConversion,
SettingsDownloadConversionHeader,
SettingsDownloadConversionType,
} from '@/lib/graphql/generated/graphql.ts';
import { UrlUtil } from '@/lib/UrlUtil.ts';
import { jsonSaveParse } from '@/lib/HelperFunctions.ts';
let COUNTER = 0;
@@ -33,14 +36,14 @@ const normalizeMimeType = (mimeType: string): string => mimeType.replace(MIME_TY
export const isDefaultMimeType = (mimeType: string): boolean =>
normalizeMimeType(mimeType.toLowerCase().trim()) === DEFAULT_MIME_TYPE;
export const isDuplicateHeader = (
export const isDuplicateKeyValueItem = (
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 hasDuplicateKeyValueItems = (headers: SettingsDownloadConversionType['headers']): boolean =>
headers?.some((header, index) => isDuplicateKeyValueItem(header.name, index, headers)) ?? false;
export const isDuplicateConversion = (
mimeType: string,
@@ -82,14 +85,15 @@ const isInvalidTarget = (target: string, mode: ImageProcessingTargetMode, mimeTy
export const containsInvalidConversion = (conversions: TSettingsDownloadConversion[]): boolean =>
conversions.some(
({ mimeType, compressionLevel, target, callTimeout, connectTimeout, headers, mode }, index) =>
({ mimeType, compressionLevel, target, callTimeout, connectTimeout, headers, searchParams, mode }, index) =>
isUnsetConversion(mimeType, target) ||
isInvalidTarget(target, mode, mimeType) ||
!isValidCompressionLevel(compressionLevel) ||
!isValidCallTimeoutSetting(callTimeout) ||
!isValidConnectTimeoutSetting(connectTimeout) ||
isDuplicateConversion(mimeType, index, conversions) ||
hasDuplicateHeaders(headers),
hasDuplicateKeyValueItems(headers) ||
hasDuplicateKeyValueItems(searchParams),
);
export const getTargetMode = (target: string): ImageProcessingTargetMode => {
@@ -106,13 +110,22 @@ export const getTargetMode = (target: string): ImageProcessingTargetMode => {
return ImageProcessingTargetMode.IMAGE;
};
export const addStableIdToHeaders = (
headers: (SettingsDownloadConversionHeader | TSettingsDownloadConversionHeader)[],
): TSettingsDownloadConversionHeader[] =>
headers.map((header) => ({
export const extractSearchParams = (url: string): SettingsDownloadConversionHeader[] => {
const urlObject = UrlUtil.asUrl(url);
return [...(urlObject?.searchParams ?? []).entries()].map(([key, value]) => ({
name: String(key),
value: jsonSaveParse(value) ?? value,
}));
};
export const addStableIdToKeyValueItems = (
items: (SettingsDownloadConversionHeader | TSettingsDownloadConversionKeyValueItem)[],
): TSettingsDownloadConversionKeyValueItem[] =>
items.map((item) => ({
// eslint-disable-next-line no-plusplus
id: (header as TSettingsDownloadConversionHeader).id ?? COUNTER++,
...header,
id: (item as TSettingsDownloadConversionKeyValueItem).id ?? COUNTER++,
...item,
}));
export const addStableIdToConversions = (
@@ -123,9 +136,29 @@ export const addStableIdToConversions = (
id: (conversion as TSettingsDownloadConversion).id ?? COUNTER++,
...conversion,
mode: getTargetMode(normalizeMimeType(conversion.target)),
headers: conversion.headers ? addStableIdToHeaders(conversion.headers) : null,
headers: conversion.headers ? addStableIdToKeyValueItems(conversion.headers) : null,
searchParams: addStableIdToKeyValueItems(extractSearchParams(conversion.target)),
}));
export const getUpdatedSearchParams = (
url: string,
existingParams: Maybe<TSettingsDownloadConversionKeyValueItem[] | undefined>,
): TSettingsDownloadConversionKeyValueItem[] => {
const urlObject = UrlUtil.asUrl(url);
const params = [...(urlObject?.searchParams?.entries() ?? [])].map(([key, value]) => ({ name: key, value }));
const paramsWithRetainedStableId = params.map((param) => {
const existingParam = existingParams?.find(({ name }) => name === param.name);
return {
id: existingParam?.id,
...param,
};
});
return addStableIdToKeyValueItems(paramsWithRetainedStableId);
};
export const normalizeConversions = (conversions: TSettingsDownloadConversion[]): TSettingsDownloadConversion[] =>
conversions.map((conversion) => ({
...conversion,
@@ -144,7 +177,7 @@ const toValidServerMimeType = (mimeType: string): string => {
export const toValidServerConversions = (conversions: TSettingsDownloadConversion[]): SettingsDownloadConversion[] =>
conversions
.filter(({ mimeType, target }) => !!mimeType && !!target)
.map(({ id, mode, ...conversion }) => ({
.map(({ id, mode, searchParams, ...conversion }) => ({
...conversion,
mimeType: toValidServerMimeType(conversion.mimeType),
target: isUrlTargetMode(conversion.target) ? conversion.target : `${MIME_TYPE_PREFIX}${conversion.target}`,

View File

@@ -70,7 +70,7 @@ export enum ImageProcessingType {
SERVE = 'serve',
}
export type TSettingsDownloadConversionHeader = SettingsDownloadConversionHeader & {
export type TSettingsDownloadConversionKeyValueItem = SettingsDownloadConversionHeader & {
/**
* The conversion object does not have a stable key, which causes issues when editing the settings
*/
@@ -83,5 +83,6 @@ export type TSettingsDownloadConversion = Omit<SettingsDownloadConversion, 'head
*/
id: number;
mode: ImageProcessingTargetMode;
headers?: Maybe<TSettingsDownloadConversionHeader[]>;
headers?: Maybe<TSettingsDownloadConversionKeyValueItem[]>;
searchParams?: Maybe<TSettingsDownloadConversionKeyValueItem[]>;
};

View File

@@ -14,9 +14,9 @@ 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';
import { TSettingsDownloadConversionKeyValueItem } from '@/features/settings/Settings.types';
export const Header = ({
export const KeyValueItem = ({
id,
name,
value,
@@ -24,8 +24,8 @@ export const Header = ({
isDuplicate,
}: {
isDuplicate: boolean;
onChange: (header: TSettingsDownloadConversionHeader | null) => void;
} & TSettingsDownloadConversionHeader) => {
onChange: (header: TSettingsDownloadConversionKeyValueItem | null) => void;
} & TSettingsDownloadConversionKeyValueItem) => {
const { t } = useTranslation();
const theme = useTheme();

View File

@@ -12,43 +12,47 @@ 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';
import { addStableIdToKeyValueItems, isDuplicateKeyValueItem } from '@/features/settings/ImageProcessing.utils.ts';
import { KeyValueItem } from '@/features/settings/components/images/KeyValueItem.tsx';
import { TSettingsDownloadConversionKeyValueItem } from '@/features/settings/Settings.types.ts';
export const Headers = ({
export const KeyValueItems = ({
title,
open,
headers,
items,
onChange,
}: {
title: string;
open: boolean;
headers: Maybe<TSettingsDownloadConversionHeader[] | undefined>;
onChange: (headers: Maybe<TSettingsDownloadConversionHeader[]>) => void;
items: Maybe<TSettingsDownloadConversionKeyValueItem[] | undefined>;
onChange: (items: Maybe<TSettingsDownloadConversionKeyValueItem[]>) => void;
}) => {
const { t } = useTranslation();
return (
<Collapse in={open}>
<Stack sx={{ justifyContent: 'start', gap: 2, pt: 2 }}>
<Typography>{t('download.settings.conversion.headers.title')}</Typography>
{headers?.map((header, index) => (
<Header
<Typography>{title}</Typography>
{items?.map((header, index) => (
<KeyValueItem
key={header.id}
{...header}
isDuplicate={isDuplicateHeader(header.name, index, headers)}
isDuplicate={isDuplicateKeyValueItem(header.name, index, items)}
onChange={(updatedHeader) => {
const isDeletion = updatedHeader == null;
if (isDeletion) {
onChange(headers?.toSpliced(index, 1));
onChange(items?.toSpliced(index, 1));
return;
}
onChange((headers ?? []).toSpliced(index, 1, updatedHeader));
onChange((items ?? []).toSpliced(index, 1, updatedHeader));
}}
/>
))}
<Button
onClick={() => onChange([...(headers ?? []), ...addStableIdToHeaders([{ name: '', value: '' }])])}
onClick={() =>
onChange([...(items ?? []), ...addStableIdToKeyValueItems([{ name: '', value: '' }])])
}
variant="contained"
sx={{ width: 'fit-content' }}
>

View File

@@ -20,7 +20,7 @@ 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 { KeyValueItems } from '@/features/settings/components/images/KeyValueItems.tsx';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import { TranslationKey } from '@/base/Base.types.ts';
import {
@@ -36,15 +36,18 @@ import { TypographyMaxLines } from '@/base/components/texts/TypographyMaxLines.t
import { MimeTypeTextField } from '@/features/settings/components/images/MimeTypeTextField.tsx';
import { ImageProcessingTargetMode, TSettingsDownloadConversion } from '@/features/settings/Settings.types.ts';
import {
getUpdatedSearchParams,
isDefaultMimeType,
isUrlTargetMode,
isValidCallTimeoutSetting,
isValidCompressionLevel,
isValidConnectTimeoutSetting,
} from '@/features/settings/ImageProcessing.utils.ts';
import { UrlUtil } from '@/lib/UrlUtil.ts';
export const Processing = ({
conversion,
conversion: { mode, mimeType, target, compressionLevel, headers, callTimeout, connectTimeout },
conversion: { mode, mimeType, target, compressionLevel, headers, searchParams, callTimeout, connectTimeout },
onChange,
isDuplicate,
}: {
@@ -57,10 +60,12 @@ export const Processing = ({
const { ref: textFieldRef, height: textFieldHeight } = useElementSize();
const [areSearchParamsCollapsed, setAreSearchParamsCollapsed] = useState(true);
const [areHeadersCollapsed, setAreHeadersCollapsed] = useState(true);
const isDisabledMode = mode === ImageProcessingTargetMode.DISABLED;
const isImageMode = mode === ImageProcessingTargetMode.IMAGE;
const isUrlMode = mode === ImageProcessingTargetMode.URL;
const isCallTimeoutValid = isValidCallTimeoutSetting(callTimeout);
const isConnectTimeoutValid = isValidConnectTimeoutSetting(connectTimeout);
@@ -122,14 +127,21 @@ export const Processing = ({
labelId="image-conversion-target-mode-label"
label={t('download.settings.conversion.target_modes.title')}
value={mode}
onChange={(e) =>
onChange={(e) => {
if (!isUrlMode) {
setAreSearchParamsCollapsed(true);
setAreHeadersCollapsed(true);
}
onChange({
...conversion,
mode: e.target.value,
target:
e.target.value === ImageProcessingTargetMode.DISABLED ? TARGET_DISABLED : '',
})
}
headers: !isUrlMode ? null : headers,
searchParams: !isUrlMode ? null : searchParams,
});
}}
>
{IMAGE_PROCESSING_TARGET_MODES_SELECT_VALUES.map(([selectValue, { text: selectText }]) => (
<MenuItem key={selectValue} value={selectValue}>
@@ -146,12 +158,13 @@ export const Processing = ({
isDuplicate={false}
label={t('download.settings.conversion.target')}
value={target}
onUpdate={(value) =>
onUpdate={(value) => {
onChange({
...conversion,
target: value,
})
}
searchParams: isImageMode ? null : getUpdatedSearchParams(value, searchParams),
});
}}
/>
)}
{(() => {
@@ -237,6 +250,16 @@ export const Processing = ({
});
}}
/>
<Button
disabled={!isUrlTargetMode(target)}
onClick={() => setAreSearchParamsCollapsed(!areSearchParamsCollapsed)}
variant={areSearchParamsCollapsed ? 'outlined' : 'contained'}
sx={{ height: textFieldHeight }}
>
{t('download.settings.conversion.search_params.button', {
count: searchParams?.length ?? 0,
})}
</Button>
<Button
onClick={() => setAreHeadersCollapsed(!areHeadersCollapsed)}
variant={areHeadersCollapsed ? 'outlined' : 'contained'}
@@ -261,9 +284,25 @@ export const Processing = ({
</IconButton>
</CustomTooltip>
</Stack>
<Headers
open={!areHeadersCollapsed}
headers={headers}
<KeyValueItems
title={t('download.settings.conversion.search_params.title')}
open={isUrlMode && !areSearchParamsCollapsed}
items={searchParams}
onChange={(params) => {
const baseUrl = target?.split('?')[0] ?? '';
const updatedParams = Object.fromEntries(params?.map(({ name, value }) => [name, value]) ?? []);
onChange({
...conversion,
target: UrlUtil.addParams(baseUrl, updatedParams),
searchParams: params,
});
}}
/>
<KeyValueItems
title={t('download.settings.conversion.headers.title')}
open={isUrlMode && !areHeadersCollapsed}
items={headers}
onChange={(updatedHeaders) =>
onChange({
...conversion,

View File

@@ -92,7 +92,7 @@ export const ImageProcessingSetting = ({ type }: { type: ImageProcessingType })
};
return (
<Stack sx={{ p: 2, gap: 3 }}>
<Stack sx={{ p: 2, gap: 5 }}>
<Typography>{t('download.settings.conversion.description', { value: TARGET_DISABLED })}</Typography>
<Stack sx={{ flexDirection: 'column', gap: 5 }}>
{tmpConversions.map((conversion, index) => {

View File

@@ -9,6 +9,14 @@
import { SearchParam } from '@/base/Base.types.ts';
export class UrlUtil {
static asUrl(url: string): URL | null {
try {
return new URL(url);
} catch (e) {
return null;
}
}
static createParams(params: Record<SearchParam | string, string | null | undefined>): URLSearchParams {
const paramEntries = Object.entries(params).filter(
(entry): entry is [string, string] => typeof entry[1] === 'string',