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

@@ -225,6 +225,10 @@
"value": "Value" "value": "Value"
}, },
"mime_type": "MIME-Type", "mime_type": "MIME-Type",
"search_params": {
"button": "Search parameters ({{count}})",
"title": "Search parameters"
},
"target": "MIME-Type target", "target": "MIME-Type target",
"target_modes": { "target_modes": {
"image": { "image": {

View File

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

View File

@@ -70,7 +70,7 @@ export enum ImageProcessingType {
SERVE = 'serve', 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 * 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; id: number;
mode: ImageProcessingTargetMode; 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 { useTheme } from '@mui/material/styles';
import { CustomTooltip } from '@/base/components/CustomTooltip.tsx'; import { CustomTooltip } from '@/base/components/CustomTooltip.tsx';
import { IMAGE_PROCESSING_INPUT_WIDTH } from '@/features/settings/Settings.constants.ts'; 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, id,
name, name,
value, value,
@@ -24,8 +24,8 @@ export const Header = ({
isDuplicate, isDuplicate,
}: { }: {
isDuplicate: boolean; isDuplicate: boolean;
onChange: (header: TSettingsDownloadConversionHeader | null) => void; onChange: (header: TSettingsDownloadConversionKeyValueItem | null) => void;
} & TSettingsDownloadConversionHeader) => { } & TSettingsDownloadConversionKeyValueItem) => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme(); const theme = useTheme();

View File

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

View File

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

View File

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

View File

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