diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index ece88ef93..577b98105 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -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 & { presets: T[]; }; -export interface AutoTaggingAppState - extends AppSectionState, - AppSectionDeleteState, - AppSectionSaveState {} - -export interface AutoTaggingSpecificationAppState - extends AppSectionState, - AppSectionDeleteState, - AppSectionSaveState, - AppSectionSchemaState {} - export interface DelayProfileAppState extends AppSectionListState, AppSectionDeleteState, @@ -70,8 +58,6 @@ export interface ImportListOptionsSettingsAppState AppSectionSaveState {} interface SettingsAppState { - autoTaggings: AutoTaggingAppState; - autoTaggingSpecifications: AutoTaggingSpecificationAppState; customFormats: CustomFormatAppState; customFormatSpecifications: CustomFormatSpecificationAppState; delayProfiles: DelayProfileAppState; diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTagging.tsx b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.tsx index 8f1179188..149e10985 100644 --- a/frontend/src/Settings/Tags/AutoTagging/AutoTagging.tsx +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.tsx @@ -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); diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.tsx b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.tsx index 7031d9260..5bd12a0db 100644 --- a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.tsx +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.tsx @@ -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( - '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(); + const [cloneId, setCloneId] = useState(); - 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 (
@@ -76,9 +52,7 @@ export default function AutoTaggings() { ); @@ -93,7 +67,7 @@ export default function AutoTaggings() { diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.tsx b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.tsx index 96bd117d6..b9236d5e2 100644 --- a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.tsx +++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.tsx @@ -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 ( - + ); diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.tsx b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.tsx index 6317a5b61..b89ac5534 100644 --- a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.tsx +++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.tsx @@ -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(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({
- {isFetching ? : null} +
+ + {translate('Name')} - {!isFetching && !!error ? ( - {translate('AddAutoTagError')} - ) : null} + + - {!isFetching && !error && specificationsPopulated ? ( -
- + {translate('RemoveTagsAutomatically')} + + + + + + {translate('Tags')} + + + + + +
+
+ {specifications.map((specification) => { + return ( + + ); + })} + + - - {translate('Name')} - - - - - - {translate('RemoveTagsAutomatically')} - - - - - - {translate('Tags')} - - - - - -
-
- {specifications.map((specification) => { - return ( - - ); - })} - - -
- -
-
+
+
-
- - - - - - {/* */} +
+
+ + + + {editingSpecification ? ( + ) : null}
@@ -234,13 +210,6 @@ export default function EditAutoTaggingModalContent({ {translate('Delete')} ) : null} - - {/* */}
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.tsx b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.tsx index 774f367e9..89c80be1e 100644 --- a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.tsx +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.tsx @@ -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'; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.tsx b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.tsx index e56486625..eddb5b88e 100644 --- a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.tsx +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.tsx @@ -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({ diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.tsx b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.tsx index 12b8373ff..b326c49d7 100644 --- a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.tsx +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.tsx @@ -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 ( - + {translate('AddCondition')} {isSchemaFetching ? : null} - {!isSchemaFetching && !!schemaError ? ( + {!isSchemaFetching && schemaError ? ( {translate('AddConditionError')} ) : null} - {isSchemaPopulated && !schemaError ? ( + {!isSchemaFetching && !schemaError && schema.length ? (
{translate('SupportedAutoTaggingProperties')}
diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.tsx b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.tsx index 5b4041e62..71eb22646 100644 --- a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.tsx +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.tsx @@ -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 ( ); diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.tsx b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.tsx index 8bd43a15d..d20721911 100644 --- a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.tsx +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.tsx @@ -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({}); - 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 ( - {id + {specification.id ? translate('EditConditionImplementation', { implementationName }) : translate('AddConditionImplementation', { implementationName })} -
+ {fields && fields.some((x) => x.label === 'Regular Expression') && (
@@ -167,11 +213,11 @@ function EditSpecificationModalContent({ - {id ? ( + {specification.id ? ( diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.tsx b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.tsx index b49bf27c6..7f2bc93da 100644 --- a/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.tsx +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.tsx @@ -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( + () => ({ + 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({
{ + return useProviderSettings({ + 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(id, PATH); + + return { + ...result, + deleteAutoTagging: result.deleteProvider, + }; +}; + +export const useAutoTaggingSchema = (enabled: boolean = true) => { + return useProviderSchema(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(() => { + if (cloneId && cloneSource) { + return { + ...cloneSource, + id: 0, + name: translate('DefaultNameCopiedProfile', { + name: cloneSource.name, + }), + }; + } + + return DEFAULT_AUTO_TAGGING; + }, [cloneId, cloneSource]); + + const manage = useManageProviderSettings( + 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, + }; +}; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.tsx b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.tsx index fd003ecb8..c744666cf 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.tsx +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.tsx @@ -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 ( diff --git a/frontend/src/Store/Actions/Settings/autoTaggingSpecifications.js b/frontend/src/Store/Actions/Settings/autoTaggingSpecifications.js deleted file mode 100644 index a5099030c..000000000 --- a/frontend/src/Store/Actions/Settings/autoTaggingSpecifications.js +++ /dev/null @@ -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: [] - }) - } -}; diff --git a/frontend/src/Store/Actions/Settings/autoTaggings.js b/frontend/src/Store/Actions/Settings/autoTaggings.js deleted file mode 100644 index c1d9c122a..000000000 --- a/frontend/src/Store/Actions/Settings/autoTaggings.js +++ /dev/null @@ -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); - } - } - -}; diff --git a/frontend/src/Store/Actions/settingsActions.js b/frontend/src/Store/Actions/settingsActions.js index 294d4dca0..d09729c7a 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -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, diff --git a/frontend/src/typings/AutoTagging.ts b/frontend/src/typings/AutoTagging.ts deleted file mode 100644 index fab9759d3..000000000 --- a/frontend/src/typings/AutoTagging.ts +++ /dev/null @@ -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; diff --git a/src/Sonarr.Api.V5/AutoTagging/AutoTaggingController.cs b/src/Sonarr.Api.V5/AutoTagging/AutoTaggingController.cs new file mode 100644 index 000000000..0c3332ac0 --- /dev/null +++ b/src/Sonarr.Api.V5/AutoTagging/AutoTaggingController.cs @@ -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 +{ + private readonly IAutoTaggingService _autoTaggingService; + private readonly List _specifications; + + public AutoTaggingController(IAutoTaggingService autoTaggingService, + List 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 GetAll() + { + return _autoTaggingService.All().ToResource(); + } + + [RestPostById] + [Consumes("application/json")] + public ActionResult Create([FromBody] AutoTaggingResource autoTagResource) + { + var model = autoTagResource.ToModel(_specifications); + + Validate(model); + + return Created(_autoTaggingService.Insert(model).Id); + } + + [RestPutById] + [Consumes("application/json")] + public ActionResult 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 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); + } + } +} diff --git a/src/Sonarr.Api.V5/AutoTagging/AutoTaggingResource.cs b/src/Sonarr.Api.V5/AutoTagging/AutoTaggingResource.cs new file mode 100644 index 000000000..eb1bc42f5 --- /dev/null +++ b/src/Sonarr.Api.V5/AutoTagging/AutoTaggingResource.cs @@ -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 Tags { get; set; } = []; + public List 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 ToResource(this IEnumerable models) + { + return models.Select(m => m.ToResource()).ToList(); + } + + public static AutoTag ToModel(this AutoTaggingResource resource, List 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 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; + } +} diff --git a/src/Sonarr.Api.V5/AutoTagging/AutoTaggingSpecificationSchema.cs b/src/Sonarr.Api.V5/AutoTagging/AutoTaggingSpecificationSchema.cs new file mode 100644 index 000000000..73e392762 --- /dev/null +++ b/src/Sonarr.Api.V5/AutoTagging/AutoTaggingSpecificationSchema.cs @@ -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 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) + }; + } +}