diff --git a/frontend/src/Components/Form/ProviderFieldFormGroup.js b/frontend/src/Components/Form/ProviderFieldFormGroup.js index 4fa86583e..79191b2b0 100644 --- a/frontend/src/Components/Form/ProviderFieldFormGroup.js +++ b/frontend/src/Components/Form/ProviderFieldFormGroup.js @@ -35,6 +35,8 @@ function getType({ type, selectOptionsProviderAction }) { return inputTypes.TEXT; case 'oAuth': return inputTypes.OAUTH; + case 'rootFolder': + return inputTypes.ROOT_FOLDER_SELECT; default: return inputTypes.TEXT; } diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css new file mode 100644 index 000000000..b1e2de95b --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css @@ -0,0 +1,38 @@ +.autoTagging { + composes: card from '~Components/Card.css'; + + width: 300px; +} + +.nameContainer { + display: flex; + justify-content: space-between; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.cloneButton { + composes: button from '~Components/Link/IconButton.css'; + + height: 36px; +} + +.formats { + display: flex; + flex-wrap: wrap; + margin-top: 5px; + pointer-events: all; +} + +.tooltipLabel { + composes: label from '~Components/Label.css'; + + margin: 0; + border: none; +} diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css.d.ts new file mode 100644 index 000000000..b6b665429 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.css.d.ts @@ -0,0 +1,12 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'autoTagging': string; + 'cloneButton': string; + 'formats': string; + 'name': string; + 'nameContainer': string; + 'tooltipLabel': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTagging.js b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.js new file mode 100644 index 000000000..ed72ad99c --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTagging.js @@ -0,0 +1,135 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useState } from 'react'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import TagList from 'Components/TagList'; +import { icons, kinds } from 'Helpers/Props'; +import EditAutoTaggingModal from './EditAutoTaggingModal'; +import styles from './AutoTagging.css'; + +export default function AutoTagging(props) { + const { + id, + name, + tags, + tagList, + specifications, + isDeleting, + onConfirmDeleteAutoTagging, + onCloneAutoTaggingPress + } = props; + + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + 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(() => { + onConfirmDeleteAutoTagging(id); + }, [id, onConfirmDeleteAutoTagging]); + + const onClonePress = useCallback(() => { + onCloneAutoTaggingPress(id); + }, [id, onCloneAutoTaggingPress]); + + return ( + +
+
+ {name} +
+ +
+ +
+
+ + + +
+ { + specifications.map((item, index) => { + if (!item) { + return null; + } + + let kind = kinds.DEFAULT; + if (item.required) { + kind = kinds.SUCCESS; + } + if (item.negate) { + kind = kinds.DANGER; + } + + return ( + + ); + }) + } +
+ + + + +
+ ); +} + +AutoTagging.propTypes = { + id: PropTypes.number.isRequired, + name: PropTypes.string.isRequired, + specifications: PropTypes.arrayOf(PropTypes.object).isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + tagList: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + onConfirmDeleteAutoTagging: PropTypes.func.isRequired, + onCloneAutoTaggingPress: PropTypes.func.isRequired +}; diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css new file mode 100644 index 000000000..40950bd5f --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css @@ -0,0 +1,21 @@ +.autoTaggings { + display: flex; + flex-wrap: wrap; +} + +.addAutoTagging { + composes: autoTagging from '~./AutoTagging.css'; + + background-color: var(--cardAlternateBackgroundColor); + color: var(--gray); + text-align: center; + font-size: 45px; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid var(--borderColor); + border-radius: 4px; + background-color: var(--cardCenterBackgroundColor); +} diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css.d.ts new file mode 100644 index 000000000..ef3094d3b --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'addAutoTagging': string; + 'autoTaggings': string; + 'center': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js new file mode 100644 index 000000000..9c9005e7b --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/AutoTaggings.js @@ -0,0 +1,103 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +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 { fetchRootFolders } from 'Store/Actions/rootFolderActions'; +import { cloneAutoTagging, deleteAutoTagging, fetchAutoTaggings } from 'Store/Actions/settingsActions'; +import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import sortByName from 'Utilities/Array/sortByName'; +import AutoTagging from './AutoTagging'; +import EditAutoTaggingModal from './EditAutoTaggingModal'; +import styles from './AutoTaggings.css'; + +export default function AutoTaggings() { + const { + error, + items, + isDeleting, + isFetching, + isPopulated + } = useSelector( + createSortedSectionSelector('settings.autoTaggings', sortByName) + ); + + const tagList = useSelector(createTagsSelector()); + const dispatch = useDispatch(); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [tagsFromId, setTagsFromId] = useState(undefined); + + const onClonePress = useCallback((id) => { + dispatch(cloneAutoTagging({ id })); + + setTagsFromId(id); + setIsEditModalOpen(true); + }, [dispatch, setIsEditModalOpen]); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onConfirmDelete = useCallback((id) => { + dispatch(deleteAutoTagging({ id })); + }, [dispatch]); + + useEffect(() => { + dispatch(fetchAutoTaggings()); + dispatch(fetchRootFolders()); + }, [dispatch]); + + return ( +
+ +
+ { + items.map((item) => { + return ( + + ); + }) + } + + +
+ +
+
+
+ + + +
+
+ ); +} diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.js b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.js new file mode 100644 index 000000000..c6f810785 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModal.js @@ -0,0 +1,50 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditAutoTaggingModalContent from './EditAutoTaggingModalContent'; + +export default function EditAutoTaggingModal(props) { + const { + isOpen, + onModalClose: onOriginalModalClose, + ...otherProps + } = props; + + const dispatch = useDispatch(); + const [height, setHeight] = useState('auto'); + + const onContentHeightChange = useCallback((h) => { + if (height === 'auto' || h > height) { + setHeight(h); + } + }, [height, setHeight]); + + const onModalClose = useCallback(() => { + dispatch(clearPendingChanges({ section: 'settings.autoTaggings' })); + onOriginalModalClose(); + }, [dispatch, onOriginalModalClose]); + + return ( + + + + ); +} + +EditAutoTaggingModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css new file mode 100644 index 000000000..a197dbcd4 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css @@ -0,0 +1,27 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} + +.rightButtons { + justify-content: flex-end; + margin-right: auto; +} + +.addSpecification { + composes: autoTagging from '~./AutoTagging.css'; + + background-color: var(--cardAlternateBackgroundColor); + color: var(--gray); + text-align: center; + font-size: 45px; +} + +.center { + display: inline-block; + padding: 5px 20px 0; + border: 1px solid var(--borderColor); + border-radius: 4px; + background-color: var(--cardCenterBackgroundColor); +} diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css.d.ts new file mode 100644 index 000000000..1339caf02 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.css.d.ts @@ -0,0 +1,10 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'addSpecification': string; + 'center': string; + 'deleteButton': string; + 'rightButtons': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.js b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.js new file mode 100644 index 000000000..28bea7d9c --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/EditAutoTaggingModalContent.js @@ -0,0 +1,268 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Card from 'Components/Card'; +import FieldSet from 'Components/FieldSet'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +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 { icons, inputTypes, kinds } from 'Helpers/Props'; +import { + cloneAutoTaggingSpecification, + deleteAutoTaggingSpecification, + fetchAutoTaggingSpecifications, + saveAutoTagging, + setAutoTaggingValue +} from 'Store/Actions/settingsActions'; +import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector'; +import AddSpecificationModal from './Specifications/AddSpecificationModal'; +import EditSpecificationModal from './Specifications/EditSpecificationModal'; +import Specification from './Specifications/Specification'; +import styles from './EditAutoTaggingModalContent.css'; + +export default function EditAutoTaggingModalContent(props) { + const { + id, + tagsFromId, + onModalClose, + onDeleteAutoTaggingPress + } = props; + + const { + error, + item, + isFetching, + isSaving, + saveError, + validationErrors, + validationWarnings + } = useSelector(createProviderSettingsSelectorHook('autoTaggings', id)); + + const { + isPopulated: specificationsPopulated, + items: specifications + } = useSelector((state) => state.settings.autoTaggingSpecifications); + + const dispatch = useDispatch(); + const [isAddSpecificationModalOpen, setIsAddSpecificationModalOpen] = useState(false); + const [isEditSpecificationModalOpen, setIsEditSpecificationModalOpen] = useState(false); + // const [isImportAutoTaggingModalOpen, setIsImportAutoTaggingModalOpen] = useState(false); + + const onAddSpecificationPress = useCallback(() => { + setIsAddSpecificationModalOpen(true); + }, [setIsAddSpecificationModalOpen]); + + const onAddSpecificationModalClose = useCallback(({ specificationSelected = false } = {}) => { + setIsAddSpecificationModalOpen(false); + setIsEditSpecificationModalOpen(specificationSelected); + }, [setIsAddSpecificationModalOpen]); + + const onEditSpecificationModalClose = useCallback(() => { + setIsEditSpecificationModalOpen(false); + }, [setIsEditSpecificationModalOpen]); + + const onInputChange = useCallback(({ name, value }) => { + dispatch(setAutoTaggingValue({ name, value })); + }, [dispatch]); + + const onSavePress = useCallback(() => { + dispatch(saveAutoTagging({ id })); + }, [dispatch, id]); + + const onCloneSpecificationPress = useCallback((specId) => { + dispatch(cloneAutoTaggingSpecification({ id: specId })); + }, [dispatch]); + + const onConfirmDeleteSpecification = useCallback((specId) => { + dispatch(deleteAutoTaggingSpecification({ id: specId })); + }, [dispatch]); + + useEffect(() => { + dispatch(fetchAutoTaggingSpecifications({ id: tagsFromId || id })); + }, [id, tagsFromId, dispatch]); + + const isSavingRef = useRef(); + + useEffect(() => { + if (isSavingRef.current && !isSaving && !saveError) { + onModalClose(); + } + + isSavingRef.current = isSaving; + }, [isSaving, saveError, onModalClose]); + + const { + name, + removeTagsAutomatically, + tags + } = item; + + return ( + + + + {id ? 'Edit Auto Tag' : 'Add Auto Tag'} + + + +
+ { + isFetching ? : null + } + + { + !isFetching && !!error ? +
+ {'Unable to add a new auto tag, please try again.'} +
: + null + } + + { + !isFetching && !error && specificationsPopulated ? +
+
+ + + Name + + + + + + + {'Remove Tags Automatically'} + + + + + + Tags + + + +
+ +
+
+ { + specifications.map((tag) => { + return ( + + ); + }) + } + + +
+ +
+
+
+
+ + + + + + {/* */} + +
: + null + } +
+
+ +
+ { + id ? + : + null + } + + {/* */} +
+ + + + + Save + +
+
+ ); +} + +EditAutoTaggingModalContent.propTypes = { + id: PropTypes.number, + tagsFromId: PropTypes.number, + onModalClose: PropTypes.func.isRequired, + onDeleteAutoTaggingPress: PropTypes.func +}; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css new file mode 100644 index 000000000..eabcae750 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css @@ -0,0 +1,44 @@ +.specification { + composes: card from '~Components/Card.css'; + + position: relative; + width: 300px; + height: 100px; +} + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + padding: 10px; +} + +.name { + text-align: center; + font-weight: lighter; + font-size: 24px; +} + +.actions { + margin-top: 20px; + text-align: right; +} + +.presetsMenu { + composes: menu from '~Components/Menu/Menu.css'; + + display: inline-block; + margin: 0 5px; +} + +.presetsMenuButton { + composes: button from '~Components/Link/Button.css'; + + &::after { + margin-left: 5px; + content: '\25BE'; + } +} diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css.d.ts new file mode 100644 index 000000000..7f8a93de9 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.css.d.ts @@ -0,0 +1,13 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'actions': string; + 'name': string; + 'overlay': string; + 'presetsMenu': string; + 'presetsMenuButton': string; + 'specification': string; + 'underlay': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.js new file mode 100644 index 000000000..ac23b9291 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationItem.js @@ -0,0 +1,100 @@ +import PropTypes from 'prop-types'; +import React, { useCallback } from 'react'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import Menu from 'Components/Menu/Menu'; +import MenuContent from 'Components/Menu/MenuContent'; +import { sizes } from 'Helpers/Props'; +import AddSpecificationPresetMenuItem from './AddSpecificationPresetMenuItem'; +import styles from './AddSpecificationItem.css'; + +export default function AddSpecificationItem(props) { + const { + implementation, + implementationName, + infoLink, + presets, + onSpecificationSelect + } = props; + + const onWrappedSpecificationSelect = useCallback(() => { + onSpecificationSelect({ implementation }); + }, [implementation, onSpecificationSelect]); + + const hasPresets = !!presets && !!presets.length; + + return ( +
+ + +
+
+ {implementationName} +
+ +
+ { + hasPresets ? + + + + + + + + { + presets.map((preset, index) => { + return ( + + ); + }) + } + + + : + null + } + + { + infoLink ? + : + null + } +
+
+
+ ); +} + +AddSpecificationItem.propTypes = { + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + infoLink: PropTypes.string, + presets: PropTypes.arrayOf(PropTypes.object), + onSpecificationSelect: PropTypes.func.isRequired +}; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.js new file mode 100644 index 000000000..1a8c115f0 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import AddSpecificationModalContent from './AddSpecificationModalContent'; + +function AddSpecificationModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +AddSpecificationModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default AddSpecificationModal; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css new file mode 100644 index 000000000..d51349ea9 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css @@ -0,0 +1,5 @@ +.specifications { + display: flex; + justify-content: center; + flex-wrap: wrap; +} diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css.d.ts new file mode 100644 index 000000000..83fbf5804 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'specifications': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.js new file mode 100644 index 000000000..c6e55b4d2 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationModalContent.js @@ -0,0 +1,105 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Alert from 'Components/Alert'; +import Button from 'Components/Link/Button'; +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 { kinds } from 'Helpers/Props'; +import { + fetchAutoTaggingSpecificationSchema, + selectAutoTaggingSpecificationSchema +} from 'Store/Actions/settingsActions'; +import AddSpecificationItem from './AddSpecificationItem'; +import styles from './AddSpecificationModalContent.css'; + +export default function AddSpecificationModalContent(props) { + const { + onModalClose + } = props; + + const { + isSchemaFetching, + isSchemaPopulated, + schemaError, + schema + } = useSelector( + (state) => state.settings.autoTaggingSpecifications + ); + + const dispatch = useDispatch(); + + const onSpecificationSelect = useCallback(({ implementation, name }) => { + dispatch(selectAutoTaggingSpecificationSchema({ implementation, presetName: name })); + onModalClose({ specificationSelected: true }); + }, [dispatch, onModalClose]); + + useEffect(() => { + dispatch(fetchAutoTaggingSpecificationSchema()); + }, [dispatch]); + + return ( + + + Add Condition + + + + { + isSchemaFetching ? : null + } + + { + !isSchemaFetching && !!schemaError ? +
+ {'Unable to add a new condition, please try again.'} +
: + null + } + + { + isSchemaPopulated && !schemaError ? +
+ + +
+ {'Radarr supports the follow properties for auto tagging rules'} +
+
+ +
+ { + schema.map((specification) => { + return ( + + ); + }) + } +
+ +
: + null + } +
+ + + + +
+ ); +} + +AddSpecificationModalContent.propTypes = { + onModalClose: PropTypes.func.isRequired +}; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationPresetMenuItem.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationPresetMenuItem.js new file mode 100644 index 000000000..b043ddf06 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/AddSpecificationPresetMenuItem.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React, { useCallback } from 'react'; +import MenuItem from 'Components/Menu/MenuItem'; + +export default function AddSpecificationPresetMenuItem(props) { + const { + name, + implementation, + onPress, + ...otherProps + } = props; + + const onWrappedPress = useCallback(() => { + onPress({ + name, + implementation + }); + }, [name, implementation, onPress]); + + return ( + + {name} + + ); +} + +AddSpecificationPresetMenuItem.propTypes = { + name: PropTypes.string.isRequired, + implementation: PropTypes.string.isRequired, + onPress: PropTypes.func.isRequired +}; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.js new file mode 100644 index 000000000..16ed4daec --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModal.js @@ -0,0 +1,36 @@ +import PropTypes from 'prop-types'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditSpecificationModalContent from './EditSpecificationModalContent'; + +function EditSpecificationModal({ isOpen, onModalClose, ...otherProps }) { + const dispatch = useDispatch(); + + const onWrappedModalClose = useCallback(() => { + dispatch(clearPendingChanges({ section: 'settings.autoTaggingSpecifications' })); + onModalClose(); + }, [onModalClose, dispatch]); + + return ( + + + + ); +} + +EditSpecificationModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditSpecificationModal; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css new file mode 100644 index 000000000..a2b6014df --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css @@ -0,0 +1,5 @@ +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: auto; +} diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css.d.ts new file mode 100644 index 000000000..c5f0ef8a7 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.css.d.ts @@ -0,0 +1,7 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'deleteButton': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js new file mode 100644 index 000000000..ce8851d75 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContent.js @@ -0,0 +1,188 @@ +import PropTypes from 'prop-types'; +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Alert from 'Components/Alert'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import ProviderFieldFormGroup from 'Components/Form/ProviderFieldFormGroup'; +import Button from 'Components/Link/Button'; +import Link from 'Components/Link/Link'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; +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 { inputTypes, kinds } from 'Helpers/Props'; +import { + clearAutoTaggingSpecificationPending, + saveAutoTaggingSpecification, + setAutoTaggingSpecificationFieldValue, + setAutoTaggingSpecificationValue +} from 'Store/Actions/settingsActions'; +import { createProviderSettingsSelectorHook } from 'Store/Selectors/createProviderSettingsSelector'; +import styles from './EditSpecificationModalContent.css'; + +function EditSpecificationModalContent(props) { + const { + id, + onDeleteSpecificationPress, + onModalClose + } = props; + + const advancedSettings = useSelector((state) => state.settings.advancedSettings); + + const { + item, + ...otherFormProps + } = useSelector( + createProviderSettingsSelectorHook('autoTaggingSpecifications', id) + ); + + const dispatch = useDispatch(); + + const onInputChange = useCallback(({ name, value }) => { + dispatch(setAutoTaggingSpecificationValue({ name, value })); + }, [dispatch]); + + const onFieldChange = useCallback(({ name, value }) => { + dispatch(setAutoTaggingSpecificationFieldValue({ name, value })); + }, [dispatch]); + + const onCancelPress = useCallback(({ name, value }) => { + dispatch(clearAutoTaggingSpecificationPending()); + onModalClose(); + }, [dispatch, onModalClose]); + + const onSavePress = useCallback(({ name, value }) => { + dispatch(saveAutoTaggingSpecification({ id })); + onModalClose(); + }, [dispatch, id, onModalClose]); + + const { + implementationName, + name, + negate, + required, + fields + } = item; + + return ( + + + {`${id ? 'Edit' : 'Add'} Condition - ${implementationName}`} + + + +
+ { + fields && fields.some((x) => x.label === 'Regular Expression') && + +
+
\\^$.|?*+()[{ have special meanings and need escaping with a \\' }} /> + {'More details'} {'Here'} +
+
+ {'Regular expressions can be tested '} + Here +
+ + } + + + + Name + + + + + + { + fields && fields.map((field) => { + return ( + + ); + }) + } + + + + Negate + + + + + + + + Required + + + + + + + + { + id ? + : + null + } + + + + + Save + + + + ); +} + +EditSpecificationModalContent.propTypes = { + id: PropTypes.number, + onDeleteSpecificationPress: PropTypes.func, + onModalClose: PropTypes.func.isRequired +}; + +export default EditSpecificationModalContent; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContentConnector.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContentConnector.js new file mode 100644 index 000000000..8f27b74e0 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/EditSpecificationModalContentConnector.js @@ -0,0 +1,78 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { clearAutoTaggingSpecificationPending, saveAutoTaggingSpecification, setAutoTaggingSpecificationFieldValue, setAutoTaggingSpecificationValue } from 'Store/Actions/settingsActions'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import EditSpecificationModalContent from './EditSpecificationModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('autoTaggingSpecifications'), + (advancedSettings, specification) => { + return { + advancedSettings, + ...specification + }; + } + ); +} + +const mapDispatchToProps = { + setAutoTaggingSpecificationValue, + setAutoTaggingSpecificationFieldValue, + saveAutoTaggingSpecification, + clearAutoTaggingSpecificationPending +}; + +class EditSpecificationModalContentConnector extends Component { + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.setAutoTaggingSpecificationValue({ name, value }); + }; + + onFieldChange = ({ name, value }) => { + this.props.setAutoTaggingSpecificationFieldValue({ name, value }); + }; + + onCancelPress = () => { + this.props.clearAutoTaggingSpecificationPending(); + this.props.onModalClose(); + }; + + onSavePress = () => { + this.props.saveAutoTaggingSpecification({ id: this.props.id }); + this.props.onModalClose(); + }; + + // + // Render + + render() { + return ( + + ); + } +} + +EditSpecificationModalContentConnector.propTypes = { + id: PropTypes.number, + item: PropTypes.object.isRequired, + setAutoTaggingSpecificationValue: PropTypes.func.isRequired, + setAutoTaggingSpecificationFieldValue: PropTypes.func.isRequired, + clearAutoTaggingSpecificationPending: PropTypes.func.isRequired, + saveAutoTaggingSpecification: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditSpecificationModalContentConnector); diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css new file mode 100644 index 000000000..e329fc313 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css @@ -0,0 +1,38 @@ +.autoTagging { + composes: card from '~Components/Card.css'; + + width: 300px; +} + +.nameContainer { + display: flex; + justify-content: space-between; +} + +.name { + @add-mixin truncate; + + margin-bottom: 20px; + font-weight: 300; + font-size: 24px; +} + +.cloneButton { + composes: button from '~Components/Link/IconButton.css'; + + height: 36px; +} + +.labels { + display: flex; + flex-wrap: wrap; + margin-top: 5px; + pointer-events: all; +} + +.tooltipLabel { + composes: label from '~Components/Label.css'; + + margin: 0; + border: none; +} diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css.d.ts b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css.d.ts new file mode 100644 index 000000000..b3229d715 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.css.d.ts @@ -0,0 +1,12 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'autoTagging': string; + 'cloneButton': string; + 'labels': string; + 'name': string; + 'nameContainer': string; + 'tooltipLabel': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.js b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.js new file mode 100644 index 000000000..b53bc74b6 --- /dev/null +++ b/frontend/src/Settings/Tags/AutoTagging/Specifications/Specification.js @@ -0,0 +1,121 @@ +import PropTypes from 'prop-types'; +import React, { useCallback, useState } from 'react'; +import Card from 'Components/Card'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import { icons, kinds } from 'Helpers/Props'; +import EditSpecificationModal from './EditSpecificationModal'; +import styles from './Specification.css'; + +export default function Specification(props) { + const { + id, + implementationName, + name, + required, + negate, + onConfirmDeleteSpecification, + onCloneSpecificationPress + } = props; + + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + + 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); + }, [id, onConfirmDeleteSpecification]); + + const onClonePress = useCallback(() => { + onCloneSpecificationPress(id); + }, [id, onCloneSpecificationPress]); + + return ( + +
+
+ {name} +
+ + +
+ +
+ + + { + negate ? + : + null + } + + { + required ? + : + null + } +
+ + + + +
+ ); +} + +Specification.propTypes = { + id: PropTypes.number.isRequired, + implementation: PropTypes.string.isRequired, + implementationName: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + negate: PropTypes.bool.isRequired, + required: PropTypes.bool.isRequired, + fields: PropTypes.arrayOf(PropTypes.object).isRequired, + onConfirmDeleteSpecification: PropTypes.func.isRequired, + onCloneSpecificationPress: PropTypes.func.isRequired +}; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js index e946afdf4..3ede0b016 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContent.js @@ -24,6 +24,7 @@ function TagDetailsModalContent(props) { importLists, indexers, downloadClients, + autoTags, onModalClose, onDeleteTagPress } = props; @@ -198,6 +199,22 @@ function TagDetailsModalContent(props) { : null } + + { + autoTags.length ? +
+ { + autoTags.map((item) => { + return ( +
+ {item.name} +
+ ); + }) + } +
: + null + } @@ -233,6 +250,7 @@ TagDetailsModalContent.propTypes = { importLists: PropTypes.arrayOf(PropTypes.object).isRequired, indexers: PropTypes.arrayOf(PropTypes.object).isRequired, downloadClients: PropTypes.arrayOf(PropTypes.object).isRequired, + autoTags: PropTypes.arrayOf(PropTypes.object).isRequired, onModalClose: PropTypes.func.isRequired, onDeleteTagPress: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js index a8d1e8ffc..156e554a1 100644 --- a/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js +++ b/frontend/src/Settings/Tags/Details/TagDetailsModalContentConnector.js @@ -85,6 +85,14 @@ function createMatchingDownloadClientsSelector() { ); } +function createMatchingAutoTagsSelector() { + return createSelector( + (state, { autoTagIds }) => autoTagIds, + (state) => state.settings.autoTaggings.items, + findMatchingItems + ); +} + function createMapStateToProps() { return createSelector( createMatchingMoviesSelector(), @@ -94,7 +102,8 @@ function createMapStateToProps() { createMatchingImportListsSelector(), createMatchingIndexersSelector(), createMatchingDownloadClientsSelector(), - (movies, delayProfiles, notifications, restrictions, importLists, indexers, downloadClients) => { + createMatchingAutoTagsSelector(), + (movies, delayProfiles, notifications, restrictions, importLists, indexers, downloadClients, autoTags) => { return { movies, delayProfiles, @@ -102,7 +111,8 @@ function createMapStateToProps() { restrictions, importLists, indexers, - downloadClients + downloadClients, + autoTags }; } ); diff --git a/frontend/src/Settings/Tags/Tag.js b/frontend/src/Settings/Tags/Tag.js index 1d4f419a0..661e632b9 100644 --- a/frontend/src/Settings/Tags/Tag.js +++ b/frontend/src/Settings/Tags/Tag.js @@ -5,6 +5,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal'; import { kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import TagDetailsModal from './Details/TagDetailsModal'; +import TagInUse from './TagInUse'; import styles from './Tag.css'; class Tag extends Component { @@ -54,12 +55,13 @@ class Tag extends Component { const { label, delayProfileIds, + importListIds, notificationIds, restrictionIds, - importListIds, - movieIds, indexerIds, - downloadClientIds + downloadClientIds, + autoTagIds, + movieIds } = this.props; const { @@ -69,12 +71,13 @@ class Tag extends Component { const isTagUsed = !!( delayProfileIds.length || + importListIds.length || notificationIds.length || restrictionIds.length || - importListIds.length || - movieIds.length || indexerIds.length || - downloadClientIds.length + downloadClientIds.length || + autoTagIds.length || + movieIds.length ); return ( @@ -88,59 +91,50 @@ class Tag extends Component {
{ - isTagUsed && + isTagUsed ?
- { - !!movieIds.length && -
- {movieIds.length} movies -
- } + - { - !!delayProfileIds.length && -
- {delayProfileIds.length} delay profile{delayProfileIds.length > 1 && 's'} -
- } + - { - !!notificationIds.length && -
- {notificationIds.length} connection{notificationIds.length > 1 && 's'} -
- } + - { - !!restrictionIds.length && -
- {restrictionIds.length} restriction{restrictionIds.length > 1 && 's'} -
- } + - { - !!importListIds.length && -
- {importListIds.length} list{importListIds.length > 1 && 's'} -
- } + - { - indexerIds.length ? -
- {indexerIds.length} indexer{indexerIds.length > 1 && 's'} -
: - null - } + - { - downloadClientIds.length ? -
- {downloadClientIds.length} download client{indexerIds.length > 1 && 's'} -
: - null - } -
+ + + + : + null } { @@ -155,11 +149,12 @@ class Tag extends Component { isTagUsed={isTagUsed} movieIds={movieIds} delayProfileIds={delayProfileIds} + importListIds={importListIds} notificationIds={notificationIds} restrictionIds={restrictionIds} - importListIds={importListIds} indexerIds={indexerIds} downloadClientIds={downloadClientIds} + autoTagIds={autoTagIds} isOpen={isDetailsModalOpen} onModalClose={this.onDetailsModalClose} onDeleteTagPress={this.onDeleteTagPress} @@ -183,23 +178,25 @@ Tag.propTypes = { id: PropTypes.number.isRequired, label: PropTypes.string.isRequired, delayProfileIds: PropTypes.arrayOf(PropTypes.number).isRequired, + importListIds: PropTypes.arrayOf(PropTypes.number).isRequired, notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired, restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired, - importListIds: PropTypes.arrayOf(PropTypes.number).isRequired, - movieIds: PropTypes.arrayOf(PropTypes.number).isRequired, indexerIds: PropTypes.arrayOf(PropTypes.number).isRequired, downloadClientIds: PropTypes.arrayOf(PropTypes.number).isRequired, + autoTagIds: PropTypes.arrayOf(PropTypes.number).isRequired, + movieIds: PropTypes.arrayOf(PropTypes.number).isRequired, onConfirmDeleteTag: PropTypes.func.isRequired }; Tag.defaultProps = { delayProfileIds: [], + importListIds: [], notificationIds: [], restrictionIds: [], - importListIds: [], - movieIds: [], indexerIds: [], - downloadClientIds: [] + downloadClientIds: [], + autoTagIds: [], + movieIds: [] }; export default Tag; diff --git a/frontend/src/Settings/Tags/TagInUse.js b/frontend/src/Settings/Tags/TagInUse.js new file mode 100644 index 000000000..cc0b968b5 --- /dev/null +++ b/frontend/src/Settings/Tags/TagInUse.js @@ -0,0 +1,34 @@ +import PropTypes from 'prop-types'; +import React from 'react'; + +export default function TagInUse(props) { + const { + label, + count, + shouldPluralize = true + } = props; + + if (count === 0) { + return null; + } + + if (count > 1 && shouldPluralize) { + return ( +
+ {count} {label}s +
+ ); + } + + return ( +
+ {count} {label} +
+ ); +} + +TagInUse.propTypes = { + label: PropTypes.string.isRequired, + count: PropTypes.number.isRequired, + shouldPluralize: PropTypes.bool +}; diff --git a/frontend/src/Settings/Tags/TagSettings.js b/frontend/src/Settings/Tags/TagSettings.js index ad2e499eb..ca8672603 100644 --- a/frontend/src/Settings/Tags/TagSettings.js +++ b/frontend/src/Settings/Tags/TagSettings.js @@ -3,6 +3,7 @@ import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import translate from 'Utilities/String/translate'; +import AutoTaggings from './AutoTagging/AutoTaggings'; import TagsConnector from './TagsConnector'; function TagSettings() { @@ -14,6 +15,7 @@ function TagSettings() { + ); diff --git a/frontend/src/Store/Actions/Settings/autoTaggingSpecifications.js b/frontend/src/Store/Actions/Settings/autoTaggingSpecifications.js new file mode 100644 index 000000000..cfc919c7d --- /dev/null +++ b/frontend/src/Store/Actions/Settings/autoTaggingSpecifications.js @@ -0,0 +1,193 @@ +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 { 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: `${item.name} - Copy` + }; + 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 new file mode 100644 index 000000000..35b3d4149 --- /dev/null +++ b/frontend/src/Store/Actions/Settings/autoTaggings.js @@ -0,0 +1,109 @@ +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'; + +// +// 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 = `${pendingChanges.name} - Copy`; + 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 f14548f9a..be1c621a5 100644 --- a/frontend/src/Store/Actions/settingsActions.js +++ b/frontend/src/Store/Actions/settingsActions.js @@ -1,6 +1,8 @@ import { createAction } from 'redux-actions'; 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'; @@ -26,6 +28,8 @@ import remotePathMappings from './Settings/remotePathMappings'; import restrictions from './Settings/restrictions'; import ui from './Settings/ui'; +export * from './Settings/autoTaggingSpecifications'; +export * from './Settings/autoTaggings'; export * from './Settings/customFormatSpecifications.js'; export * from './Settings/customFormats'; export * from './Settings/delayProfiles'; @@ -61,7 +65,8 @@ export const section = 'settings'; export const defaultState = { advancedSettings: false, - + autoTaggingSpecifications: autoTaggingSpecifications.defaultState, + autoTaggings: autoTaggings.defaultState, customFormatSpecifications: customFormatSpecifications.defaultState, customFormats: customFormats.defaultState, delayProfiles: delayProfiles.defaultState, @@ -106,6 +111,8 @@ export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS); // Action Handlers export const actionHandlers = handleThunks({ + ...autoTaggingSpecifications.actionHandlers, + ...autoTaggings.actionHandlers, ...customFormatSpecifications.actionHandlers, ...customFormats.actionHandlers, ...delayProfiles.actionHandlers, @@ -141,6 +148,8 @@ export const reducers = createHandleActions({ return Object.assign({}, state, { advancedSettings: !state.advancedSettings }); }, + ...autoTaggingSpecifications.reducers, + ...autoTaggings.reducers, ...customFormatSpecifications.reducers, ...customFormats.reducers, ...delayProfiles.reducers, diff --git a/frontend/src/Store/Selectors/createProviderSettingsSelector.js b/frontend/src/Store/Selectors/createProviderSettingsSelector.js index 46659609f..f5ac9bad5 100644 --- a/frontend/src/Store/Selectors/createProviderSettingsSelector.js +++ b/frontend/src/Store/Selectors/createProviderSettingsSelector.js @@ -2,62 +2,70 @@ import _ from 'lodash'; import { createSelector } from 'reselect'; import selectSettings from 'Store/Selectors/selectSettings'; -function createProviderSettingsSelector(sectionName) { +function selector(id, section) { + if (!id) { + const item = _.isArray(section.schema) ? section.selectedSchema : section.schema; + const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError); + + const { + isSchemaFetching: isFetching, + isSchemaPopulated: isPopulated, + schemaError: error, + isSaving, + saveError, + isTesting, + pendingChanges + } = section; + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + pendingChanges, + ...settings, + item: settings.settings + }; + } + + const { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + pendingChanges + } = section; + + const settings = selectSettings(_.find(section.items, { id }), pendingChanges, saveError); + + return { + isFetching, + isPopulated, + error, + isSaving, + saveError, + isTesting, + ...settings, + item: settings.settings + }; +} + +export default function createProviderSettingsSelector(sectionName) { return createSelector( (state, { id }) => id, (state) => state.settings[sectionName], - (id, section) => { - if (!id) { - const item = _.isArray(section.schema) ? section.selectedSchema : section.schema; - const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError); - - const { - isSchemaFetching: isFetching, - isSchemaPopulated: isPopulated, - schemaError: error, - isSaving, - saveError, - isTesting, - pendingChanges - } = section; - - return { - isFetching, - isPopulated, - error, - isSaving, - saveError, - isTesting, - pendingChanges, - ...settings, - item: settings.settings - }; - } - - const { - isFetching, - isPopulated, - error, - isSaving, - saveError, - isTesting, - pendingChanges - } = section; - - const settings = selectSettings(_.find(section.items, { id }), pendingChanges, saveError); - - return { - isFetching, - isPopulated, - error, - isSaving, - saveError, - isTesting, - ...settings, - item: settings.settings - }; - } + (id, section) => selector(id, section) + ); +} + +export function createProviderSettingsSelectorHook(sectionName, id) { + return createSelector( + (state) => state.settings[sectionName], + (section) => selector(id, section) ); } -export default createProviderSettingsSelector; diff --git a/src/NzbDrone.Core.Test/AutoTagging/AutoTaggingServiceFixture.cs b/src/NzbDrone.Core.Test/AutoTagging/AutoTaggingServiceFixture.cs new file mode 100644 index 000000000..b93057904 --- /dev/null +++ b/src/NzbDrone.Core.Test/AutoTagging/AutoTaggingServiceFixture.cs @@ -0,0 +1,125 @@ +using System.Collections.Generic; +using System.Linq; +using FizzWare.NBuilder; +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.AutoTagging; +using NzbDrone.Core.AutoTagging.Specifications; +using NzbDrone.Core.Movies; +using NzbDrone.Core.Test.Framework; + +namespace NzbDrone.Core.Test.AutoTagging +{ + [TestFixture] + public class AutoTaggingServiceFixture : CoreTest + { + private Movie _movie; + private AutoTag _tag; + + [SetUp] + public void Setup() + { + _movie = Builder.CreateNew() + .With(m => m.MovieMetadata = new MovieMetadata + { + Genres = new List { "Comedy" } + }) + .Build(); + + _tag = new AutoTag + { + Name = "Test", + Specifications = new List + { + new GenreSpecification + { + Name = "Genre", + Value = new List + { + "Comedy" + } + } + }, + Tags = new HashSet { 1 }, + RemoveTagsAutomatically = false + }; + } + + private void GivenAutoTags(List autoTags) + { + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(autoTags); + } + + [Test] + public void should_not_have_changes_if_there_are_no_auto_tags() + { + GivenAutoTags(new List()); + + var result = Subject.GetTagChanges(_movie); + + result.TagsToAdd.Should().BeEmpty(); + result.TagsToRemove.Should().BeEmpty(); + } + + [Test] + public void should_have_tags_to_add_if_series_does_not_have_match_tag() + { + GivenAutoTags(new List { _tag }); + + var result = Subject.GetTagChanges(_movie); + + result.TagsToAdd.Should().HaveCount(1); + result.TagsToAdd.Should().Contain(1); + result.TagsToRemove.Should().BeEmpty(); + } + + [Test] + public void should_not_have_tags_to_remove_if_series_has_matching_tag_but_remove_is_false() + { + _movie.Tags = new HashSet { 1 }; + _movie.MovieMetadata.Value.Genres = new List { "NotComedy" }; + + GivenAutoTags(new List { _tag }); + + var result = Subject.GetTagChanges(_movie); + + result.TagsToAdd.Should().BeEmpty(); + result.TagsToRemove.Should().BeEmpty(); + } + + [Test] + public void should_have_tags_to_remove_if_series_has_matching_tag_and_remove_is_true() + { + _movie.Tags = new HashSet { 1 }; + _movie.MovieMetadata.Value.Genres = new List { "NotComedy" }; + + _tag.RemoveTagsAutomatically = true; + + GivenAutoTags(new List { _tag }); + + var result = Subject.GetTagChanges(_movie); + + result.TagsToAdd.Should().BeEmpty(); + result.TagsToRemove.Should().HaveCount(1); + result.TagsToRemove.Should().Contain(1); + } + + [Test] + public void should_match_if_specification_is_negated() + { + _movie.MovieMetadata.Value.Genres = new List { "NotComedy" }; + + _tag.Specifications.First().Negate = true; + + GivenAutoTags(new List { _tag }); + + var result = Subject.GetTagChanges(_movie); + + result.TagsToAdd.Should().HaveCount(1); + result.TagsToAdd.Should().Contain(1); + result.TagsToRemove.Should().BeEmpty(); + } + } +} diff --git a/src/NzbDrone.Core.Test/MovieTests/RefreshMovieServiceFixture.cs b/src/NzbDrone.Core.Test/MovieTests/RefreshMovieServiceFixture.cs index b57e565c4..33cb41ac4 100644 --- a/src/NzbDrone.Core.Test/MovieTests/RefreshMovieServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MovieTests/RefreshMovieServiceFixture.cs @@ -4,6 +4,7 @@ using Moq; using NUnit.Framework; using NzbDrone.Common.Extensions; +using NzbDrone.Core.AutoTagging; using NzbDrone.Core.Exceptions; using NzbDrone.Core.MetadataSource; using NzbDrone.Core.Movies; @@ -56,6 +57,10 @@ public void Setup() Mocker.GetMock() .Setup(s => s.GetBestRootFolderPath(It.IsAny(), null)) .Returns(string.Empty); + + Mocker.GetMock() + .Setup(s => s.GetTagChanges(_existingMovie)) + .Returns(new AutoTaggingChanges()); } private void GivenNewMovieInfo(MovieMetadata movie) diff --git a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs index d35409c0b..8682037f5 100644 --- a/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs +++ b/src/NzbDrone.Core/Annotations/FieldDefinitionAttribute.cs @@ -65,7 +65,8 @@ public enum FieldType Captcha, OAuth, Device, - TagSelect + TagSelect, + RootFolder } public enum HiddenType diff --git a/src/NzbDrone.Core/AutoTagging/AutoTag.cs b/src/NzbDrone.Core/AutoTagging/AutoTag.cs new file mode 100644 index 000000000..07e36afe7 --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/AutoTag.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +using NzbDrone.Core.AutoTagging.Specifications; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.AutoTagging +{ + public class AutoTag : ModelBase + { + public AutoTag() + { + Tags = new HashSet(); + } + + public string Name { get; set; } + public List Specifications { get; set; } + public bool RemoveTagsAutomatically { get; set; } + public HashSet Tags { get; set; } + } +} diff --git a/src/NzbDrone.Core/AutoTagging/AutoTaggingChanges.cs b/src/NzbDrone.Core/AutoTagging/AutoTaggingChanges.cs new file mode 100644 index 000000000..14bcb1922 --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/AutoTaggingChanges.cs @@ -0,0 +1,16 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.AutoTagging +{ + public class AutoTaggingChanges + { + public HashSet TagsToAdd { get; set; } + public HashSet TagsToRemove { get; set; } + + public AutoTaggingChanges() + { + TagsToAdd = new HashSet(); + TagsToRemove = new HashSet(); + } + } +} diff --git a/src/NzbDrone.Core/AutoTagging/AutoTaggingRepository.cs b/src/NzbDrone.Core/AutoTagging/AutoTaggingRepository.cs new file mode 100644 index 000000000..4a5b1c9a3 --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/AutoTaggingRepository.cs @@ -0,0 +1,17 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.AutoTagging +{ + public interface IAutoTaggingRepository : IBasicRepository + { + } + + public class AutoTaggingRepository : BasicRepository, IAutoTaggingRepository + { + public AutoTaggingRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/AutoTagging/AutoTaggingService.cs b/src/NzbDrone.Core/AutoTagging/AutoTaggingService.cs new file mode 100644 index 000000000..c65134134 --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/AutoTaggingService.cs @@ -0,0 +1,128 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Movies; +using NzbDrone.Core.RootFolders; + +namespace NzbDrone.Core.AutoTagging +{ + public interface IAutoTaggingService + { + void Update(AutoTag autoTag); + AutoTag Insert(AutoTag autoTag); + List All(); + AutoTag GetById(int id); + void Delete(int id); + List AllForTag(int tagId); + AutoTaggingChanges GetTagChanges(Movie movie); + } + + public class AutoTaggingService : IAutoTaggingService + { + private readonly IAutoTaggingRepository _repository; + private readonly RootFolderService _rootFolderService; + private readonly ICached> _cache; + + public AutoTaggingService(IAutoTaggingRepository repository, + RootFolderService rootFolderService, + ICacheManager cacheManager) + { + _repository = repository; + _rootFolderService = rootFolderService; + _cache = cacheManager.GetCache>(typeof(AutoTag), "autoTags"); + } + + private Dictionary AllDictionary() + { + return _cache.Get("all", () => _repository.All().ToDictionary(m => m.Id)); + } + + public List All() + { + return AllDictionary().Values.ToList(); + } + + public AutoTag GetById(int id) + { + return AllDictionary()[id]; + } + + public void Update(AutoTag autoTag) + { + _repository.Update(autoTag); + _cache.Clear(); + } + + public AutoTag Insert(AutoTag autoTag) + { + var result = _repository.Insert(autoTag); + _cache.Clear(); + + return result; + } + + public void Delete(int id) + { + _repository.Delete(id); + _cache.Clear(); + } + + public List AllForTag(int tagId) + { + return All().Where(p => p.Tags.Contains(tagId)) + .ToList(); + } + + public AutoTaggingChanges GetTagChanges(Movie movie) + { + var autoTags = All(); + var changes = new AutoTaggingChanges(); + + if (autoTags.Empty()) + { + return changes; + } + + // Set the root folder path on the series + movie.RootFolderPath = _rootFolderService.GetBestRootFolderPath(movie.Path); + + foreach (var autoTag in autoTags) + { + var specificationMatches = autoTag.Specifications + .GroupBy(t => t.GetType()) + .Select(g => new SpecificationMatchesGroup + { + Matches = g.ToDictionary(t => t, t => t.IsSatisfiedBy(movie)) + }) + .ToList(); + + var allMatch = specificationMatches.All(x => x.DidMatch); + var tags = autoTag.Tags; + + if (allMatch) + { + foreach (var tag in tags) + { + if (!movie.Tags.Contains(tag)) + { + changes.TagsToAdd.Add(tag); + } + } + + continue; + } + + if (autoTag.RemoveTagsAutomatically) + { + foreach (var tag in tags) + { + changes.TagsToRemove.Add(tag); + } + } + } + + return changes; + } + } +} diff --git a/src/NzbDrone.Core/AutoTagging/SpecificationMatchesGroup.cs b/src/NzbDrone.Core/AutoTagging/SpecificationMatchesGroup.cs new file mode 100644 index 000000000..96cd11881 --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/SpecificationMatchesGroup.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.AutoTagging.Specifications; + +namespace NzbDrone.Core.AutoTagging +{ + public class SpecificationMatchesGroup + { + public Dictionary Matches { get; set; } + + public bool DidMatch => !(Matches.Any(m => m.Key.Required && m.Value == false) || + Matches.All(m => m.Value == false)); + } +} diff --git a/src/NzbDrone.Core/AutoTagging/Specifications/AutoTagSpecificationBase.cs b/src/NzbDrone.Core/AutoTagging/Specifications/AutoTagSpecificationBase.cs new file mode 100644 index 000000000..896665e11 --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/Specifications/AutoTagSpecificationBase.cs @@ -0,0 +1,36 @@ +using NzbDrone.Core.Movies; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.AutoTagging.Specifications +{ + public abstract class AutoTaggingSpecificationBase : IAutoTaggingSpecification + { + public abstract int Order { get; } + public abstract string ImplementationName { get; } + + public string Name { get; set; } + public bool Negate { get; set; } + public bool Required { get; set; } + + public IAutoTaggingSpecification Clone() + { + return (IAutoTaggingSpecification)MemberwiseClone(); + } + + public abstract NzbDroneValidationResult Validate(); + + public bool IsSatisfiedBy(Movie movie) + { + var match = IsSatisfiedByWithoutNegate(movie); + + if (Negate) + { + match = !match; + } + + return match; + } + + protected abstract bool IsSatisfiedByWithoutNegate(Movie movie); + } +} diff --git a/src/NzbDrone.Core/AutoTagging/Specifications/GenreSpecification.cs b/src/NzbDrone.Core/AutoTagging/Specifications/GenreSpecification.cs new file mode 100644 index 000000000..0940fdc14 --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/Specifications/GenreSpecification.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Movies; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.AutoTagging.Specifications +{ + public class GenreSpecificationValidator : AbstractValidator + { + public GenreSpecificationValidator() + { + RuleFor(c => c.Value).NotEmpty(); + } + } + + public class GenreSpecification : AutoTaggingSpecificationBase + { + private static readonly GenreSpecificationValidator Validator = new (); + + public override int Order => 1; + public override string ImplementationName => "Genre"; + + [FieldDefinition(1, Label = "Genre(s)", Type = FieldType.Tag)] + public IEnumerable Value { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(Movie movie) + { + return movie.MovieMetadata.Value.Genres.Any(genre => Value.Contains(genre)); + } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/AutoTagging/Specifications/IAutoTagSpecification.cs b/src/NzbDrone.Core/AutoTagging/Specifications/IAutoTagSpecification.cs new file mode 100644 index 000000000..c86a7a743 --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/Specifications/IAutoTagSpecification.cs @@ -0,0 +1,18 @@ +using NzbDrone.Core.Movies; +using NzbDrone.Core.Validation; + +namespace NzbDrone.Core.AutoTagging.Specifications +{ + public interface IAutoTaggingSpecification + { + int Order { get; } + string ImplementationName { get; } + string Name { get; set; } + bool Negate { get; set; } + bool Required { get; set; } + NzbDroneValidationResult Validate(); + + IAutoTaggingSpecification Clone(); + bool IsSatisfiedBy(Movie movie); + } +} diff --git a/src/NzbDrone.Core/AutoTagging/Specifications/RootFolderSpecification.cs b/src/NzbDrone.Core/AutoTagging/Specifications/RootFolderSpecification.cs new file mode 100644 index 000000000..2ff8bb24d --- /dev/null +++ b/src/NzbDrone.Core/AutoTagging/Specifications/RootFolderSpecification.cs @@ -0,0 +1,38 @@ +using FluentValidation; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Annotations; +using NzbDrone.Core.Movies; +using NzbDrone.Core.Validation; +using NzbDrone.Core.Validation.Paths; + +namespace NzbDrone.Core.AutoTagging.Specifications +{ + public class RootFolderSpecificationValidator : AbstractValidator + { + public RootFolderSpecificationValidator() + { + RuleFor(c => c.Value).IsValidPath(); + } + } + + public class RootFolderSpecification : AutoTaggingSpecificationBase + { + private static readonly RootFolderSpecificationValidator Validator = new (); + + public override int Order => 1; + public override string ImplementationName => "Root Folder"; + + [FieldDefinition(1, Label = "Root Folder", Type = FieldType.RootFolder)] + public string Value { get; set; } + + protected override bool IsSatisfiedByWithoutNegate(Movie movie) + { + return movie.RootFolderPath.PathEquals(Value); + } + + public override NzbDroneValidationResult Validate() + { + return new NzbDroneValidationResult(Validator.Validate(this)); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Converters/AutoTagSpecificationConverter.cs b/src/NzbDrone.Core/Datastore/Converters/AutoTagSpecificationConverter.cs new file mode 100644 index 000000000..5857c73f7 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Converters/AutoTagSpecificationConverter.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using NzbDrone.Core.AutoTagging.Specifications; + +namespace NzbDrone.Core.Datastore.Converters +{ + public class AutoTaggingSpecificationConverter : JsonConverter> + { + public override void Write(Utf8JsonWriter writer, List value, JsonSerializerOptions options) + { + var wrapped = value.Select(x => new SpecificationWrapper + { + Type = x.GetType().Name, + Body = x + }); + + JsonSerializer.Serialize(writer, wrapped, options); + } + + public override List Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + ValidateToken(reader, JsonTokenType.StartArray); + + var results = new List(); + + reader.Read(); // Advance to the first object after the StartArray token. This should be either a StartObject token, or the EndArray token. Anything else is invalid. + + while (reader.TokenType == JsonTokenType.StartObject) + { + reader.Read(); // Move to type property name + ValidateToken(reader, JsonTokenType.PropertyName); + + reader.Read(); // Move to type property value + ValidateToken(reader, JsonTokenType.String); + var typename = reader.GetString(); + + reader.Read(); // Move to body property name + ValidateToken(reader, JsonTokenType.PropertyName); + + reader.Read(); // Move to start of object (stored in this property) + ValidateToken(reader, JsonTokenType.StartObject); // Start of specification + + var type = Type.GetType($"NzbDrone.Core.AutoTagging.Specifications.{typename}, Radarr.Core", true); + var item = (IAutoTaggingSpecification)JsonSerializer.Deserialize(ref reader, type, options); + results.Add(item); + + reader.Read(); // Move past end of body object + reader.Read(); // Move past end of 'wrapper' object + } + + ValidateToken(reader, JsonTokenType.EndArray); + + return results; + } + + // Helper function for validating where you are in the JSON + private void ValidateToken(Utf8JsonReader reader, JsonTokenType tokenType) + { + if (reader.TokenType != tokenType) + { + throw new JsonException($"Invalid token: Was expecting a '{tokenType}' token but received a '{reader.TokenType}' token"); + } + } + + private class SpecificationWrapper + { + public string Type { get; set; } + public object Body { get; set; } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/227_add_auto_tagging.cs b/src/NzbDrone.Core/Datastore/Migration/227_add_auto_tagging.cs new file mode 100644 index 000000000..3703fa449 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/227_add_auto_tagging.cs @@ -0,0 +1,18 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(227)] + public class add_auto_tagging : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("AutoTagging") + .WithColumn("Name").AsString().Unique() + .WithColumn("Specifications").AsString().WithDefaultValue("[]") + .WithColumn("RemoveTagsAutomatically").AsBoolean().WithDefaultValue(false) + .WithColumn("Tags").AsString().WithDefaultValue("[]"); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 4f8254d3f..641c69a0e 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -4,6 +4,7 @@ using Dapper; using NzbDrone.Common.Reflection; using NzbDrone.Core.Authentication; +using NzbDrone.Core.AutoTagging.Specifications; using NzbDrone.Core.Blocklisting; using NzbDrone.Core.Configuration; using NzbDrone.Core.CustomFilters; @@ -174,6 +175,8 @@ public static void Map() .Ignore(s => s.Translations); Mapper.Entity("Collections").RegisterModel(); + + Mapper.Entity("AutoTagging").RegisterModel(); } private static void RegisterMappers() @@ -188,6 +191,7 @@ private static void RegisterMappers() SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>(new QualityIntConverter())); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>(new CustomFormatIntConverter())); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>(new CustomFormatSpecificationListConverter())); + SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>(new AutoTaggingSpecificationConverter())); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter(new QualityIntConverter())); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>()); SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter>()); diff --git a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs index 5b5ea3728..205f9b5a5 100644 --- a/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs +++ b/src/NzbDrone.Core/Housekeeping/Housekeepers/CleanupUnusedTags.cs @@ -19,7 +19,7 @@ public CleanupUnusedTags(IMainDatabase database) public void Clean() { using var mapper = _database.OpenConnection(); - var usedTags = new[] { "Movies", "Notifications", "DelayProfiles", "Restrictions", "ImportLists", "Indexers", "DownloadClients" } + var usedTags = new[] { "Movies", "Notifications", "DelayProfiles", "Restrictions", "ImportLists", "Indexers", "AutoTagging", "DownloadClients" } .SelectMany(v => GetUsedTags(v, mapper)) .Distinct() .ToList(); diff --git a/src/NzbDrone.Core/Movies/RefreshMovieService.cs b/src/NzbDrone.Core/Movies/RefreshMovieService.cs index c4e1fd48f..c09414bdd 100644 --- a/src/NzbDrone.Core/Movies/RefreshMovieService.cs +++ b/src/NzbDrone.Core/Movies/RefreshMovieService.cs @@ -3,6 +3,7 @@ using System.Linq; using NLog; using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.AutoTagging; using NzbDrone.Core.Configuration; using NzbDrone.Core.Exceptions; using NzbDrone.Core.MediaFiles; @@ -33,7 +34,7 @@ public class RefreshMovieService : IExecute private readonly IDiskScanService _diskScanService; private readonly ICheckIfMovieShouldBeRefreshed _checkIfMovieShouldBeRefreshed; private readonly IConfigService _configService; - + private readonly IAutoTaggingService _autoTaggingService; private readonly Logger _logger; public RefreshMovieService(IProvideMovieInfo movieInfo, @@ -48,6 +49,7 @@ public RefreshMovieService(IProvideMovieInfo movieInfo, IDiskScanService diskScanService, ICheckIfMovieShouldBeRefreshed checkIfMovieShouldBeRefreshed, IConfigService configService, + IAutoTaggingService autoTaggingService, Logger logger) { _movieInfo = movieInfo; @@ -62,6 +64,7 @@ public RefreshMovieService(IProvideMovieInfo movieInfo, _diskScanService = diskScanService; _checkIfMovieShouldBeRefreshed = checkIfMovieShouldBeRefreshed; _configService = configService; + _autoTaggingService = autoTaggingService; _logger = logger; } @@ -202,6 +205,39 @@ private void RescanMovie(Movie movie, bool isNew, CommandTrigger trigger) } } + private void UpdateTags(Movie movie) + { + _logger.Trace("Updating tags for {0}", movie); + + var tagsAdded = new HashSet(); + var tagsRemoved = new HashSet(); + var changes = _autoTaggingService.GetTagChanges(movie); + + foreach (var tag in changes.TagsToRemove) + { + if (movie.Tags.Contains(tag)) + { + movie.Tags.Remove(tag); + tagsRemoved.Add(tag); + } + } + + foreach (var tag in changes.TagsToAdd) + { + if (!movie.Tags.Contains(tag)) + { + movie.Tags.Add(tag); + tagsAdded.Add(tag); + } + } + + if (tagsAdded.Any() || tagsRemoved.Any()) + { + _movieService.UpdateMovie(movie); + _logger.Debug("Updated tags for '{0}'. Added: {1}, Removed: {2}", movie.Title, tagsAdded.Count, tagsRemoved.Count); + } + } + public void Execute(RefreshMovieCommand message) { var trigger = message.Trigger; @@ -217,6 +253,7 @@ public void Execute(RefreshMovieCommand message) try { movie = RefreshMovieInfo(movieId); + UpdateTags(movie); RescanMovie(movie, isNew, trigger); } catch (MovieNotFoundException) @@ -226,6 +263,7 @@ public void Execute(RefreshMovieCommand message) catch (Exception e) { _logger.Error(e, "Couldn't refresh info for {0}", movie); + UpdateTags(movie); RescanMovie(movie, isNew, trigger); throw; } @@ -262,11 +300,13 @@ public void Execute(RefreshMovieCommand message) _logger.Error(e, "Couldn't refresh info for {0}", movieLocal); } + UpdateTags(movie); RescanMovie(movieLocal, false, trigger); } else { _logger.Debug("Skipping refresh of movie: {0}", movieLocal.Title); + UpdateTags(movie); RescanMovie(movieLocal, false, trigger); } } diff --git a/src/NzbDrone.Core/Tags/TagDetails.cs b/src/NzbDrone.Core/Tags/TagDetails.cs index 23fc59e03..d26acc97d 100644 --- a/src/NzbDrone.Core/Tags/TagDetails.cs +++ b/src/NzbDrone.Core/Tags/TagDetails.cs @@ -10,9 +10,10 @@ public class TagDetails : ModelBase public List MovieIds { get; set; } public List NotificationIds { get; set; } public List RestrictionIds { get; set; } - public List ImportListIds { get; set; } public List DelayProfileIds { get; set; } + public List ImportListIds { get; set; } public List IndexerIds { get; set; } + public List AutoTagIds { get; set; } public List DownloadClientIds { get; set; } public bool InUse => MovieIds.Any() || @@ -21,6 +22,7 @@ public class TagDetails : ModelBase DelayProfileIds.Any() || ImportListIds.Any() || IndexerIds.Any() || + AutoTagIds.Any() || DownloadClientIds.Any(); } } diff --git a/src/NzbDrone.Core/Tags/TagService.cs b/src/NzbDrone.Core/Tags/TagService.cs index 85f4d4ebf..0eebd3331 100644 --- a/src/NzbDrone.Core/Tags/TagService.cs +++ b/src/NzbDrone.Core/Tags/TagService.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using NzbDrone.Core.AutoTagging; using NzbDrone.Core.Datastore; using NzbDrone.Core.Download; using NzbDrone.Core.ImportLists; @@ -35,6 +36,7 @@ public class TagService : ITagService private readonly IRestrictionService _restrictionService; private readonly IMovieService _movieService; private readonly IIndexerFactory _indexerService; + private readonly IAutoTaggingService _autoTaggingService; private readonly IDownloadClientFactory _downloadClientFactory; public TagService(ITagRepository repo, @@ -45,6 +47,7 @@ public TagService(ITagRepository repo, IRestrictionService restrictionService, IMovieService movieService, IIndexerFactory indexerService, + IAutoTaggingService autoTaggingService, IDownloadClientFactory downloadClientFactory) { _repo = repo; @@ -55,6 +58,7 @@ public TagService(ITagRepository repo, _restrictionService = restrictionService; _movieService = movieService; _indexerService = indexerService; + _autoTaggingService = autoTaggingService; _downloadClientFactory = downloadClientFactory; } @@ -89,6 +93,7 @@ public TagDetails Details(int tagId) var restrictions = _restrictionService.AllForTag(tagId); var movies = _movieService.AllMovieTags().Where(x => x.Value.Contains(tagId)).Select(x => x.Key).ToList(); var indexers = _indexerService.AllForTag(tagId); + var autoTags = _autoTaggingService.AllForTag(tagId); var downloadClients = _downloadClientFactory.AllForTag(tagId); return new TagDetails @@ -101,6 +106,7 @@ public TagDetails Details(int tagId) RestrictionIds = restrictions.Select(c => c.Id).ToList(), MovieIds = movies, IndexerIds = indexers.Select(c => c.Id).ToList(), + AutoTagIds = autoTags.Select(c => c.Id).ToList(), DownloadClientIds = downloadClients.Select(c => c.Id).ToList() }; } @@ -114,6 +120,7 @@ public List Details() var restrictions = _restrictionService.All(); var movies = _movieService.AllMovieTags(); var indexers = _indexerService.All(); + var autotags = _autoTaggingService.All(); var downloadClients = _downloadClientFactory.All(); var details = new List(); @@ -130,6 +137,7 @@ public List Details() RestrictionIds = restrictions.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), MovieIds = movies.Where(c => c.Value.Contains(tag.Id)).Select(c => c.Key).ToList(), IndexerIds = indexers.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), + AutoTagIds = autotags.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), DownloadClientIds = downloadClients.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(), }); } diff --git a/src/Radarr.Api.V3/AutoTagging/AutoTaggingController.cs b/src/Radarr.Api.V3/AutoTagging/AutoTaggingController.cs new file mode 100644 index 000000000..6b08692c2 --- /dev/null +++ b/src/Radarr.Api.V3/AutoTagging/AutoTaggingController.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Linq; +using FluentValidation; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.AutoTagging; +using NzbDrone.Core.AutoTagging.Specifications; +using Radarr.Http; +using Radarr.Http.REST; +using Radarr.Http.REST.Attributes; + +namespace Radarr.Api.V3.AutoTagging +{ + [V3ApiController] + 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.Specifications).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(); + } + + [RestPostById] + [Consumes("application/json")] + public ActionResult Create(AutoTaggingResource autoTagResource) + { + var model = autoTagResource.ToModel(_specifications); + return Created(_autoTaggingService.Insert(model).Id); + } + + [RestPutById] + [Consumes("application/json")] + public ActionResult Update(AutoTaggingResource resource) + { + var model = resource.ToModel(_specifications); + _autoTaggingService.Update(model); + + return Accepted(model.Id); + } + + [HttpGet] + [Produces("application/json")] + public List GetAll() + { + return _autoTaggingService.All().ToResource(); + } + + [RestDeleteById] + public void DeleteFormat(int id) + { + _autoTaggingService.Delete(id); + } + + [HttpGet("schema")] + public object GetTemplates() + { + var schema = _specifications.OrderBy(x => x.Order).Select(x => x.ToSchema()).ToList(); + + return schema; + } + } +} diff --git a/src/Radarr.Api.V3/AutoTagging/AutoTaggingResource.cs b/src/Radarr.Api.V3/AutoTagging/AutoTaggingResource.cs new file mode 100644 index 000000000..46f344624 --- /dev/null +++ b/src/Radarr.Api.V3/AutoTagging/AutoTaggingResource.cs @@ -0,0 +1,76 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json.Serialization; +using NzbDrone.Core.AutoTagging; +using NzbDrone.Core.AutoTagging.Specifications; +using Radarr.Http.ClientSchema; +using Radarr.Http.REST; + +namespace Radarr.Api.V3.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. + // TODO: Check ReadFromSchema for third argument + var spec = (IAutoTaggingSpecification)SchemaBuilder.ReadFromSchema(resource.Fields, type); + spec.Name = resource.Name; + spec.Negate = resource.Negate; + return spec; + } + } +} diff --git a/src/Radarr.Api.V3/AutoTagging/AutoTaggingSpecificationSchema.cs b/src/Radarr.Api.V3/AutoTagging/AutoTaggingSpecificationSchema.cs new file mode 100644 index 000000000..9b7e458e4 --- /dev/null +++ b/src/Radarr.Api.V3/AutoTagging/AutoTaggingSpecificationSchema.cs @@ -0,0 +1,33 @@ +using System.Collections.Generic; +using NzbDrone.Core.AutoTagging.Specifications; +using Radarr.Http.ClientSchema; +using Radarr.Http.REST; + +namespace Radarr.Api.V3.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) + }; + } + } +} diff --git a/src/Radarr.Api.V3/Tags/TagDetailsResource.cs b/src/Radarr.Api.V3/Tags/TagDetailsResource.cs index 2bcf7929e..7b57975e2 100644 --- a/src/Radarr.Api.V3/Tags/TagDetailsResource.cs +++ b/src/Radarr.Api.V3/Tags/TagDetailsResource.cs @@ -9,12 +9,13 @@ public class TagDetailsResource : RestResource { public string Label { get; set; } public List DelayProfileIds { get; set; } + public List ImportListIds { get; set; } public List NotificationIds { get; set; } public List RestrictionIds { get; set; } - public List ImportListIds { get; set; } - public List MovieIds { get; set; } public List IndexerIds { get; set; } public List DownloadClientIds { get; set; } + public List AutoTagIds { get; set; } + public List MovieIds { get; set; } } public static class TagDetailsResourceMapper @@ -31,12 +32,13 @@ public static TagDetailsResource ToResource(this TagDetails model) Id = model.Id, Label = model.Label, DelayProfileIds = model.DelayProfileIds, + ImportListIds = model.ImportListIds, NotificationIds = model.NotificationIds, RestrictionIds = model.RestrictionIds, - ImportListIds = model.ImportListIds, - MovieIds = model.MovieIds, IndexerIds = model.IndexerIds, DownloadClientIds = model.DownloadClientIds, + AutoTagIds = model.AutoTagIds, + MovieIds = model.MovieIds, }; }