This commit is contained in:
Mark McDowall 2026-04-26 18:37:43 -07:00 committed by GitHub
commit 50b15dd483
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 706 additions and 659 deletions

View file

@ -5,7 +5,6 @@ import AppSectionState, {
AppSectionSaveState,
AppSectionSchemaState,
} from 'App/State/AppSectionState';
import AutoTagging, { AutoTaggingSpecification } from 'typings/AutoTagging';
import CustomFormat from 'typings/CustomFormat';
import CustomFormatSpecification from 'typings/CustomFormatSpecification';
import DelayProfile from 'typings/DelayProfile';
@ -18,17 +17,6 @@ type Presets<T> = T & {
presets: T[];
};
export interface AutoTaggingAppState
extends AppSectionState<AutoTagging>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface AutoTaggingSpecificationAppState
extends AppSectionState<AutoTaggingSpecification>,
AppSectionDeleteState,
AppSectionSaveState,
AppSectionSchemaState<AutoTaggingSpecification> {}
export interface DelayProfileAppState
extends AppSectionListState<DelayProfile>,
AppSectionDeleteState,
@ -70,8 +58,6 @@ export interface ImportListOptionsSettingsAppState
AppSectionSaveState {}
interface SettingsAppState {
autoTaggings: AutoTaggingAppState;
autoTaggingSpecifications: AutoTaggingSpecificationAppState;
customFormats: CustomFormatAppState;
customFormatSpecifications: CustomFormatSpecificationAppState;
delayProfiles: DelayProfileAppState;

View file

@ -7,9 +7,12 @@ import TagList from 'Components/TagList';
import { icons, kinds } from 'Helpers/Props';
import { Kind } from 'Helpers/Props/kinds';
import { Tag } from 'Tags/useTags';
import { AutoTaggingSpecification } from 'typings/AutoTagging';
import translate from 'Utilities/String/translate';
import EditAutoTaggingModal from './EditAutoTaggingModal';
import {
AutoTaggingSpecification,
useDeleteAutoTagging,
} from './useAutoTaggings';
import styles from './AutoTagging.css';
interface AutoTaggingProps {
@ -18,8 +21,6 @@ interface AutoTaggingProps {
specifications: AutoTaggingSpecification[];
tags: number[];
tagList: Tag[];
isDeleting: boolean;
onConfirmDeleteAutoTagging: (id: number) => void;
onCloneAutoTaggingPress: (id: number) => void;
}
@ -29,10 +30,9 @@ export default function AutoTagging({
tags,
tagList,
specifications,
isDeleting,
onConfirmDeleteAutoTagging,
onCloneAutoTaggingPress,
}: AutoTaggingProps) {
const { deleteAutoTagging, isDeleting } = useDeleteAutoTagging(id);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
@ -54,8 +54,8 @@ export default function AutoTagging({
}, [setIsDeleteModalOpen]);
const onConfirmDelete = useCallback(() => {
onConfirmDeleteAutoTagging(id);
}, [id, onConfirmDeleteAutoTagging]);
deleteAutoTagging();
}, [deleteAutoTagging]);
const onClonePress = useCallback(() => {
onCloneAutoTaggingPress(id);

View file

@ -1,66 +1,42 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { AutoTaggingAppState } from 'App/State/SettingsAppState';
import React, { useCallback, useState } from 'react';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Icon from 'Components/Icon';
import PageSectionContent from 'Components/Page/PageSectionContent';
import { icons } from 'Helpers/Props';
import {
cloneAutoTagging,
deleteAutoTagging,
fetchAutoTaggings,
} from 'Store/Actions/settingsActions';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import { useTagList } from 'Tags/useTags';
import AutoTaggingModel from 'typings/AutoTagging';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import AutoTagging from './AutoTagging';
import EditAutoTaggingModal from './EditAutoTaggingModal';
import { useSortedAutoTaggings } from './useAutoTaggings';
import styles from './AutoTaggings.css';
export default function AutoTaggings() {
const { error, items, isDeleting, isFetching, isPopulated } = useSelector(
createSortedSectionSelector<AutoTaggingModel, AutoTaggingAppState>(
'settings.autoTaggings',
sortByProp('name')
)
);
const {
data: items,
error,
isFetching,
isFetched: isPopulated,
} = useSortedAutoTaggings();
const tagList = useTagList();
const dispatch = useDispatch();
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [tagsFromId, setTagsFromId] = useState<number>();
const [cloneId, setCloneId] = useState<number>();
const onClonePress = useCallback(
(id: number) => {
dispatch(cloneAutoTagging({ id }));
setTagsFromId(id);
setIsEditModalOpen(true);
},
[dispatch, setIsEditModalOpen]
);
const onClonePress = useCallback((id: number) => {
setCloneId(id);
setIsEditModalOpen(true);
}, []);
const onEditPress = useCallback(() => {
setCloneId(undefined);
setIsEditModalOpen(true);
}, [setIsEditModalOpen]);
}, []);
const onEditModalClose = useCallback(() => {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
const onConfirmDelete = useCallback(
(id: number) => {
dispatch(deleteAutoTagging({ id }));
},
[dispatch]
);
useEffect(() => {
dispatch(fetchAutoTaggings());
}, [dispatch]);
setCloneId(undefined);
}, []);
return (
<FieldSet legend={translate('AutoTagging')}>
@ -76,9 +52,7 @@ export default function AutoTaggings() {
<AutoTagging
key={item.id}
{...item}
isDeleting={isDeleting}
tagList={tagList}
onConfirmDeleteAutoTagging={onConfirmDelete}
onCloneAutoTaggingPress={onClonePress}
/>
);
@ -93,7 +67,7 @@ export default function AutoTaggings() {
<EditAutoTaggingModal
isOpen={isEditModalOpen}
tagsFromId={tagsFromId}
cloneId={cloneId}
onModalClose={onEditModalClose}
/>
</PageSectionContent>

View file

@ -1,8 +1,6 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditAutoTaggingModalContent, {
EditAutoTaggingModalContentProps,
} from './EditAutoTaggingModalContent';
@ -17,18 +15,11 @@ export default function EditAutoTaggingModal({
onModalClose,
...otherProps
}: EditAutoTaggingModalProps) {
const dispatch = useDispatch();
const handleModalClose = useCallback(() => {
dispatch(clearPendingChanges({ section: 'settings.autoTaggings' }));
onModalClose();
}, [dispatch, onModalClose]);
return (
<Modal isOpen={isOpen} size={sizes.LARGE} onModalClose={handleModalClose}>
<Modal isOpen={isOpen} size={sizes.LARGE} onModalClose={onModalClose}>
<EditAutoTaggingModalContent
{...otherProps}
onModalClose={handleModalClose}
onModalClose={onModalClose}
/>
</Modal>
);

View file

@ -1,7 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Alert from 'Components/Alert';
import React, { useCallback, useEffect, useState } from 'react';
import Card from 'Components/Card';
import FieldSet from 'Components/FieldSet';
import Form from 'Components/Form/Form';
@ -11,114 +8,107 @@ import FormLabel from 'Components/Form/FormLabel';
import Icon from 'Components/Icon';
import Button from 'Components/Link/Button';
import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons, inputTypes, kinds } from 'Helpers/Props';
import {
cloneAutoTaggingSpecification,
deleteAutoTaggingSpecification,
fetchAutoTaggingSpecifications,
saveAutoTagging,
setAutoTaggingValue,
} from 'Store/Actions/settingsActions';
import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import AddSpecificationModal from './Specifications/AddSpecificationModal';
import EditSpecificationModal from './Specifications/EditSpecificationModal';
import Specification from './Specifications/Specification';
import {
AutoTagging,
AutoTaggingSpecification,
useManageAutoTagging,
} from './useAutoTaggings';
import styles from './EditAutoTaggingModalContent.css';
export interface EditAutoTaggingModalContentProps {
id?: number;
tagsFromId?: number;
cloneId?: number;
onModalClose: () => void;
onDeleteAutoTaggingPress?: () => void;
}
export default function EditAutoTaggingModalContent({
id,
tagsFromId,
cloneId,
onModalClose,
onDeleteAutoTaggingPress,
}: EditAutoTaggingModalContentProps) {
const {
error,
item,
isFetching,
isSaving,
saveError,
validationErrors,
validationWarnings,
} = useSelector(createProviderSettingsSelectorHook('autoTaggings', id));
updateValue,
saveAutoTagging,
isSaving,
saveError,
specifications,
saveSpecification,
deleteSpecification,
cloneSpecification,
} = useManageAutoTagging(id, cloneId);
const { isPopulated: specificationsPopulated, items: specifications } =
useSelector((state: AppState) => state.settings.autoTaggingSpecifications);
const dispatch = useDispatch();
const [isAddSpecificationModalOpen, setIsAddSpecificationModalOpen] =
useState(false);
const [isEditSpecificationModalOpen, setIsEditSpecificationModalOpen] =
useState(false);
const [editingSpecification, setEditingSpecification] =
useState<AutoTaggingSpecification | null>(null);
const handleAddSpecificationPress = useCallback(() => {
setIsAddSpecificationModalOpen(true);
}, [setIsAddSpecificationModalOpen]);
}, []);
const handleAddSpecificationModalClose = useCallback(
({ specificationSelected = false } = {}) => {
(selectedSpec?: AutoTaggingSpecification) => {
setIsAddSpecificationModalOpen(false);
setIsEditSpecificationModalOpen(specificationSelected);
if (selectedSpec) {
setEditingSpecification({ ...selectedSpec, id: 0 });
setIsEditSpecificationModalOpen(true);
}
},
[setIsAddSpecificationModalOpen]
[]
);
const handleEditSpecificationModalClose = useCallback(() => {
const handleNewSpecificationModalClose = useCallback(() => {
setIsEditSpecificationModalOpen(false);
}, [setIsEditSpecificationModalOpen]);
setEditingSpecification(null);
}, []);
const handleSaveNewSpecification = useCallback(
(spec: AutoTaggingSpecification) => {
saveSpecification(spec);
},
[saveSpecification]
);
const handleInputChange = useCallback(
({ name, value }: InputChanged) => {
// @ts-expect-error - actions are not typed
dispatch(setAutoTaggingValue({ name, value }));
updateValue(
name as keyof AutoTagging,
value as AutoTagging[keyof AutoTagging]
);
},
[dispatch]
[updateValue]
);
const handleSavePress = useCallback(() => {
dispatch(saveAutoTagging({ id }));
}, [dispatch, id]);
saveAutoTagging();
}, [saveAutoTagging]);
const handleCloneSpecificationPress = useCallback(
(specId: number) => {
dispatch(cloneAutoTaggingSpecification({ id: specId }));
},
[dispatch]
);
const handleConfirmDeleteSpecification = useCallback(
(specId: number) => {
dispatch(deleteAutoTaggingSpecification({ id: specId }));
},
[dispatch]
);
const wasSaving = usePrevious(isSaving);
useEffect(() => {
dispatch(fetchAutoTaggingSpecifications({ id: tagsFromId || id }));
}, [id, tagsFromId, dispatch]);
const isSavingRef = useRef(false);
useEffect(() => {
if (isSavingRef.current && !isSaving && !saveError) {
if (wasSaving && !isSaving && !saveError) {
onModalClose();
}
isSavingRef.current = isSaving;
}, [isSaving, saveError, onModalClose]);
}, [isSaving, wasSaving, saveError, onModalClose]);
const { name, removeTagsAutomatically, tags } = item;
@ -130,96 +120,82 @@ export default function EditAutoTaggingModalContent({
<ModalBody>
<div>
{isFetching ? <LoadingIndicator /> : null}
<Form
validationErrors={validationErrors}
validationWarnings={validationWarnings}
>
<FormGroup>
<FormLabel>{translate('Name')}</FormLabel>
{!isFetching && !!error ? (
<Alert kind={kinds.DANGER}>{translate('AddAutoTagError')}</Alert>
) : null}
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={handleInputChange}
/>
</FormGroup>
{!isFetching && !error && specificationsPopulated ? (
<div>
<Form
validationErrors={validationErrors}
validationWarnings={validationWarnings}
<FormGroup>
<FormLabel>{translate('RemoveTagsAutomatically')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="removeTagsAutomatically"
helpText={translate('RemoveTagsAutomaticallyHelpText')}
{...removeTagsAutomatically}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={handleInputChange}
{...tags}
/>
</FormGroup>
</Form>
<FieldSet legend={translate('Conditions')}>
<div className={styles.autoTaggings}>
{specifications.map((specification) => {
return (
<Specification
key={specification.id}
{...specification}
onSaveSpecification={saveSpecification}
onCloneSpecificationPress={cloneSpecification}
onConfirmDeleteSpecification={deleteSpecification}
/>
);
})}
<Card
className={styles.addSpecification}
onPress={handleAddSpecificationPress}
>
<FormGroup>
<FormLabel>{translate('Name')}</FormLabel>
<FormInputGroup
type={inputTypes.TEXT}
name="name"
{...name}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('RemoveTagsAutomatically')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="removeTagsAutomatically"
helpText={translate('RemoveTagsAutomaticallyHelpText')}
{...removeTagsAutomatically}
onChange={handleInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('Tags')}</FormLabel>
<FormInputGroup
type={inputTypes.TAG}
name="tags"
onChange={handleInputChange}
{...tags}
/>
</FormGroup>
</Form>
<FieldSet legend={translate('Conditions')}>
<div className={styles.autoTaggings}>
{specifications.map((specification) => {
return (
<Specification
key={specification.id}
{...specification}
onCloneSpecificationPress={
handleCloneSpecificationPress
}
onConfirmDeleteSpecification={
handleConfirmDeleteSpecification
}
/>
);
})}
<Card
className={styles.addSpecification}
onPress={handleAddSpecificationPress}
>
<div className={styles.center}>
<Icon name={icons.ADD} size={45} />
</div>
</Card>
<div className={styles.center}>
<Icon name={icons.ADD} size={45} />
</div>
</FieldSet>
<AddSpecificationModal
isOpen={isAddSpecificationModalOpen}
onModalClose={handleAddSpecificationModalClose}
/>
<EditSpecificationModal
isOpen={isEditSpecificationModalOpen}
onModalClose={handleEditSpecificationModalClose}
/>
{/* <ImportAutoTaggingModal
isOpen={isImportAutoTaggingModalOpen}
onModalClose={onImportAutoTaggingModalClose}
/> */}
</Card>
</div>
</FieldSet>
<AddSpecificationModal
isOpen={isAddSpecificationModalOpen}
onModalClose={handleAddSpecificationModalClose}
/>
{editingSpecification ? (
<EditSpecificationModal
isOpen={isEditSpecificationModalOpen}
specification={editingSpecification}
onSave={handleSaveNewSpecification}
onModalClose={handleNewSpecificationModalClose}
/>
) : null}
</div>
</ModalBody>
@ -234,13 +210,6 @@ export default function EditAutoTaggingModalContent({
{translate('Delete')}
</Button>
) : null}
{/* <Button
className={styles.deleteButton}
onPress={onImportPress}
>
Import
</Button> */}
</div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>

View file

@ -4,8 +4,8 @@ import Link from 'Components/Link/Link';
import Menu from 'Components/Menu/Menu';
import MenuContent from 'Components/Menu/MenuContent';
import { sizes } from 'Helpers/Props';
import { AutoTaggingSpecification } from 'typings/AutoTagging';
import translate from 'Utilities/String/translate';
import { AutoTaggingSpecification } from '../useAutoTaggings';
import AddSpecificationPresetMenuItem from './AddSpecificationPresetMenuItem';
import styles from './AddSpecificationItem.css';

View file

@ -1,10 +1,11 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { AutoTaggingSpecification } from '../useAutoTaggings';
import AddSpecificationModalContent from './AddSpecificationModalContent';
interface AddSpecificationModalProps {
isOpen: boolean;
onModalClose: (options?: { specificationSelected: boolean }) => void;
onModalClose: (selectedSpec?: AutoTaggingSpecification) => void;
}
function AddSpecificationModal({

View file

@ -1,6 +1,4 @@
import React, { useCallback, useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import React, { useCallback } from 'react';
import Alert from 'Components/Alert';
import Button from 'Components/Link/Button';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@ -9,59 +7,50 @@ import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds } from 'Helpers/Props';
import {
fetchAutoTaggingSpecificationSchema,
selectAutoTaggingSpecificationSchema,
} from 'Store/Actions/settingsActions';
import translate from 'Utilities/String/translate';
import {
AutoTaggingSpecification,
useAutoTaggingSchema,
} from '../useAutoTaggings';
import AddSpecificationItem from './AddSpecificationItem';
import styles from './AddSpecificationModalContent.css';
interface AddSpecificationModalContentProps {
onModalClose: (options?: { specificationSelected: boolean }) => void;
onModalClose: (selectedSpec?: AutoTaggingSpecification) => void;
}
export default function AddSpecificationModalContent({
onModalClose,
}: AddSpecificationModalContentProps) {
const { isSchemaFetching, isSchemaPopulated, schemaError, schema } =
useSelector((state: AppState) => state.settings.autoTaggingSpecifications);
const dispatch = useDispatch();
const { schema, isSchemaFetching, schemaError } = useAutoTaggingSchema();
const onSpecificationSelect = useCallback(
({ implementation }: { implementation: string }) => {
dispatch(
selectAutoTaggingSpecificationSchema({
implementation,
presetName: name,
})
);
onModalClose({ specificationSelected: true });
const selected = schema.find((s) => s.implementation === implementation);
if (selected) {
onModalClose(selected);
}
},
[dispatch, onModalClose]
[schema, onModalClose]
);
const handleModalClose = useCallback(() => {
onModalClose();
}, [onModalClose]);
useEffect(() => {
dispatch(fetchAutoTaggingSpecificationSchema());
}, [dispatch]);
return (
<ModalContent onModalClose={onModalClose}>
<ModalContent onModalClose={handleModalClose}>
<ModalHeader>{translate('AddCondition')}</ModalHeader>
<ModalBody>
{isSchemaFetching ? <LoadingIndicator /> : null}
{!isSchemaFetching && !!schemaError ? (
{!isSchemaFetching && schemaError ? (
<Alert kind={kinds.DANGER}>{translate('AddConditionError')}</Alert>
) : null}
{isSchemaPopulated && !schemaError ? (
{!isSchemaFetching && !schemaError && schema.length ? (
<div>
<Alert kind={kinds.INFO}>
<div>{translate('SupportedAutoTaggingProperties')}</div>

View file

@ -1,37 +1,35 @@
import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import EditSpecificationModalContent, {
EditSpecificationModalContentProps,
} from './EditSpecificationModalContent';
import { AutoTaggingSpecification } from '../useAutoTaggings';
import EditSpecificationModalContent from './EditSpecificationModalContent';
interface EditSpecificationModalProps
extends EditSpecificationModalContentProps {
interface EditSpecificationModalProps {
isOpen: boolean;
specification: AutoTaggingSpecification | null;
onSave: (spec: AutoTaggingSpecification) => void;
onDeleteSpecificationPress?: () => void;
onModalClose: () => void;
}
function EditSpecificationModal({
isOpen,
specification,
onSave,
onDeleteSpecificationPress,
onModalClose,
...otherProps
}: EditSpecificationModalProps) {
const dispatch = useDispatch();
const onWrappedModalClose = useCallback(() => {
dispatch(
clearPendingChanges({ section: 'settings.autoTaggingSpecifications' })
);
onModalClose();
}, [onModalClose, dispatch]);
if (!specification) {
return null;
}
return (
<Modal size={sizes.MEDIUM} isOpen={isOpen} onModalClose={onModalClose}>
<EditSpecificationModalContent
{...otherProps}
onModalClose={onWrappedModalClose}
specification={specification}
onSave={onSave}
onDeleteSpecificationPress={onDeleteSpecificationPress}
onModalClose={onModalClose}
/>
</Modal>
);

View file

@ -1,6 +1,4 @@
import React, { useCallback } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { AutoTaggingSpecificationAppState } from 'App/State/SettingsAppState';
import React, { useCallback, useMemo } from 'react';
import Alert from 'Components/Alert';
import Form from 'Components/Form/Form';
import FormGroup from 'Components/Form/FormGroup';
@ -14,80 +12,128 @@ import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { usePendingChangesStore } from 'Helpers/Hooks/usePendingChangesStore';
import { usePendingFieldsStore } from 'Helpers/Hooks/usePendingFieldsStore';
import { inputTypes, kinds } from 'Helpers/Props';
import { useShowAdvancedSettings } from 'Settings/advancedSettingsStore';
import {
clearAutoTaggingSpecificationPending,
saveAutoTaggingSpecification,
setAutoTaggingSpecificationFieldValue,
setAutoTaggingSpecificationValue,
} from 'Store/Actions/settingsActions';
import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector';
import { AutoTaggingSpecification } from 'typings/AutoTagging';
import selectSettings from 'Store/Selectors/selectSettings';
import { InputChanged } from 'typings/inputs';
import translate from 'Utilities/String/translate';
import { AutoTaggingSpecification } from '../useAutoTaggings';
import styles from './EditSpecificationModalContent.css';
export interface EditSpecificationModalContentProps {
id?: number;
interface EditSpecificationModalContentProps {
specification: AutoTaggingSpecification;
onSave: (spec: AutoTaggingSpecification) => void;
onDeleteSpecificationPress?: () => void;
onModalClose: () => void;
}
function EditSpecificationModalContent({
id,
specification,
onSave,
onDeleteSpecificationPress,
onModalClose,
}: EditSpecificationModalContentProps) {
const advancedSettings = useShowAdvancedSettings();
const { item, ...otherFormProps } = useSelector(
createProviderSettingsSelectorHook<
AutoTaggingSpecification,
AutoTaggingSpecificationAppState
>('autoTaggingSpecifications', id)
);
const { pendingChanges, setPendingChange, clearPendingChanges } =
usePendingChangesStore<AutoTaggingSpecification>({});
const dispatch = useDispatch();
const {
pendingFields,
setPendingField,
hasPendingFields,
clearPendingFields,
} = usePendingFieldsStore();
const {
settings: item,
validationErrors,
validationWarnings,
} = useMemo(() => {
const combinedPendingChanges = hasPendingFields
? {
...pendingChanges,
fields: Object.fromEntries(pendingFields),
}
: pendingChanges;
return selectSettings(specification, combinedPendingChanges);
}, [specification, pendingChanges, pendingFields, hasPendingFields]);
const onInputChange = useCallback(
({ name, value }: InputChanged) => {
// @ts-expect-error - actions are not typed
dispatch(setAutoTaggingSpecificationValue({ name, value }));
setPendingChange(
name as keyof AutoTaggingSpecification,
value as AutoTaggingSpecification[keyof AutoTaggingSpecification]
);
},
[dispatch]
[setPendingChange]
);
const onFieldChange = useCallback(
({ name, value }: InputChanged) => {
// @ts-expect-error - actions are not typed
dispatch(setAutoTaggingSpecificationFieldValue({ name, value }));
setPendingField(name, value);
},
[dispatch]
[setPendingField]
);
const onDeletePress = useCallback(() => {
if (onDeleteSpecificationPress) {
onDeleteSpecificationPress();
}
}, [onDeleteSpecificationPress]);
const onCancelPress = useCallback(() => {
dispatch(clearAutoTaggingSpecificationPending());
clearPendingChanges();
clearPendingFields();
onModalClose();
}, [dispatch, onModalClose]);
}, [clearPendingChanges, clearPendingFields, onModalClose]);
const onSavePress = useCallback(() => {
dispatch(saveAutoTaggingSpecification({ id }));
let updatedSpec: AutoTaggingSpecification = {
...specification,
...pendingChanges,
};
if (hasPendingFields) {
updatedSpec = {
...updatedSpec,
fields: specification.fields.map((f) =>
pendingFields.has(f.name)
? { ...f, value: pendingFields.get(f.name) as typeof f.value }
: f
),
};
}
onSave(updatedSpec);
onModalClose();
}, [dispatch, id, onModalClose]);
}, [
specification,
pendingChanges,
pendingFields,
hasPendingFields,
onSave,
onModalClose,
]);
const { implementationName, name, negate, required, fields } = item;
return (
<ModalContent onModalClose={onCancelPress}>
<ModalHeader>
{id
{specification.id
? translate('EditConditionImplementation', { implementationName })
: translate('AddConditionImplementation', { implementationName })}
</ModalHeader>
<ModalBody>
<Form {...otherFormProps}>
<Form
validationErrors={validationErrors}
validationWarnings={validationWarnings}
>
{fields && fields.some((x) => x.label === 'Regular Expression') && (
<Alert kind={kinds.INFO}>
<div>
@ -167,11 +213,11 @@ function EditSpecificationModalContent({
</Form>
</ModalBody>
<ModalFooter>
{id ? (
{specification.id ? (
<Button
className={styles.deleteButton}
kind={kinds.DANGER}
onPress={onDeleteSpecificationPress}
onPress={onDeletePress}
>
{translate('Delete')}
</Button>

View file

@ -1,4 +1,4 @@
import React, { useCallback, useState } from 'react';
import React, { useCallback, useMemo, useState } from 'react';
import Card from 'Components/Card';
import Label from 'Components/Label';
import IconButton from 'Components/Link/IconButton';
@ -6,6 +6,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal';
import { icons, kinds } from 'Helpers/Props';
import Field from 'typings/Field';
import translate from 'Utilities/String/translate';
import { AutoTaggingSpecification } from '../useAutoTaggings';
import EditSpecificationModal from './EditSpecificationModal';
import styles from './Specification.css';
@ -17,38 +18,55 @@ interface SpecificationProps {
negate: boolean;
required: boolean;
fields: Field[];
onSaveSpecification: (spec: AutoTaggingSpecification) => void;
onConfirmDeleteSpecification: (specId: number) => void;
onCloneSpecificationPress: (specId: number) => void;
}
export default function Specification({
id,
implementation,
implementationName,
name,
required,
negate,
required,
fields,
onSaveSpecification,
onConfirmDeleteSpecification,
onCloneSpecificationPress,
}: SpecificationProps) {
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const spec = useMemo<AutoTaggingSpecification>(
() => ({
id,
implementation,
implementationName,
name,
negate,
required,
fields,
}),
[id, implementation, implementationName, name, negate, required, fields]
);
const onEditPress = useCallback(() => {
setIsEditModalOpen(true);
}, [setIsEditModalOpen]);
}, []);
const onEditModalClose = useCallback(() => {
setIsEditModalOpen(false);
}, [setIsEditModalOpen]);
}, []);
const onDeletePress = useCallback(() => {
setIsEditModalOpen(false);
setIsDeleteModalOpen(true);
}, [setIsEditModalOpen, setIsDeleteModalOpen]);
}, []);
const onDeleteModalClose = useCallback(() => {
setIsDeleteModalOpen(false);
}, [setIsDeleteModalOpen]);
}, []);
const onConfirmDelete = useCallback(() => {
onConfirmDeleteSpecification(id);
@ -89,10 +107,11 @@ export default function Specification({
</div>
<EditSpecificationModal
id={id}
isOpen={isEditModalOpen}
onModalClose={onEditModalClose}
specification={spec}
onSave={onSaveSpecification}
onDeleteSpecificationPress={onDeletePress}
onModalClose={onEditModalClose}
/>
<ConfirmModal

View file

@ -0,0 +1,196 @@
import { useCallback, useMemo } from 'react';
import ModelBase from 'App/ModelBase';
import { useProviderSchema } from 'Settings/useProviderSchema';
import {
useDeleteProvider,
useManageProviderSettings,
useProviderSettings,
} from 'Settings/useProviderSettings';
import Field from 'typings/Field';
import { sortByProp } from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
export interface AutoTaggingSpecification {
id: number;
name: string;
implementation: string;
implementationName: string;
negate: boolean;
required: boolean;
fields: Field[];
}
export interface AutoTagging extends ModelBase {
name: string;
removeTagsAutomatically: boolean;
tags: number[];
specifications: AutoTaggingSpecification[];
}
const PATH = '/autoTagging';
const DEFAULT_AUTO_TAGGING: AutoTagging = {
id: 0,
name: '',
removeTagsAutomatically: false,
tags: [],
specifications: [],
};
export const useAutoTaggings = () => {
return useProviderSettings<AutoTagging>({
path: PATH,
queryOptions: {
refetchOnWindowFocus: false,
},
});
};
export const useSortedAutoTaggings = () => {
const result = useAutoTaggings();
const sortedData = useMemo(
() => [...result.data].sort(sortByProp('name')),
[result.data]
);
return { ...result, data: sortedData };
};
export const useAutoTagging = (id: number | undefined) => {
const { data } = useAutoTaggings();
if (id === undefined) {
return undefined;
}
return data.find((at) => at.id === id);
};
export const useAutoTaggingsWithIds = (ids: number[]) => {
const { data } = useAutoTaggings();
return data.filter((at) => ids.includes(at.id));
};
export const useDeleteAutoTagging = (id: number) => {
const result = useDeleteProvider<AutoTagging>(id, PATH);
return {
...result,
deleteAutoTagging: result.deleteProvider,
};
};
export const useAutoTaggingSchema = (enabled: boolean = true) => {
return useProviderSchema<AutoTaggingSpecification>(PATH, enabled);
};
function getNextSpecId(specifications: AutoTaggingSpecification[]) {
return specifications.length > 0
? Math.max(...specifications.map((s) => s.id)) + 1
: 1;
}
export const useManageAutoTagging = (
id: number | undefined,
cloneId: number | undefined
) => {
const cloneSource = useAutoTagging(cloneId);
if (cloneId && !cloneSource) {
throw new Error(`AutoTagging with ID ${cloneId} not found`);
}
const defaultProvider = useMemo<AutoTagging>(() => {
if (cloneId && cloneSource) {
return {
...cloneSource,
id: 0,
name: translate('DefaultNameCopiedProfile', {
name: cloneSource.name,
}),
};
}
return DEFAULT_AUTO_TAGGING;
}, [cloneId, cloneSource]);
const manage = useManageProviderSettings<AutoTagging>(
id,
defaultProvider,
PATH
);
const specifications = useMemo(
() =>
manage.item.specifications.value.map((spec, i) => ({
...spec,
id: spec.id ?? i + 1,
})),
[manage.item.specifications.value]
);
const saveSpecification = useCallback(
(spec: AutoTaggingSpecification) => {
if (spec.id > 0 && specifications.some((s) => s.id === spec.id)) {
manage.updateValue(
'specifications',
specifications.map((s) => (s.id === spec.id ? spec : s))
);
return;
}
const newId = getNextSpecId(specifications);
manage.updateValue('specifications', [
...specifications,
{ ...spec, id: newId },
]);
},
[specifications, manage]
);
const deleteSpecification = useCallback(
(specId: number) => {
manage.updateValue(
'specifications',
specifications.filter((s) => s.id !== specId)
);
},
[specifications, manage]
);
const cloneSpecification = useCallback(
(specId: number) => {
const spec = specifications.find((s) => s.id === specId);
if (!spec) {
return;
}
const newId = getNextSpecId(specifications);
manage.updateValue('specifications', [
...specifications,
{
...spec,
id: newId,
name: translate('DefaultNameCopiedSpecification', {
name: spec.name,
}),
},
]);
},
[specifications, manage]
);
return {
...manage,
saveAutoTagging: manage.saveProvider,
specifications,
saveSpecification,
deleteSpecification,
cloneSpecification,
};
};

View file

@ -15,6 +15,7 @@ import useSeries from 'Series/useSeries';
import { useIndexersWithIds } from 'Settings/Indexers/useIndexers';
import { useConnectionsWithIds } from 'Settings/Notifications/useConnections';
import { useReleaseProfilesWithIds } from 'Settings/Profiles/Release/useReleaseProfiles';
import { useAutoTaggingsWithIds } from 'Settings/Tags/AutoTagging/useAutoTaggings';
import translate from 'Utilities/String/translate';
import TagDetailsDelayProfile from './TagDetailsDelayProfile';
import styles from './TagDetailsModalContent.css';
@ -109,12 +110,7 @@ function TagDetailsModalContent({
)
);
const autoTags = useSelector(
createMatchingItemSelector(
autoTagIds,
(state: AppState) => state.settings.autoTaggings.items
)
);
const autoTags = useAutoTaggingsWithIds(autoTagIds);
return (
<ModalContent onModalClose={onModalClose}>

View file

@ -1,194 +0,0 @@
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler';
import createClearReducer from 'Store/Actions/Creators/Reducers/createClearReducer';
import createSetProviderFieldValueReducer from 'Store/Actions/Creators/Reducers/createSetProviderFieldValueReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
import getNextId from 'Utilities/State/getNextId';
import getProviderState from 'Utilities/State/getProviderState';
import getSectionState from 'Utilities/State/getSectionState';
import selectProviderSchema from 'Utilities/State/selectProviderSchema';
import updateSectionState from 'Utilities/State/updateSectionState';
import translate from 'Utilities/String/translate';
import { removeItem, set, update, updateItem } from '../baseActions';
//
// Variables
const section = 'settings.autoTaggingSpecifications';
//
// Actions Types
export const FETCH_AUTO_TAGGING_SPECIFICATIONS = 'settings/autoTaggingSpecifications/fetchAutoTaggingSpecifications';
export const FETCH_AUTO_TAGGING_SPECIFICATION_SCHEMA = 'settings/autoTaggingSpecifications/fetchAutoTaggingSpecificationSchema';
export const SELECT_AUTO_TAGGING_SPECIFICATION_SCHEMA = 'settings/autoTaggingSpecifications/selectAutoTaggingSpecificationSchema';
export const SET_AUTO_TAGGING_SPECIFICATION_VALUE = 'settings/autoTaggingSpecifications/setAutoTaggingSpecificationValue';
export const SET_AUTO_TAGGING_SPECIFICATION_FIELD_VALUE = 'settings/autoTaggingSpecifications/setAutoTaggingSpecificationFieldValue';
export const SAVE_AUTO_TAGGING_SPECIFICATION = 'settings/autoTaggingSpecifications/saveAutoTaggingSpecification';
export const DELETE_AUTO_TAGGING_SPECIFICATION = 'settings/autoTaggingSpecifications/deleteAutoTaggingSpecification';
export const DELETE_ALL_AUTO_TAGGING_SPECIFICATION = 'settings/autoTaggingSpecifications/deleteAllAutoTaggingSpecification';
export const CLONE_AUTO_TAGGING_SPECIFICATION = 'settings/autoTaggingSpecifications/cloneAutoTaggingSpecification';
export const CLEAR_AUTO_TAGGING_SPECIFICATIONS = 'settings/autoTaggingSpecifications/clearAutoTaggingSpecifications';
export const CLEAR_AUTO_TAGGING_SPECIFICATION_PENDING = 'settings/autoTaggingSpecifications/clearAutoTaggingSpecificationPending';
//
// Action Creators
export const fetchAutoTaggingSpecifications = createThunk(FETCH_AUTO_TAGGING_SPECIFICATIONS);
export const fetchAutoTaggingSpecificationSchema = createThunk(FETCH_AUTO_TAGGING_SPECIFICATION_SCHEMA);
export const selectAutoTaggingSpecificationSchema = createAction(SELECT_AUTO_TAGGING_SPECIFICATION_SCHEMA);
export const saveAutoTaggingSpecification = createThunk(SAVE_AUTO_TAGGING_SPECIFICATION);
export const deleteAutoTaggingSpecification = createThunk(DELETE_AUTO_TAGGING_SPECIFICATION);
export const deleteAllAutoTaggingSpecification = createThunk(DELETE_ALL_AUTO_TAGGING_SPECIFICATION);
export const setAutoTaggingSpecificationValue = createAction(SET_AUTO_TAGGING_SPECIFICATION_VALUE, (payload) => {
return {
section,
...payload
};
});
export const setAutoTaggingSpecificationFieldValue = createAction(SET_AUTO_TAGGING_SPECIFICATION_FIELD_VALUE, (payload) => {
return {
section,
...payload
};
});
export const cloneAutoTaggingSpecification = createAction(CLONE_AUTO_TAGGING_SPECIFICATION);
export const clearAutoTaggingSpecification = createAction(CLEAR_AUTO_TAGGING_SPECIFICATIONS);
export const clearAutoTaggingSpecificationPending = createThunk(CLEAR_AUTO_TAGGING_SPECIFICATION_PENDING);
//
// Details
export default {
//
// State
defaultState: {
isPopulated: false,
error: null,
isSchemaFetching: false,
isSchemaPopulated: false,
schemaError: null,
schema: [],
selectedSchema: {},
isSaving: false,
saveError: null,
items: [],
pendingChanges: {}
},
//
// Action Handlers
actionHandlers: {
[FETCH_AUTO_TAGGING_SPECIFICATION_SCHEMA]: createFetchSchemaHandler(section, '/autoTagging/schema'),
[FETCH_AUTO_TAGGING_SPECIFICATIONS]: (getState, payload, dispatch) => {
let tags = [];
if (payload.id) {
const cfState = getSectionState(getState(), 'settings.autoTaggings', true);
const cf = cfState.items[cfState.itemMap[payload.id]];
tags = cf.specifications.map((tag, i) => {
return {
id: i + 1,
...tag
};
});
}
dispatch(batchActions([
update({ section, data: tags }),
set({
section,
isPopulated: true
})
]));
},
[SAVE_AUTO_TAGGING_SPECIFICATION]: (getState, payload, dispatch) => {
const {
id,
...otherPayload
} = payload;
const saveData = getProviderState({ id, ...otherPayload }, getState, section, false);
// we have to set id since not actually posting to server yet
if (!saveData.id) {
saveData.id = getNextId(getState().settings.autoTaggingSpecifications.items);
}
dispatch(batchActions([
updateItem({ section, ...saveData }),
set({
section,
pendingChanges: {}
})
]));
},
[DELETE_AUTO_TAGGING_SPECIFICATION]: (getState, payload, dispatch) => {
const id = payload.id;
return dispatch(removeItem({ section, id }));
},
[DELETE_ALL_AUTO_TAGGING_SPECIFICATION]: (getState, payload, dispatch) => {
return dispatch(set({
section,
items: []
}));
},
[CLEAR_AUTO_TAGGING_SPECIFICATION_PENDING]: (getState, payload, dispatch) => {
return dispatch(set({
section,
pendingChanges: {}
}));
}
},
//
// Reducers
reducers: {
[SET_AUTO_TAGGING_SPECIFICATION_VALUE]: createSetSettingValueReducer(section),
[SET_AUTO_TAGGING_SPECIFICATION_FIELD_VALUE]: createSetProviderFieldValueReducer(section),
[SELECT_AUTO_TAGGING_SPECIFICATION_SCHEMA]: (state, { payload }) => {
return selectProviderSchema(state, section, payload, (selectedSchema) => {
return selectedSchema;
});
},
[CLONE_AUTO_TAGGING_SPECIFICATION]: function(state, { payload }) {
const id = payload.id;
const newState = getSectionState(state, section);
const items = newState.items;
const item = items.find((i) => i.id === id);
const newId = getNextId(newState.items);
const newItem = {
...item,
id: newId,
name: translate('DefaultNameCopiedSpecification', { name: item.name })
};
newState.items = [...items, newItem];
newState.itemMap[newId] = newState.items.length - 1;
return updateSectionState(state, section, newState);
},
[CLEAR_AUTO_TAGGING_SPECIFICATIONS]: createClearReducer(section, {
isPopulated: false,
error: null,
items: []
})
}
};

View file

@ -1,110 +0,0 @@
import { createAction } from 'redux-actions';
import { set } from 'Store/Actions/baseActions';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks';
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
import translate from 'Utilities/String/translate';
//
// Variables
const section = 'settings.autoTaggings';
//
// Actions Types
export const FETCH_AUTO_TAGGINGS = 'settings/autoTaggings/fetchAutoTaggings';
export const SAVE_AUTO_TAGGING = 'settings/autoTaggings/saveAutoTagging';
export const DELETE_AUTO_TAGGING = 'settings/autoTaggings/deleteAutoTagging';
export const SET_AUTO_TAGGING_VALUE = 'settings/autoTaggings/setAutoTaggingValue';
export const CLONE_AUTO_TAGGING = 'settings/autoTaggings/cloneAutoTagging';
//
// Action Creators
export const fetchAutoTaggings = createThunk(FETCH_AUTO_TAGGINGS);
export const saveAutoTagging = createThunk(SAVE_AUTO_TAGGING);
export const deleteAutoTagging = createThunk(DELETE_AUTO_TAGGING);
export const setAutoTaggingValue = createAction(SET_AUTO_TAGGING_VALUE, (payload) => {
return {
section,
...payload
};
});
export const cloneAutoTagging = createAction(CLONE_AUTO_TAGGING);
//
// Details
export default {
//
// State
defaultState: {
isSchemaFetching: false,
isSchemaPopulated: false,
isFetching: false,
isPopulated: false,
schema: {
removeTagsAutomatically: false,
tags: []
},
error: null,
isDeleting: false,
deleteError: null,
isSaving: false,
saveError: null,
items: [],
pendingChanges: {}
},
//
// Action Handlers
actionHandlers: {
[FETCH_AUTO_TAGGINGS]: createFetchHandler(section, '/autoTagging'),
[DELETE_AUTO_TAGGING]: createRemoveItemHandler(section, '/autoTagging'),
[SAVE_AUTO_TAGGING]: (getState, payload, dispatch) => {
// move the format tags in as a pending change
const state = getState();
const pendingChanges = state.settings.autoTaggings.pendingChanges;
pendingChanges.specifications = state.settings.autoTaggingSpecifications.items;
dispatch(set({
section,
pendingChanges
}));
createSaveProviderHandler(section, '/autoTagging')(getState, payload, dispatch);
}
},
//
// Reducers
reducers: {
[SET_AUTO_TAGGING_VALUE]: createSetSettingValueReducer(section),
[CLONE_AUTO_TAGGING]: function(state, { payload }) {
const id = payload.id;
const newState = getSectionState(state, section);
const item = newState.items.find((i) => i.id === id);
const pendingChanges = { ...item, id: 0 };
delete pendingChanges.id;
pendingChanges.name = translate('DefaultNameCopiedProfile', { name: pendingChanges.name });
newState.pendingChanges = pendingChanges;
return updateSectionState(state, section, newState);
}
}
};

View file

@ -1,7 +1,5 @@
import { handleThunks } from 'Store/thunks';
import createHandleActions from './Creators/createHandleActions';
import autoTaggings from './Settings/autoTaggings';
import autoTaggingSpecifications from './Settings/autoTaggingSpecifications';
import customFormats from './Settings/customFormats';
import customFormatSpecifications from './Settings/customFormatSpecifications';
import delayProfiles from './Settings/delayProfiles';
@ -10,8 +8,6 @@ import downloadClients from './Settings/downloadClients';
import importListOptions from './Settings/importListOptions';
import importLists from './Settings/importLists';
export * from './Settings/autoTaggingSpecifications';
export * from './Settings/autoTaggings';
export * from './Settings/customFormatSpecifications.js';
export * from './Settings/customFormats';
export * from './Settings/delayProfiles';
@ -30,8 +26,6 @@ export const section = 'settings';
export const defaultState = {
advancedSettings: false,
autoTaggingSpecifications: autoTaggingSpecifications.defaultState,
autoTaggings: autoTaggings.defaultState,
customFormatSpecifications: customFormatSpecifications.defaultState,
customFormats: customFormats.defaultState,
delayProfiles: delayProfiles.defaultState,
@ -49,8 +43,6 @@ export const persistState = [
// Action Handlers
export const actionHandlers = handleThunks({
...autoTaggingSpecifications.actionHandlers,
...autoTaggings.actionHandlers,
...customFormatSpecifications.actionHandlers,
...customFormats.actionHandlers,
...delayProfiles.actionHandlers,
@ -64,8 +56,6 @@ export const actionHandlers = handleThunks({
// Reducers
export const reducers = createHandleActions({
...autoTaggingSpecifications.reducers,
...autoTaggings.reducers,
...customFormatSpecifications.reducers,
...customFormats.reducers,
...delayProfiles.reducers,

View file

@ -1,21 +0,0 @@
import ModelBase from 'App/ModelBase';
import Field from './Field';
export interface AutoTaggingSpecification {
id: number;
name: string;
implementation: string;
implementationName: string;
negate: boolean;
required: boolean;
fields: Field[];
}
interface AutoTagging extends ModelBase {
name: string;
removeTagsAutomatically: boolean;
tags: number[];
specifications: AutoTaggingSpecification[];
}
export default AutoTagging;

View file

@ -0,0 +1,114 @@
using FluentValidation;
using FluentValidation.Results;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Common.Extensions;
using NzbDrone.Core.AutoTagging;
using NzbDrone.Core.AutoTagging.Specifications;
using NzbDrone.Core.Validation;
using Sonarr.Http;
using Sonarr.Http.REST;
using Sonarr.Http.REST.Attributes;
namespace Sonarr.Api.V5.AutoTagging;
[V5ApiController]
public class AutoTaggingController : RestController<AutoTaggingResource>
{
private readonly IAutoTaggingService _autoTaggingService;
private readonly List<IAutoTaggingSpecification> _specifications;
public AutoTaggingController(IAutoTaggingService autoTaggingService,
List<IAutoTaggingSpecification> specifications)
{
_autoTaggingService = autoTaggingService;
_specifications = specifications;
SharedValidator.RuleFor(c => c.Name).NotEmpty();
SharedValidator.RuleFor(c => c.Name)
.Must((v, c) => !_autoTaggingService.All().Any(f => f.Name == c && f.Id != v.Id)).WithMessage("Must be unique.");
SharedValidator.RuleFor(c => c.Tags).NotEmpty();
SharedValidator.RuleFor(c => c).Custom((autoTag, context) =>
{
if (!autoTag.Specifications.Any())
{
context.AddFailure("Must contain at least one Condition");
}
if (autoTag.Specifications.Any(s => s.Name.IsNullOrWhiteSpace()))
{
context.AddFailure("Condition name(s) cannot be empty or consist of only spaces");
}
});
}
protected override AutoTaggingResource GetResourceById(int id)
{
return _autoTaggingService.GetById(id).ToResource();
}
[HttpGet]
[Produces("application/json")]
public List<AutoTaggingResource> GetAll()
{
return _autoTaggingService.All().ToResource();
}
[RestPostById]
[Consumes("application/json")]
public ActionResult<AutoTaggingResource> Create([FromBody] AutoTaggingResource autoTagResource)
{
var model = autoTagResource.ToModel(_specifications);
Validate(model);
return Created(_autoTaggingService.Insert(model).Id);
}
[RestPutById]
[Consumes("application/json")]
public ActionResult<AutoTaggingResource> Update([FromBody] AutoTaggingResource resource)
{
var model = resource.ToModel(_specifications);
Validate(model);
_autoTaggingService.Update(model);
return Accepted(model.Id);
}
[RestDeleteById]
public NoContent DeleteAutoTagging(int id)
{
_autoTaggingService.Delete(id);
return TypedResults.NoContent();
}
[HttpGet("schema")]
[Produces("application/json")]
public List<AutoTaggingSpecificationSchema> GetTemplates()
{
return _specifications.OrderBy(x => x.Order).Select(x => x.ToSchema()).ToList();
}
private void Validate(AutoTag definition)
{
foreach (var validationResult in definition.Specifications.Select(spec => spec.Validate()))
{
VerifyValidationResult(validationResult);
}
}
private void VerifyValidationResult(ValidationResult validationResult)
{
var result = new NzbDroneValidationResult(validationResult.Errors);
if (!result.IsValid)
{
throw new ValidationException(result.Errors);
}
}
}

View file

@ -0,0 +1,72 @@
using System.Text.Json.Serialization;
using NzbDrone.Core.AutoTagging;
using NzbDrone.Core.AutoTagging.Specifications;
using Sonarr.Http.ClientSchema;
using Sonarr.Http.REST;
namespace Sonarr.Api.V5.AutoTagging;
public class AutoTaggingResource : RestResource
{
[JsonIgnore(Condition = JsonIgnoreCondition.Never)]
public override int Id { get; set; }
public string? Name { get; set; }
public bool RemoveTagsAutomatically { get; set; }
public HashSet<int> Tags { get; set; } = [];
public List<AutoTaggingSpecificationSchema> Specifications { get; set; } = [];
}
public static class AutoTaggingResourceMapper
{
public static AutoTaggingResource ToResource(this AutoTag model)
{
return new AutoTaggingResource
{
Id = model.Id,
Name = model.Name,
RemoveTagsAutomatically = model.RemoveTagsAutomatically,
Tags = model.Tags,
Specifications = model.Specifications.Select(x => x.ToSchema()).ToList()
};
}
public static List<AutoTaggingResource> ToResource(this IEnumerable<AutoTag> models)
{
return models.Select(m => m.ToResource()).ToList();
}
public static AutoTag ToModel(this AutoTaggingResource resource, List<IAutoTaggingSpecification> specifications)
{
return new AutoTag
{
Id = resource.Id,
Name = resource.Name,
RemoveTagsAutomatically = resource.RemoveTagsAutomatically,
Tags = resource.Tags,
Specifications = resource.Specifications.Select(x => MapSpecification(x, specifications)).ToList()
};
}
private static IAutoTaggingSpecification MapSpecification(AutoTaggingSpecificationSchema resource, List<IAutoTaggingSpecification> specifications)
{
var matchingSpec =
specifications.SingleOrDefault(x => x.GetType().Name == resource.Implementation);
if (matchingSpec is null)
{
throw new ArgumentException(
$"{resource.Implementation} is not a valid specification implementation");
}
var type = matchingSpec.GetType();
// Finding the exact current specification isn't possible given the dynamic nature of them and the possibility that multiple
// of the same type exist within the same format. Passing in null is safe as long as there never exists a specification that
// relies on additional privacy.
var spec = (IAutoTaggingSpecification)SchemaBuilder.ReadFromSchema(resource.Fields, type, null);
spec.Name = resource.Name;
spec.Negate = resource.Negate;
spec.Required = resource.Required;
return spec;
}
}

View file

@ -0,0 +1,31 @@
using NzbDrone.Core.AutoTagging.Specifications;
using Sonarr.Http.ClientSchema;
using Sonarr.Http.REST;
namespace Sonarr.Api.V5.AutoTagging;
public class AutoTaggingSpecificationSchema : RestResource
{
public string? Name { get; set; }
public string? Implementation { get; set; }
public string? ImplementationName { get; set; }
public bool Negate { get; set; }
public bool Required { get; set; }
public List<Field> Fields { get; set; } = [];
}
public static class AutoTaggingSpecificationSchemaMapper
{
public static AutoTaggingSpecificationSchema ToSchema(this IAutoTaggingSpecification model)
{
return new AutoTaggingSpecificationSchema
{
Name = model.Name,
Implementation = model.GetType().Name,
ImplementationName = model.ImplementationName,
Negate = model.Negate,
Required = model.Required,
Fields = SchemaBuilder.ToSchema(model)
};
}
}