parent
8eb941d590
commit
335fc05dd1
|
@ -35,6 +35,8 @@ function getType({ type, selectOptionsProviderAction }) {
|
||||||
return inputTypes.TEXT;
|
return inputTypes.TEXT;
|
||||||
case 'oAuth':
|
case 'oAuth':
|
||||||
return inputTypes.OAUTH;
|
return inputTypes.OAUTH;
|
||||||
|
case 'rootFolder':
|
||||||
|
return inputTypes.ROOT_FOLDER_SELECT;
|
||||||
default:
|
default:
|
||||||
return inputTypes.TEXT;
|
return inputTypes.TEXT;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<Card
|
||||||
|
className={styles.autoTagging}
|
||||||
|
overlayContent={true}
|
||||||
|
onPress={onEditPress}
|
||||||
|
>
|
||||||
|
<div className={styles.nameContainer}>
|
||||||
|
<div className={styles.name}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<IconButton
|
||||||
|
className={styles.cloneButton}
|
||||||
|
title="Clone Auto Tag"
|
||||||
|
name={icons.CLONE}
|
||||||
|
onPress={onClonePress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TagList
|
||||||
|
tags={tags}
|
||||||
|
tagList={tagList}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
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 (
|
||||||
|
<Label
|
||||||
|
key={index}
|
||||||
|
kind={kind}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Label>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditAutoTaggingModal
|
||||||
|
id={id}
|
||||||
|
isOpen={isEditModalOpen}
|
||||||
|
onModalClose={onEditModalClose}
|
||||||
|
onDeleteAutoTaggingPress={onDeletePress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={isDeleteModalOpen}
|
||||||
|
kind={kinds.DANGER}
|
||||||
|
title="Delete Auto Tag"
|
||||||
|
message={`Are you sure you want to delete the auto tag '${name}'?`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
isSpinning={isDeleting}
|
||||||
|
onConfirm={onConfirmDelete}
|
||||||
|
onCancel={onDeleteModalClose}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
|
@ -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);
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<FieldSet legend="Auto Tagging">
|
||||||
|
<PageSectionContent
|
||||||
|
errorMessage="Unable to load auto taggimg"
|
||||||
|
error={error}
|
||||||
|
isFetching={isFetching}
|
||||||
|
isPopulated={isPopulated}
|
||||||
|
>
|
||||||
|
<div className={styles.autoTaggings}>
|
||||||
|
{
|
||||||
|
items.map((item) => {
|
||||||
|
return (
|
||||||
|
<AutoTagging
|
||||||
|
key={item.id}
|
||||||
|
{...item}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
tagList={tagList}
|
||||||
|
onConfirmDeleteAutoTagging={onConfirmDelete}
|
||||||
|
onCloneAutoTaggingPress={onClonePress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={styles.addAutoTagging}
|
||||||
|
onPress={onEditPress}
|
||||||
|
>
|
||||||
|
<div className={styles.center}>
|
||||||
|
<Icon
|
||||||
|
name={icons.ADD}
|
||||||
|
size={45}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditAutoTaggingModal
|
||||||
|
isOpen={isEditModalOpen}
|
||||||
|
tagsFromId={tagsFromId}
|
||||||
|
onModalClose={onEditModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</PageSectionContent>
|
||||||
|
</FieldSet>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<Modal
|
||||||
|
style={{ height: height === 'auto' ? 'auto': `${height}px` }}
|
||||||
|
isOpen={isOpen}
|
||||||
|
size={sizes.LARGE}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
>
|
||||||
|
<EditAutoTaggingModalContent
|
||||||
|
{...otherProps}
|
||||||
|
onContentHeightChange={onContentHeightChange}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditAutoTaggingModal.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<ModalContent onModalClose={onModalClose}>
|
||||||
|
|
||||||
|
<ModalHeader>
|
||||||
|
{id ? 'Edit Auto Tag' : 'Add Auto Tag'}
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
<div>
|
||||||
|
{
|
||||||
|
isFetching ? <LoadingIndicator />: null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isFetching && !!error ?
|
||||||
|
<div>
|
||||||
|
{'Unable to add a new auto tag, please try again.'}
|
||||||
|
</div> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isFetching && !error && specificationsPopulated ?
|
||||||
|
<div>
|
||||||
|
<Form
|
||||||
|
validationErrors={validationErrors}
|
||||||
|
validationWarnings={validationWarnings}
|
||||||
|
>
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>
|
||||||
|
Name
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.TEXT}
|
||||||
|
name="name"
|
||||||
|
{...name}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{'Remove Tags Automatically'}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="removeTagsAutomatically"
|
||||||
|
helpText={'Remove tags automatically if conditions are not met'}
|
||||||
|
{...removeTagsAutomatically}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>Tags</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.TAG}
|
||||||
|
name="tags"
|
||||||
|
onChange={onInputChange}
|
||||||
|
{...tags}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<FieldSet legend={'Conditions'}>
|
||||||
|
<div className={styles.autoTaggings}>
|
||||||
|
{
|
||||||
|
specifications.map((tag) => {
|
||||||
|
return (
|
||||||
|
<Specification
|
||||||
|
key={tag.id}
|
||||||
|
{...tag}
|
||||||
|
onCloneSpecificationPress={onCloneSpecificationPress}
|
||||||
|
onConfirmDeleteSpecification={onConfirmDeleteSpecification}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={styles.addSpecification}
|
||||||
|
onPress={onAddSpecificationPress}
|
||||||
|
>
|
||||||
|
<div className={styles.center}>
|
||||||
|
<Icon
|
||||||
|
name={icons.ADD}
|
||||||
|
size={45}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</FieldSet>
|
||||||
|
|
||||||
|
<AddSpecificationModal
|
||||||
|
isOpen={isAddSpecificationModalOpen}
|
||||||
|
onModalClose={onAddSpecificationModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditSpecificationModal
|
||||||
|
isOpen={isEditSpecificationModalOpen}
|
||||||
|
onModalClose={onEditSpecificationModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* <ImportAutoTaggingModal
|
||||||
|
isOpen={isImportAutoTaggingModalOpen}
|
||||||
|
onModalClose={onImportAutoTaggingModalClose}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
</div> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<div className={styles.rightButtons}>
|
||||||
|
{
|
||||||
|
id ?
|
||||||
|
<Button
|
||||||
|
className={styles.deleteButton}
|
||||||
|
kind={kinds.DANGER}
|
||||||
|
onPress={onDeleteAutoTaggingPress}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{/* <Button
|
||||||
|
className={styles.deleteButton}
|
||||||
|
onPress={onImportPress}
|
||||||
|
>
|
||||||
|
Import
|
||||||
|
</Button> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onPress={onModalClose}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<SpinnerErrorButton
|
||||||
|
isSpinning={isSaving}
|
||||||
|
error={saveError}
|
||||||
|
onPress={onSavePress}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</SpinnerErrorButton>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditAutoTaggingModalContent.propTypes = {
|
||||||
|
id: PropTypes.number,
|
||||||
|
tagsFromId: PropTypes.number,
|
||||||
|
onModalClose: PropTypes.func.isRequired,
|
||||||
|
onDeleteAutoTaggingPress: PropTypes.func
|
||||||
|
};
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<div
|
||||||
|
className={styles.specification}
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
className={styles.underlay}
|
||||||
|
onPress={onWrappedSpecificationSelect}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.overlay}>
|
||||||
|
<div className={styles.name}>
|
||||||
|
{implementationName}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
{
|
||||||
|
hasPresets ?
|
||||||
|
<span>
|
||||||
|
<Button
|
||||||
|
size={sizes.SMALL}
|
||||||
|
onPress={onWrappedSpecificationSelect}
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Menu className={styles.presetsMenu}>
|
||||||
|
<Button
|
||||||
|
className={styles.presetsMenuButton}
|
||||||
|
size={sizes.SMALL}
|
||||||
|
>
|
||||||
|
Presets
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<MenuContent>
|
||||||
|
{
|
||||||
|
presets.map((preset, index) => {
|
||||||
|
return (
|
||||||
|
<AddSpecificationPresetMenuItem
|
||||||
|
key={index}
|
||||||
|
name={preset.name}
|
||||||
|
implementation={implementation}
|
||||||
|
onPress={onWrappedSpecificationSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</MenuContent>
|
||||||
|
</Menu>
|
||||||
|
</span> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
infoLink ?
|
||||||
|
<Button
|
||||||
|
to={infoLink}
|
||||||
|
size={sizes.SMALL}
|
||||||
|
>
|
||||||
|
More Info
|
||||||
|
</Button> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddSpecificationItem.propTypes = {
|
||||||
|
implementation: PropTypes.string.isRequired,
|
||||||
|
implementationName: PropTypes.string.isRequired,
|
||||||
|
infoLink: PropTypes.string,
|
||||||
|
presets: PropTypes.arrayOf(PropTypes.object),
|
||||||
|
onSpecificationSelect: PropTypes.func.isRequired
|
||||||
|
};
|
|
@ -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 (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
>
|
||||||
|
<AddSpecificationModalContent
|
||||||
|
{...otherProps}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddSpecificationModal.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddSpecificationModal;
|
|
@ -0,0 +1,5 @@
|
||||||
|
.specifications {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<ModalContent onModalClose={onModalClose}>
|
||||||
|
<ModalHeader>
|
||||||
|
Add Condition
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
{
|
||||||
|
isSchemaFetching ? <LoadingIndicator /> : null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!isSchemaFetching && !!schemaError ?
|
||||||
|
<div>
|
||||||
|
{'Unable to add a new condition, please try again.'}
|
||||||
|
</div> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
isSchemaPopulated && !schemaError ?
|
||||||
|
<div>
|
||||||
|
|
||||||
|
<Alert kind={kinds.INFO}>
|
||||||
|
<div>
|
||||||
|
{'Sonarr supports the follow properties for auto tagging rules'}
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div className={styles.specifications}>
|
||||||
|
{
|
||||||
|
schema.map((specification) => {
|
||||||
|
return (
|
||||||
|
<AddSpecificationItem
|
||||||
|
key={specification.implementation}
|
||||||
|
{...specification}
|
||||||
|
onSpecificationSelect={onSpecificationSelect}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button
|
||||||
|
onPress={onModalClose}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddSpecificationModalContent.propTypes = {
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
|
@ -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 (
|
||||||
|
<MenuItem
|
||||||
|
{...otherProps}
|
||||||
|
onPress={onWrappedPress}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddSpecificationPresetMenuItem.propTypes = {
|
||||||
|
name: PropTypes.string.isRequired,
|
||||||
|
implementation: PropTypes.string.isRequired,
|
||||||
|
onPress: PropTypes.func.isRequired
|
||||||
|
};
|
|
@ -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 (
|
||||||
|
<Modal
|
||||||
|
size={sizes.MEDIUM}
|
||||||
|
isOpen={isOpen}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
>
|
||||||
|
<EditSpecificationModalContent
|
||||||
|
{...otherProps}
|
||||||
|
onModalClose={onWrappedModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditSpecificationModal.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditSpecificationModal;
|
|
@ -0,0 +1,5 @@
|
||||||
|
.deleteButton {
|
||||||
|
composes: button from '~Components/Link/Button.css';
|
||||||
|
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<ModalContent onModalClose={onCancelPress}>
|
||||||
|
<ModalHeader>
|
||||||
|
{`${id ? 'Edit' : 'Add'} Condition - ${implementationName}`}
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
<Form
|
||||||
|
{...otherFormProps}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
fields && fields.some((x) => x.label === 'Regular Expression') &&
|
||||||
|
<Alert kind={kinds.INFO}>
|
||||||
|
<div>
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: 'This condition matches using Regular Expressions. Note that the characters <code>\\^$.|?*+()[{</code> have special meanings and need escaping with a <code>\\</code>' }} />
|
||||||
|
{'More details'} <Link to="https://www.regular-expressions.info/tutorial.html">{'Here'}</Link>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{'Regular expressions can be tested '}
|
||||||
|
<Link to="http://regexstorm.net/tester">Here</Link>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
}
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>
|
||||||
|
Name
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.TEXT}
|
||||||
|
name="name"
|
||||||
|
{...name}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
{
|
||||||
|
fields && fields.map((field) => {
|
||||||
|
return (
|
||||||
|
<ProviderFieldFormGroup
|
||||||
|
key={field.name}
|
||||||
|
advancedSettings={advancedSettings}
|
||||||
|
provider="specifications"
|
||||||
|
providerData={item}
|
||||||
|
{...field}
|
||||||
|
onChange={onFieldChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>
|
||||||
|
Negate
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="negate"
|
||||||
|
{...negate}
|
||||||
|
helpText={`If checked, the auto tagging rule will not apply if this ${implementationName} condition matches.`}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>
|
||||||
|
Required
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="required"
|
||||||
|
{...required}
|
||||||
|
helpText={`This ${implementationName} condition must match for the auto tagging rule to apply. Otherwise a single ${implementationName} match is sufficient.`}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
{
|
||||||
|
id ?
|
||||||
|
<Button
|
||||||
|
className={styles.deleteButton}
|
||||||
|
kind={kinds.DANGER}
|
||||||
|
onPress={onDeleteSpecificationPress}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onPress={onCancelPress}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<SpinnerErrorButton
|
||||||
|
isSpinning={false}
|
||||||
|
onPress={onSavePress}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</SpinnerErrorButton>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
EditSpecificationModalContent.propTypes = {
|
||||||
|
id: PropTypes.number,
|
||||||
|
onDeleteSpecificationPress: PropTypes.func,
|
||||||
|
onModalClose: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditSpecificationModalContent;
|
|
@ -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 (
|
||||||
|
<EditSpecificationModalContent
|
||||||
|
{...this.props}
|
||||||
|
onCancelPress={this.onCancelPress}
|
||||||
|
onSavePress={this.onSavePress}
|
||||||
|
onInputChange={this.onInputChange}
|
||||||
|
onFieldChange={this.onFieldChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
|
@ -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;
|
||||||
|
}
|
|
@ -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 (
|
||||||
|
<Card
|
||||||
|
className={styles.autoTagging}
|
||||||
|
overlayContent={true}
|
||||||
|
onPress={onEditPress}
|
||||||
|
>
|
||||||
|
<div className={styles.nameContainer}>
|
||||||
|
<div className={styles.name}>
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
className={styles.cloneButton}
|
||||||
|
title="Clone"
|
||||||
|
name={icons.CLONE}
|
||||||
|
onPress={onClonePress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.labels}>
|
||||||
|
<Label kind={kinds.DEFAULT}>
|
||||||
|
{implementationName}
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{
|
||||||
|
negate ?
|
||||||
|
<Label kind={kinds.DANGER}>
|
||||||
|
Negated
|
||||||
|
</Label> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
required ?
|
||||||
|
<Label kind={kinds.SUCCESS}>
|
||||||
|
Required
|
||||||
|
</Label> :
|
||||||
|
null
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EditSpecificationModal
|
||||||
|
id={id}
|
||||||
|
isOpen={isEditModalOpen}
|
||||||
|
onModalClose={onEditModalClose}
|
||||||
|
onDeleteSpecificationPress={onDeletePress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={isDeleteModalOpen}
|
||||||
|
kind={kinds.DANGER}
|
||||||
|
title="Delete Specification"
|
||||||
|
message={`Are you sure you want to delete specification ${name} ?`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={onConfirmDelete}
|
||||||
|
onCancel={onDeleteModalClose}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
};
|
|
@ -21,6 +21,7 @@ function TagDetailsModalContent(props) {
|
||||||
notifications,
|
notifications,
|
||||||
releaseProfiles,
|
releaseProfiles,
|
||||||
indexers,
|
indexers,
|
||||||
|
autoTags,
|
||||||
onModalClose,
|
onModalClose,
|
||||||
onDeleteTagPress
|
onDeleteTagPress
|
||||||
} = props;
|
} = props;
|
||||||
|
@ -177,6 +178,22 @@ function TagDetailsModalContent(props) {
|
||||||
</FieldSet> :
|
</FieldSet> :
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
autoTags.length ?
|
||||||
|
<FieldSet legend="Auto Tagging">
|
||||||
|
{
|
||||||
|
autoTags.map((item) => {
|
||||||
|
return (
|
||||||
|
<div key={item.id}>
|
||||||
|
{item.name}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</FieldSet> :
|
||||||
|
null
|
||||||
|
}
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
|
@ -211,6 +228,7 @@ TagDetailsModalContent.propTypes = {
|
||||||
notifications: PropTypes.arrayOf(PropTypes.object).isRequired,
|
notifications: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
releaseProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
releaseProfiles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
indexers: PropTypes.arrayOf(PropTypes.object).isRequired,
|
indexers: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
autoTags: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
onModalClose: PropTypes.func.isRequired,
|
onModalClose: PropTypes.func.isRequired,
|
||||||
onDeleteTagPress: PropTypes.func.isRequired
|
onDeleteTagPress: PropTypes.func.isRequired
|
||||||
};
|
};
|
||||||
|
|
|
@ -77,6 +77,14 @@ function createMatchingIndexersSelector() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createMatchingAutoTagsSelector() {
|
||||||
|
return createSelector(
|
||||||
|
(state, { autoTagIds }) => autoTagIds,
|
||||||
|
(state) => state.settings.autoTaggings.items,
|
||||||
|
findMatchingItems
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function createMapStateToProps() {
|
function createMapStateToProps() {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
createMatchingSeriesSelector(),
|
createMatchingSeriesSelector(),
|
||||||
|
@ -85,14 +93,16 @@ function createMapStateToProps() {
|
||||||
createMatchingNotificationsSelector(),
|
createMatchingNotificationsSelector(),
|
||||||
createMatchingReleaseProfilesSelector(),
|
createMatchingReleaseProfilesSelector(),
|
||||||
createMatchingIndexersSelector(),
|
createMatchingIndexersSelector(),
|
||||||
(series, delayProfiles, importLists, notifications, releaseProfiles, indexers) => {
|
createMatchingAutoTagsSelector(),
|
||||||
|
(series, delayProfiles, importLists, notifications, releaseProfiles, indexers, autoTags) => {
|
||||||
return {
|
return {
|
||||||
series,
|
series,
|
||||||
delayProfiles,
|
delayProfiles,
|
||||||
importLists,
|
importLists,
|
||||||
notifications,
|
notifications,
|
||||||
releaseProfiles,
|
releaseProfiles,
|
||||||
indexers
|
indexers,
|
||||||
|
autoTags
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,6 +4,7 @@ import Card from 'Components/Card';
|
||||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||||
import { kinds } from 'Helpers/Props';
|
import { kinds } from 'Helpers/Props';
|
||||||
import TagDetailsModal from './Details/TagDetailsModal';
|
import TagDetailsModal from './Details/TagDetailsModal';
|
||||||
|
import TagInUse from './TagInUse';
|
||||||
import styles from './Tag.css';
|
import styles from './Tag.css';
|
||||||
|
|
||||||
class Tag extends Component {
|
class Tag extends Component {
|
||||||
|
@ -57,6 +58,7 @@ class Tag extends Component {
|
||||||
notificationIds,
|
notificationIds,
|
||||||
restrictionIds,
|
restrictionIds,
|
||||||
indexerIds,
|
indexerIds,
|
||||||
|
autoTagIds,
|
||||||
seriesIds
|
seriesIds
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
|
@ -71,6 +73,7 @@ class Tag extends Component {
|
||||||
notificationIds.length ||
|
notificationIds.length ||
|
||||||
restrictionIds.length ||
|
restrictionIds.length ||
|
||||||
indexerIds.length ||
|
indexerIds.length ||
|
||||||
|
autoTagIds.length ||
|
||||||
seriesIds.length
|
seriesIds.length
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -85,57 +88,47 @@ class Tag extends Component {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{
|
||||||
isTagUsed &&
|
isTagUsed ?
|
||||||
<div>
|
<div>
|
||||||
{
|
<TagInUse
|
||||||
seriesIds.length ?
|
label="series"
|
||||||
<div>
|
count={seriesIds.length}
|
||||||
{seriesIds.length} series
|
shouldPluralize={false}
|
||||||
</div> :
|
/>
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
<TagInUse
|
||||||
delayProfileIds.length ?
|
label="delay profile"
|
||||||
<div>
|
count={delayProfileIds.length}
|
||||||
{delayProfileIds.length} delay profile{delayProfileIds.length > 1 && 's'}
|
/>
|
||||||
</div> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
<TagInUse
|
||||||
importListIds.length ?
|
label="import list"
|
||||||
<div>
|
count={importListIds.length}
|
||||||
{importListIds.length} import list{importListIds.length > 1 && 's'}
|
/>
|
||||||
</div> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
<TagInUse
|
||||||
notificationIds.length ?
|
label="connection"
|
||||||
<div>
|
count={notificationIds.length}
|
||||||
{notificationIds.length} connection{notificationIds.length > 1 && 's'}
|
/>
|
||||||
</div> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
<TagInUse
|
||||||
restrictionIds.length ?
|
label="release profile"
|
||||||
<div>
|
count={restrictionIds.length}
|
||||||
{restrictionIds.length} release profile{restrictionIds.length > 1 && 's'}
|
/>
|
||||||
</div> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
<TagInUse
|
||||||
indexerIds.length ?
|
label="indexer"
|
||||||
<div>
|
count={indexerIds.length}
|
||||||
{indexerIds.length} indexer{indexerIds.length > 1 && 's'}
|
/>
|
||||||
|
|
||||||
|
<TagInUse
|
||||||
|
label="auto tagging"
|
||||||
|
count={autoTagIds.length}
|
||||||
|
shouldPluralize={false}
|
||||||
|
/>
|
||||||
</div> :
|
</div> :
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{
|
||||||
!isTagUsed &&
|
!isTagUsed &&
|
||||||
|
@ -153,6 +146,7 @@ class Tag extends Component {
|
||||||
notificationIds={notificationIds}
|
notificationIds={notificationIds}
|
||||||
restrictionIds={restrictionIds}
|
restrictionIds={restrictionIds}
|
||||||
indexerIds={indexerIds}
|
indexerIds={indexerIds}
|
||||||
|
autoTagIds={autoTagIds}
|
||||||
isOpen={isDetailsModalOpen}
|
isOpen={isDetailsModalOpen}
|
||||||
onModalClose={this.onDetailsModalClose}
|
onModalClose={this.onDetailsModalClose}
|
||||||
onDeleteTagPress={this.onDeleteTagPress}
|
onDeleteTagPress={this.onDeleteTagPress}
|
||||||
|
@ -180,6 +174,7 @@ Tag.propTypes = {
|
||||||
notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired,
|
notificationIds: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||||
restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired,
|
restrictionIds: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||||
indexerIds: PropTypes.arrayOf(PropTypes.number).isRequired,
|
indexerIds: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||||
|
autoTagIds: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||||
seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired,
|
seriesIds: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||||
onConfirmDeleteTag: PropTypes.func.isRequired
|
onConfirmDeleteTag: PropTypes.func.isRequired
|
||||||
};
|
};
|
||||||
|
@ -190,6 +185,7 @@ Tag.defaultProps = {
|
||||||
notificationIds: [],
|
notificationIds: [],
|
||||||
restrictionIds: [],
|
restrictionIds: [],
|
||||||
indexerIds: [],
|
indexerIds: [],
|
||||||
|
autoTagIds: [],
|
||||||
seriesIds: []
|
seriesIds: []
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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 (
|
||||||
|
<div>
|
||||||
|
{count} {label}s
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{count} {label}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
TagInUse.propTypes = {
|
||||||
|
label: PropTypes.string.isRequired,
|
||||||
|
count: PropTypes.number.isRequired,
|
||||||
|
shouldPluralize: PropTypes.bool
|
||||||
|
};
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
||||||
import PageContent from 'Components/Page/PageContent';
|
import PageContent from 'Components/Page/PageContent';
|
||||||
import PageContentBody from 'Components/Page/PageContentBody';
|
import PageContentBody from 'Components/Page/PageContentBody';
|
||||||
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
|
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
|
||||||
|
import AutoTaggings from './AutoTagging/AutoTaggings';
|
||||||
import TagsConnector from './TagsConnector';
|
import TagsConnector from './TagsConnector';
|
||||||
|
|
||||||
function TagSettings() {
|
function TagSettings() {
|
||||||
|
@ -13,6 +14,7 @@ function TagSettings() {
|
||||||
|
|
||||||
<PageContentBody>
|
<PageContentBody>
|
||||||
<TagsConnector />
|
<TagsConnector />
|
||||||
|
<AutoTaggings />
|
||||||
</PageContentBody>
|
</PageContentBody>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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: []
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
};
|
|
@ -1,6 +1,8 @@
|
||||||
import { createAction } from 'redux-actions';
|
import { createAction } from 'redux-actions';
|
||||||
import { handleThunks } from 'Store/thunks';
|
import { handleThunks } from 'Store/thunks';
|
||||||
import createHandleActions from './Creators/createHandleActions';
|
import createHandleActions from './Creators/createHandleActions';
|
||||||
|
import autoTaggings from './Settings/autoTaggings';
|
||||||
|
import autoTaggingSpecifications from './Settings/autoTaggingSpecifications';
|
||||||
import customFormats from './Settings/customFormats';
|
import customFormats from './Settings/customFormats';
|
||||||
import customFormatSpecifications from './Settings/customFormatSpecifications';
|
import customFormatSpecifications from './Settings/customFormatSpecifications';
|
||||||
import delayProfiles from './Settings/delayProfiles';
|
import delayProfiles from './Settings/delayProfiles';
|
||||||
|
@ -23,6 +25,8 @@ import releaseProfiles from './Settings/releaseProfiles';
|
||||||
import remotePathMappings from './Settings/remotePathMappings';
|
import remotePathMappings from './Settings/remotePathMappings';
|
||||||
import ui from './Settings/ui';
|
import ui from './Settings/ui';
|
||||||
|
|
||||||
|
export * from './Settings/autoTaggingSpecifications';
|
||||||
|
export * from './Settings/autoTaggings';
|
||||||
export * from './Settings/customFormatSpecifications.js';
|
export * from './Settings/customFormatSpecifications.js';
|
||||||
export * from './Settings/customFormats';
|
export * from './Settings/customFormats';
|
||||||
export * from './Settings/delayProfiles';
|
export * from './Settings/delayProfiles';
|
||||||
|
@ -55,7 +59,8 @@ export const section = 'settings';
|
||||||
|
|
||||||
export const defaultState = {
|
export const defaultState = {
|
||||||
advancedSettings: false,
|
advancedSettings: false,
|
||||||
|
autoTaggingSpecifications: autoTaggingSpecifications.defaultState,
|
||||||
|
autoTaggings: autoTaggings.defaultState,
|
||||||
customFormatSpecifications: customFormatSpecifications.defaultState,
|
customFormatSpecifications: customFormatSpecifications.defaultState,
|
||||||
customFormats: customFormats.defaultState,
|
customFormats: customFormats.defaultState,
|
||||||
delayProfiles: delayProfiles.defaultState,
|
delayProfiles: delayProfiles.defaultState,
|
||||||
|
@ -97,6 +102,8 @@ export const toggleAdvancedSettings = createAction(TOGGLE_ADVANCED_SETTINGS);
|
||||||
// Action Handlers
|
// Action Handlers
|
||||||
|
|
||||||
export const actionHandlers = handleThunks({
|
export const actionHandlers = handleThunks({
|
||||||
|
...autoTaggingSpecifications.actionHandlers,
|
||||||
|
...autoTaggings.actionHandlers,
|
||||||
...customFormatSpecifications.actionHandlers,
|
...customFormatSpecifications.actionHandlers,
|
||||||
...customFormats.actionHandlers,
|
...customFormats.actionHandlers,
|
||||||
...delayProfiles.actionHandlers,
|
...delayProfiles.actionHandlers,
|
||||||
|
@ -129,6 +136,8 @@ export const reducers = createHandleActions({
|
||||||
return Object.assign({}, state, { advancedSettings: !state.advancedSettings });
|
return Object.assign({}, state, { advancedSettings: !state.advancedSettings });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
...autoTaggingSpecifications.reducers,
|
||||||
|
...autoTaggings.reducers,
|
||||||
...customFormatSpecifications.reducers,
|
...customFormatSpecifications.reducers,
|
||||||
...customFormats.reducers,
|
...customFormats.reducers,
|
||||||
...delayProfiles.reducers,
|
...delayProfiles.reducers,
|
||||||
|
|
|
@ -2,11 +2,7 @@ import _ from 'lodash';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import selectSettings from 'Store/Selectors/selectSettings';
|
import selectSettings from 'Store/Selectors/selectSettings';
|
||||||
|
|
||||||
function createProviderSettingsSelector(sectionName) {
|
function selector(id, section) {
|
||||||
return createSelector(
|
|
||||||
(state, { id }) => id,
|
|
||||||
(state) => state.settings[sectionName],
|
|
||||||
(id, section) => {
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
const item = _.isArray(section.schema) ? section.selectedSchema : section.schema;
|
const item = _.isArray(section.schema) ? section.selectedSchema : section.schema;
|
||||||
const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError);
|
const settings = selectSettings(Object.assign({ name: '' }, item), section.pendingChanges, section.saveError);
|
||||||
|
@ -57,7 +53,19 @@ function createProviderSettingsSelector(sectionName) {
|
||||||
item: settings.settings
|
item: settings.settings
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function createProviderSettingsSelector(sectionName) {
|
||||||
|
return createSelector(
|
||||||
|
(state, { id }) => id,
|
||||||
|
(state) => state.settings[sectionName],
|
||||||
|
(id, section) => selector(id, section)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createProviderSettingsSelectorHook(sectionName, id) {
|
||||||
|
return createSelector(
|
||||||
|
(state) => state.settings[sectionName],
|
||||||
|
(section) => selector(id, section)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default createProviderSettingsSelector;
|
|
||||||
|
|
|
@ -0,0 +1,140 @@
|
||||||
|
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.Test.Framework;
|
||||||
|
using NzbDrone.Core.Tv;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Test.AutoTagging
|
||||||
|
{
|
||||||
|
[TestFixture]
|
||||||
|
public class AutoTaggingServiceFixture : CoreTest<AutoTaggingService>
|
||||||
|
{
|
||||||
|
private Series _series;
|
||||||
|
private AutoTag _tag;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void Setup()
|
||||||
|
{
|
||||||
|
_series = Builder<Series>.CreateNew()
|
||||||
|
.With(s => s.Genres = new List<string> { "Comedy" })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
_tag = new AutoTag
|
||||||
|
{
|
||||||
|
Name = "Test",
|
||||||
|
Specifications = new List<IAutoTaggingSpecification>
|
||||||
|
{
|
||||||
|
new GenreSpecification
|
||||||
|
{
|
||||||
|
Name = "Genre",
|
||||||
|
Value = new List<string>
|
||||||
|
{
|
||||||
|
"Comedy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Tags = new HashSet<int> { 1 },
|
||||||
|
RemoveTagsAutomatically = false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void GivenAutoTags(List<AutoTag> autoTags)
|
||||||
|
{
|
||||||
|
Mocker.GetMock<IAutoTaggingRepository>()
|
||||||
|
.Setup(s => s.All())
|
||||||
|
.Returns(autoTags);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_not_have_changes_if_there_are_no_auto_tags()
|
||||||
|
{
|
||||||
|
GivenAutoTags(new List<AutoTag>());
|
||||||
|
|
||||||
|
var result = Subject.GetTagChanges(_series);
|
||||||
|
|
||||||
|
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<AutoTag> { _tag });
|
||||||
|
|
||||||
|
var result = Subject.GetTagChanges(_series);
|
||||||
|
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
_series.Tags = new HashSet<int> { 1 };
|
||||||
|
_series.Genres = new List<string> { "NotComedy" };
|
||||||
|
|
||||||
|
GivenAutoTags(new List<AutoTag> { _tag });
|
||||||
|
|
||||||
|
var result = Subject.GetTagChanges(_series);
|
||||||
|
|
||||||
|
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()
|
||||||
|
{
|
||||||
|
_series.Tags = new HashSet<int> { 1 };
|
||||||
|
_series.Genres = new List<string> { "NotComedy" };
|
||||||
|
|
||||||
|
_tag.RemoveTagsAutomatically = true;
|
||||||
|
|
||||||
|
GivenAutoTags(new List<AutoTag> { _tag });
|
||||||
|
|
||||||
|
var result = Subject.GetTagChanges(_series);
|
||||||
|
|
||||||
|
result.TagsToAdd.Should().BeEmpty();
|
||||||
|
result.TagsToRemove.Should().HaveCount(1);
|
||||||
|
result.TagsToRemove.Should().Contain(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_have_tags_to_add_if_series_does_not_have_match_tag_and_series_matches_all_rules()
|
||||||
|
{
|
||||||
|
_tag.Specifications.Add(new SeriesTypeSpecification
|
||||||
|
{
|
||||||
|
Name = "Series Type",
|
||||||
|
Value = (int)_series.SeriesType
|
||||||
|
});
|
||||||
|
|
||||||
|
GivenAutoTags(new List<AutoTag> { _tag });
|
||||||
|
|
||||||
|
var result = Subject.GetTagChanges(_series);
|
||||||
|
|
||||||
|
result.TagsToAdd.Should().HaveCount(1);
|
||||||
|
result.TagsToAdd.Should().Contain(1);
|
||||||
|
result.TagsToRemove.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_match_if_specification_is_negated()
|
||||||
|
{
|
||||||
|
_series.Genres = new List<string> { "NotComedy" };
|
||||||
|
|
||||||
|
_tag.Specifications.First().Negate = true;
|
||||||
|
|
||||||
|
GivenAutoTags(new List<AutoTag> { _tag });
|
||||||
|
|
||||||
|
var result = Subject.GetTagChanges(_series);
|
||||||
|
|
||||||
|
result.TagsToAdd.Should().HaveCount(1);
|
||||||
|
result.TagsToAdd.Should().Contain(1);
|
||||||
|
result.TagsToRemove.Should().BeEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -3,10 +3,10 @@ using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using FizzWare.NBuilder;
|
using FizzWare.NBuilder;
|
||||||
using FluentAssertions;
|
|
||||||
using Moq;
|
using Moq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using NzbDrone.Common.Extensions;
|
using NzbDrone.Common.Extensions;
|
||||||
|
using NzbDrone.Core.AutoTagging;
|
||||||
using NzbDrone.Core.Exceptions;
|
using NzbDrone.Core.Exceptions;
|
||||||
using NzbDrone.Core.MediaFiles;
|
using NzbDrone.Core.MediaFiles;
|
||||||
using NzbDrone.Core.MetadataSource;
|
using NzbDrone.Core.MetadataSource;
|
||||||
|
@ -44,6 +44,10 @@ namespace NzbDrone.Core.Test.TvTests
|
||||||
Mocker.GetMock<IProvideSeriesInfo>()
|
Mocker.GetMock<IProvideSeriesInfo>()
|
||||||
.Setup(s => s.GetSeriesInfo(It.IsAny<int>()))
|
.Setup(s => s.GetSeriesInfo(It.IsAny<int>()))
|
||||||
.Callback<int>(p => { throw new SeriesNotFoundException(p); });
|
.Callback<int>(p => { throw new SeriesNotFoundException(p); });
|
||||||
|
|
||||||
|
Mocker.GetMock<IAutoTaggingService>()
|
||||||
|
.Setup(s => s.GetTagChanges(_series))
|
||||||
|
.Returns(new AutoTaggingChanges());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void GivenNewSeriesInfo(Series series)
|
private void GivenNewSeriesInfo(Series series)
|
||||||
|
|
|
@ -64,7 +64,8 @@ namespace NzbDrone.Core.Annotations
|
||||||
Captcha,
|
Captcha,
|
||||||
OAuth,
|
OAuth,
|
||||||
Device,
|
Device,
|
||||||
TagSelect
|
TagSelect,
|
||||||
|
RootFolder
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum HiddenType
|
public enum HiddenType
|
||||||
|
|
|
@ -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<int>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public string Name { get; set; }
|
||||||
|
public List<IAutoTaggingSpecification> Specifications { get; set; }
|
||||||
|
public bool RemoveTagsAutomatically { get; set; }
|
||||||
|
public HashSet<int> Tags { get; set; }
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.AutoTagging
|
||||||
|
{
|
||||||
|
public class AutoTaggingChanges
|
||||||
|
{
|
||||||
|
public HashSet<int> TagsToAdd { get; set; }
|
||||||
|
public HashSet<int> TagsToRemove { get; set; }
|
||||||
|
|
||||||
|
public AutoTaggingChanges()
|
||||||
|
{
|
||||||
|
TagsToAdd = new HashSet<int>();
|
||||||
|
TagsToRemove = new HashSet<int>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
using NzbDrone.Core.Datastore;
|
||||||
|
using NzbDrone.Core.Messaging.Events;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.AutoTagging
|
||||||
|
{
|
||||||
|
public interface IAutoTaggingRepository : IBasicRepository<AutoTag>
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AutoTaggingRepository : BasicRepository<AutoTag>, IAutoTaggingRepository
|
||||||
|
{
|
||||||
|
public AutoTaggingRepository(IMainDatabase database, IEventAggregator eventAggregator)
|
||||||
|
: base(database, eventAggregator)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,128 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using NzbDrone.Common.Cache;
|
||||||
|
using NzbDrone.Common.Extensions;
|
||||||
|
using NzbDrone.Core.RootFolders;
|
||||||
|
using NzbDrone.Core.Tv;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.AutoTagging
|
||||||
|
{
|
||||||
|
public interface IAutoTaggingService
|
||||||
|
{
|
||||||
|
void Update(AutoTag autoTag);
|
||||||
|
AutoTag Insert(AutoTag autoTag);
|
||||||
|
List<AutoTag> All();
|
||||||
|
AutoTag GetById(int id);
|
||||||
|
void Delete(int id);
|
||||||
|
List<AutoTag> AllForTag(int tagId);
|
||||||
|
AutoTaggingChanges GetTagChanges(Series series);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class AutoTaggingService : IAutoTaggingService
|
||||||
|
{
|
||||||
|
private readonly IAutoTaggingRepository _repository;
|
||||||
|
private readonly RootFolderService _rootFolderService;
|
||||||
|
private readonly ICached<Dictionary<int, AutoTag>> _cache;
|
||||||
|
|
||||||
|
public AutoTaggingService(IAutoTaggingRepository repository,
|
||||||
|
RootFolderService rootFolderService,
|
||||||
|
ICacheManager cacheManager)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
_rootFolderService = rootFolderService;
|
||||||
|
_cache = cacheManager.GetCache<Dictionary<int, AutoTag>>(typeof(AutoTag), "autoTags");
|
||||||
|
}
|
||||||
|
|
||||||
|
private Dictionary<int, AutoTag> AllDictionary()
|
||||||
|
{
|
||||||
|
return _cache.Get("all", () => _repository.All().ToDictionary(m => m.Id));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<AutoTag> 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<AutoTag> AllForTag(int tagId)
|
||||||
|
{
|
||||||
|
return All().Where(p => p.Tags.Contains(tagId))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public AutoTaggingChanges GetTagChanges(Series series)
|
||||||
|
{
|
||||||
|
var autoTags = All();
|
||||||
|
var changes = new AutoTaggingChanges();
|
||||||
|
|
||||||
|
if (autoTags.Empty())
|
||||||
|
{
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the root folder path on the series
|
||||||
|
series.RootFolderPath = _rootFolderService.GetBestRootFolderPath(series.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(series))
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var allMatch = specificationMatches.All(x => x.DidMatch);
|
||||||
|
var tags = autoTag.Tags;
|
||||||
|
|
||||||
|
if (allMatch)
|
||||||
|
{
|
||||||
|
foreach (var tag in tags)
|
||||||
|
{
|
||||||
|
if (!series.Tags.Contains(tag))
|
||||||
|
{
|
||||||
|
changes.TagsToAdd.Add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoTag.RemoveTagsAutomatically)
|
||||||
|
{
|
||||||
|
foreach (var tag in tags)
|
||||||
|
{
|
||||||
|
changes.TagsToRemove.Add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<IAutoTaggingSpecification, bool> Matches { get; set; }
|
||||||
|
|
||||||
|
public bool DidMatch => !(Matches.Any(m => m.Key.Required && m.Value == false) ||
|
||||||
|
Matches.All(m => m.Value == false));
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
using NzbDrone.Core.Tv;
|
||||||
|
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(Series series)
|
||||||
|
{
|
||||||
|
var match = IsSatisfiedByWithoutNegate(series);
|
||||||
|
|
||||||
|
if (Negate)
|
||||||
|
{
|
||||||
|
match = !match;
|
||||||
|
}
|
||||||
|
|
||||||
|
return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected abstract bool IsSatisfiedByWithoutNegate(Series series);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using FluentValidation;
|
||||||
|
using NzbDrone.Core.Annotations;
|
||||||
|
using NzbDrone.Core.Tv;
|
||||||
|
using NzbDrone.Core.Validation;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.AutoTagging.Specifications
|
||||||
|
{
|
||||||
|
public class GenreSpecificationValidator : AbstractValidator<GenreSpecification>
|
||||||
|
{
|
||||||
|
public GenreSpecificationValidator()
|
||||||
|
{
|
||||||
|
RuleFor(c => c.Value).NotEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GenreSpecification : AutoTaggingSpecificationBase
|
||||||
|
{
|
||||||
|
private static readonly GenreSpecificationValidator Validator = new GenreSpecificationValidator();
|
||||||
|
|
||||||
|
public override int Order => 1;
|
||||||
|
public override string ImplementationName => "Genre";
|
||||||
|
|
||||||
|
[FieldDefinition(1, Label = "Genre(s)", Type = FieldType.Tag)]
|
||||||
|
public IEnumerable<string> Value { get; set; }
|
||||||
|
|
||||||
|
protected override bool IsSatisfiedByWithoutNegate(Series series)
|
||||||
|
{
|
||||||
|
return series.Genres.Any(genre => Value.Contains(genre));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override NzbDroneValidationResult Validate()
|
||||||
|
{
|
||||||
|
return new NzbDroneValidationResult(Validator.Validate(this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
using NzbDrone.Core.Tv;
|
||||||
|
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(Series series);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
using FluentValidation;
|
||||||
|
using NzbDrone.Common.Extensions;
|
||||||
|
using NzbDrone.Core.Annotations;
|
||||||
|
using NzbDrone.Core.Tv;
|
||||||
|
using NzbDrone.Core.Validation;
|
||||||
|
using NzbDrone.Core.Validation.Paths;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.AutoTagging.Specifications
|
||||||
|
{
|
||||||
|
public class RootFolderSpecificationValidator : AbstractValidator<RootFolderSpecification>
|
||||||
|
{
|
||||||
|
public RootFolderSpecificationValidator()
|
||||||
|
{
|
||||||
|
RuleFor(c => c.Value).IsValidPath();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class RootFolderSpecification : AutoTaggingSpecificationBase
|
||||||
|
{
|
||||||
|
private static readonly RootFolderSpecificationValidator Validator = new RootFolderSpecificationValidator();
|
||||||
|
|
||||||
|
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(Series series)
|
||||||
|
{
|
||||||
|
return series.RootFolderPath.PathEquals(Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override NzbDroneValidationResult Validate()
|
||||||
|
{
|
||||||
|
return new NzbDroneValidationResult(Validator.Validate(this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,36 @@
|
||||||
|
using FluentValidation;
|
||||||
|
using NzbDrone.Core.Annotations;
|
||||||
|
using NzbDrone.Core.Tv;
|
||||||
|
using NzbDrone.Core.Validation;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.AutoTagging.Specifications
|
||||||
|
{
|
||||||
|
public class SeriesTypeSpecificationValidator : AbstractValidator<SeriesTypeSpecification>
|
||||||
|
{
|
||||||
|
public SeriesTypeSpecificationValidator()
|
||||||
|
{
|
||||||
|
RuleFor(c => (SeriesTypes)c.Value).IsInEnum();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class SeriesTypeSpecification : AutoTaggingSpecificationBase
|
||||||
|
{
|
||||||
|
private static readonly SeriesTypeSpecificationValidator Validator = new SeriesTypeSpecificationValidator();
|
||||||
|
|
||||||
|
public override int Order => 2;
|
||||||
|
public override string ImplementationName => "Series Type";
|
||||||
|
|
||||||
|
[FieldDefinition(1, Label = "Series Type", Type = FieldType.Select, SelectOptions = typeof(SeriesTypes))]
|
||||||
|
public int Value { get; set; }
|
||||||
|
|
||||||
|
protected override bool IsSatisfiedByWithoutNegate(Series series)
|
||||||
|
{
|
||||||
|
return (int)series.SeriesType == Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override NzbDroneValidationResult Validate()
|
||||||
|
{
|
||||||
|
return new NzbDroneValidationResult(Validator.Validate(this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<List<IAutoTaggingSpecification>>
|
||||||
|
{
|
||||||
|
public override void Write(Utf8JsonWriter writer, List<IAutoTaggingSpecification> value, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
var wrapped = value.Select(x => new SpecificationWrapper
|
||||||
|
{
|
||||||
|
Type = x.GetType().Name,
|
||||||
|
Body = x
|
||||||
|
});
|
||||||
|
|
||||||
|
JsonSerializer.Serialize(writer, wrapped, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override List<IAutoTaggingSpecification> Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
ValidateToken(reader, JsonTokenType.StartArray);
|
||||||
|
|
||||||
|
var results = new List<IAutoTaggingSpecification>();
|
||||||
|
|
||||||
|
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}, Sonarr.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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,18 @@
|
||||||
|
using FluentMigrator;
|
||||||
|
using NzbDrone.Core.Datastore.Migration.Framework;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Datastore.Migration
|
||||||
|
{
|
||||||
|
[Migration(179)]
|
||||||
|
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("[]");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ using System.Linq;
|
||||||
using Dapper;
|
using Dapper;
|
||||||
using NzbDrone.Common.Reflection;
|
using NzbDrone.Common.Reflection;
|
||||||
using NzbDrone.Core.Authentication;
|
using NzbDrone.Core.Authentication;
|
||||||
|
using NzbDrone.Core.AutoTagging.Specifications;
|
||||||
using NzbDrone.Core.Blocklisting;
|
using NzbDrone.Core.Blocklisting;
|
||||||
using NzbDrone.Core.Configuration;
|
using NzbDrone.Core.Configuration;
|
||||||
using NzbDrone.Core.CustomFilters;
|
using NzbDrone.Core.CustomFilters;
|
||||||
|
@ -158,6 +159,8 @@ namespace NzbDrone.Core.Datastore
|
||||||
|
|
||||||
Mapper.Entity<UpdateHistory>("UpdateHistory").RegisterModel();
|
Mapper.Entity<UpdateHistory>("UpdateHistory").RegisterModel();
|
||||||
Mapper.Entity<ImportListExclusion>("ImportListExclusions").RegisterModel();
|
Mapper.Entity<ImportListExclusion>("ImportListExclusions").RegisterModel();
|
||||||
|
|
||||||
|
Mapper.Entity<AutoTagging.AutoTag>("AutoTagging").RegisterModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void RegisterMappers()
|
private static void RegisterMappers()
|
||||||
|
@ -171,6 +174,7 @@ namespace NzbDrone.Core.Datastore
|
||||||
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<QualityProfileQualityItem>>(new QualityIntConverter()));
|
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<QualityProfileQualityItem>>(new QualityIntConverter()));
|
||||||
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<ProfileFormatItem>>(new CustomFormatIntConverter()));
|
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<ProfileFormatItem>>(new CustomFormatIntConverter()));
|
||||||
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<ICustomFormatSpecification>>(new CustomFormatSpecificationListConverter()));
|
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<ICustomFormatSpecification>>(new CustomFormatSpecificationListConverter()));
|
||||||
|
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<List<IAutoTaggingSpecification>>(new AutoTaggingSpecificationConverter()));
|
||||||
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<QualityModel>(new QualityIntConverter()));
|
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<QualityModel>(new QualityIntConverter()));
|
||||||
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<Dictionary<string, string>>());
|
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<Dictionary<string, string>>());
|
||||||
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<IDictionary<string, string>>());
|
SqlMapper.AddTypeHandler(new EmbeddedDocumentConverter<IDictionary<string, string>>());
|
||||||
|
|
|
@ -19,7 +19,7 @@ namespace NzbDrone.Core.Housekeeping.Housekeepers
|
||||||
{
|
{
|
||||||
using (var mapper = _database.OpenConnection())
|
using (var mapper = _database.OpenConnection())
|
||||||
{
|
{
|
||||||
var usedTags = new[] { "Series", "Notifications", "DelayProfiles", "ReleaseProfiles", "ImportLists", "Indexers" }
|
var usedTags = new[] { "Series", "Notifications", "DelayProfiles", "ReleaseProfiles", "ImportLists", "Indexers", "AutoTagging" }
|
||||||
.SelectMany(v => GetUsedTags(v, mapper))
|
.SelectMany(v => GetUsedTags(v, mapper))
|
||||||
.Distinct()
|
.Distinct()
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
using NzbDrone.Common.Disk;
|
using NzbDrone.Common.Disk;
|
||||||
using NzbDrone.Common.Http;
|
using NzbDrone.Common.Http;
|
||||||
|
|
||||||
namespace NzbDrone.Core.MediaCover
|
namespace NzbDrone.Core.MediaCover
|
||||||
|
|
|
@ -13,13 +13,8 @@ namespace NzbDrone.Core.Tags
|
||||||
public List<int> DelayProfileIds { get; set; }
|
public List<int> DelayProfileIds { get; set; }
|
||||||
public List<int> ImportListIds { get; set; }
|
public List<int> ImportListIds { get; set; }
|
||||||
public List<int> IndexerIds { get; set; }
|
public List<int> IndexerIds { get; set; }
|
||||||
|
public List<int> AutoTagIds { get; set; }
|
||||||
|
|
||||||
public bool InUse
|
public bool InUse => SeriesIds.Any() || NotificationIds.Any() || RestrictionIds.Any() || DelayProfileIds.Any() || ImportListIds.Any() || IndexerIds.Any() || AutoTagIds.Any();
|
||||||
{
|
|
||||||
get
|
|
||||||
{
|
|
||||||
return SeriesIds.Any() || NotificationIds.Any() || RestrictionIds.Any() || DelayProfileIds.Any() || ImportListIds.Any() || IndexerIds.Any();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using NzbDrone.Core.AutoTagging;
|
||||||
using NzbDrone.Core.Datastore;
|
using NzbDrone.Core.Datastore;
|
||||||
using NzbDrone.Core.ImportLists;
|
using NzbDrone.Core.ImportLists;
|
||||||
using NzbDrone.Core.Indexers;
|
using NzbDrone.Core.Indexers;
|
||||||
|
@ -35,6 +36,7 @@ namespace NzbDrone.Core.Tags
|
||||||
private readonly IReleaseProfileService _releaseProfileService;
|
private readonly IReleaseProfileService _releaseProfileService;
|
||||||
private readonly ISeriesService _seriesService;
|
private readonly ISeriesService _seriesService;
|
||||||
private readonly IIndexerFactory _indexerService;
|
private readonly IIndexerFactory _indexerService;
|
||||||
|
private readonly IAutoTaggingService _autoTaggingService;
|
||||||
|
|
||||||
public TagService(ITagRepository repo,
|
public TagService(ITagRepository repo,
|
||||||
IEventAggregator eventAggregator,
|
IEventAggregator eventAggregator,
|
||||||
|
@ -43,7 +45,8 @@ namespace NzbDrone.Core.Tags
|
||||||
INotificationFactory notificationFactory,
|
INotificationFactory notificationFactory,
|
||||||
IReleaseProfileService releaseProfileService,
|
IReleaseProfileService releaseProfileService,
|
||||||
ISeriesService seriesService,
|
ISeriesService seriesService,
|
||||||
IIndexerFactory indexerService)
|
IIndexerFactory indexerService,
|
||||||
|
IAutoTaggingService autoTaggingService)
|
||||||
{
|
{
|
||||||
_repo = repo;
|
_repo = repo;
|
||||||
_eventAggregator = eventAggregator;
|
_eventAggregator = eventAggregator;
|
||||||
|
@ -53,6 +56,7 @@ namespace NzbDrone.Core.Tags
|
||||||
_releaseProfileService = releaseProfileService;
|
_releaseProfileService = releaseProfileService;
|
||||||
_seriesService = seriesService;
|
_seriesService = seriesService;
|
||||||
_indexerService = indexerService;
|
_indexerService = indexerService;
|
||||||
|
_autoTaggingService = autoTaggingService;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Tag GetTag(int tagId)
|
public Tag GetTag(int tagId)
|
||||||
|
@ -86,6 +90,7 @@ namespace NzbDrone.Core.Tags
|
||||||
var restrictions = _releaseProfileService.AllForTag(tagId);
|
var restrictions = _releaseProfileService.AllForTag(tagId);
|
||||||
var series = _seriesService.AllForTag(tagId);
|
var series = _seriesService.AllForTag(tagId);
|
||||||
var indexers = _indexerService.AllForTag(tagId);
|
var indexers = _indexerService.AllForTag(tagId);
|
||||||
|
var autoTags = _autoTaggingService.AllForTag(tagId);
|
||||||
|
|
||||||
return new TagDetails
|
return new TagDetails
|
||||||
{
|
{
|
||||||
|
@ -96,7 +101,8 @@ namespace NzbDrone.Core.Tags
|
||||||
NotificationIds = notifications.Select(c => c.Id).ToList(),
|
NotificationIds = notifications.Select(c => c.Id).ToList(),
|
||||||
RestrictionIds = restrictions.Select(c => c.Id).ToList(),
|
RestrictionIds = restrictions.Select(c => c.Id).ToList(),
|
||||||
SeriesIds = series.Select(c => c.Id).ToList(),
|
SeriesIds = series.Select(c => c.Id).ToList(),
|
||||||
IndexerIds = indexers.Select(c => c.Id).ToList()
|
IndexerIds = indexers.Select(c => c.Id).ToList(),
|
||||||
|
AutoTagIds = autoTags.Select(c => c.Id).ToList()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -109,6 +115,7 @@ namespace NzbDrone.Core.Tags
|
||||||
var restrictions = _releaseProfileService.All();
|
var restrictions = _releaseProfileService.All();
|
||||||
var series = _seriesService.GetAllSeries();
|
var series = _seriesService.GetAllSeries();
|
||||||
var indexers = _indexerService.All();
|
var indexers = _indexerService.All();
|
||||||
|
var autotags = _autoTaggingService.All();
|
||||||
|
|
||||||
var details = new List<TagDetails>();
|
var details = new List<TagDetails>();
|
||||||
|
|
||||||
|
@ -123,7 +130,8 @@ namespace NzbDrone.Core.Tags
|
||||||
NotificationIds = notifications.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
|
NotificationIds = notifications.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
|
||||||
RestrictionIds = restrictions.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
|
RestrictionIds = restrictions.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
|
||||||
SeriesIds = series.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
|
SeriesIds = series.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).ToList(),
|
||||||
IndexerIds = indexers.Where(c => c.Tags.Contains(tag.Id)).Select(c => c.Id).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(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ using System.Linq;
|
||||||
using NLog;
|
using NLog;
|
||||||
using NzbDrone.Common.Extensions;
|
using NzbDrone.Common.Extensions;
|
||||||
using NzbDrone.Common.Instrumentation.Extensions;
|
using NzbDrone.Common.Instrumentation.Extensions;
|
||||||
|
using NzbDrone.Core.AutoTagging;
|
||||||
using NzbDrone.Core.Configuration;
|
using NzbDrone.Core.Configuration;
|
||||||
using NzbDrone.Core.Exceptions;
|
using NzbDrone.Core.Exceptions;
|
||||||
using NzbDrone.Core.MediaFiles;
|
using NzbDrone.Core.MediaFiles;
|
||||||
|
@ -26,6 +27,7 @@ namespace NzbDrone.Core.Tv
|
||||||
private readonly IDiskScanService _diskScanService;
|
private readonly IDiskScanService _diskScanService;
|
||||||
private readonly ICheckIfSeriesShouldBeRefreshed _checkIfSeriesShouldBeRefreshed;
|
private readonly ICheckIfSeriesShouldBeRefreshed _checkIfSeriesShouldBeRefreshed;
|
||||||
private readonly IConfigService _configService;
|
private readonly IConfigService _configService;
|
||||||
|
private readonly IAutoTaggingService _autoTaggingService;
|
||||||
private readonly Logger _logger;
|
private readonly Logger _logger;
|
||||||
|
|
||||||
public RefreshSeriesService(IProvideSeriesInfo seriesInfo,
|
public RefreshSeriesService(IProvideSeriesInfo seriesInfo,
|
||||||
|
@ -35,6 +37,7 @@ namespace NzbDrone.Core.Tv
|
||||||
IDiskScanService diskScanService,
|
IDiskScanService diskScanService,
|
||||||
ICheckIfSeriesShouldBeRefreshed checkIfSeriesShouldBeRefreshed,
|
ICheckIfSeriesShouldBeRefreshed checkIfSeriesShouldBeRefreshed,
|
||||||
IConfigService configService,
|
IConfigService configService,
|
||||||
|
IAutoTaggingService autoTaggingService,
|
||||||
Logger logger)
|
Logger logger)
|
||||||
{
|
{
|
||||||
_seriesInfo = seriesInfo;
|
_seriesInfo = seriesInfo;
|
||||||
|
@ -44,6 +47,7 @@ namespace NzbDrone.Core.Tv
|
||||||
_diskScanService = diskScanService;
|
_diskScanService = diskScanService;
|
||||||
_checkIfSeriesShouldBeRefreshed = checkIfSeriesShouldBeRefreshed;
|
_checkIfSeriesShouldBeRefreshed = checkIfSeriesShouldBeRefreshed;
|
||||||
_configService = configService;
|
_configService = configService;
|
||||||
|
_autoTaggingService = autoTaggingService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -189,6 +193,39 @@ namespace NzbDrone.Core.Tv
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdateTags(Series series)
|
||||||
|
{
|
||||||
|
_logger.Trace("Updating tags for {0}", series);
|
||||||
|
|
||||||
|
var tagsAdded = new HashSet<int>();
|
||||||
|
var tagsRemoved = new HashSet<int>();
|
||||||
|
var changes = _autoTaggingService.GetTagChanges(series);
|
||||||
|
|
||||||
|
foreach (var tag in changes.TagsToRemove)
|
||||||
|
{
|
||||||
|
if (series.Tags.Contains(tag))
|
||||||
|
{
|
||||||
|
series.Tags.Remove(tag);
|
||||||
|
tagsRemoved.Add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var tag in changes.TagsToAdd)
|
||||||
|
{
|
||||||
|
if (!series.Tags.Contains(tag))
|
||||||
|
{
|
||||||
|
series.Tags.Add(tag);
|
||||||
|
tagsAdded.Add(tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tagsAdded.Any() || tagsRemoved.Any())
|
||||||
|
{
|
||||||
|
_seriesService.UpdateSeries(series);
|
||||||
|
_logger.Debug("Updated tags for '{0}'. Added: {1}, Removed: {2}", series.Title, tagsAdded.Count, tagsRemoved.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Execute(RefreshSeriesCommand message)
|
public void Execute(RefreshSeriesCommand message)
|
||||||
{
|
{
|
||||||
var trigger = message.Trigger;
|
var trigger = message.Trigger;
|
||||||
|
@ -202,6 +239,7 @@ namespace NzbDrone.Core.Tv
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
series = RefreshSeriesInfo(message.SeriesId.Value);
|
series = RefreshSeriesInfo(message.SeriesId.Value);
|
||||||
|
UpdateTags(series);
|
||||||
RescanSeries(series, isNew, trigger);
|
RescanSeries(series, isNew, trigger);
|
||||||
}
|
}
|
||||||
catch (SeriesNotFoundException)
|
catch (SeriesNotFoundException)
|
||||||
|
@ -211,6 +249,7 @@ namespace NzbDrone.Core.Tv
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
_logger.Error(e, "Couldn't refresh info for {0}", series);
|
_logger.Error(e, "Couldn't refresh info for {0}", series);
|
||||||
|
UpdateTags(series);
|
||||||
RescanSeries(series, isNew, trigger);
|
RescanSeries(series, isNew, trigger);
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
|
@ -238,11 +277,13 @@ namespace NzbDrone.Core.Tv
|
||||||
_logger.Error(e, "Couldn't refresh info for {0}", seriesLocal);
|
_logger.Error(e, "Couldn't refresh info for {0}", seriesLocal);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
UpdateTags(series);
|
||||||
RescanSeries(seriesLocal, false, trigger);
|
RescanSeries(seriesLocal, false, trigger);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.Info("Skipping refresh of series: {0}", seriesLocal.Title);
|
_logger.Info("Skipping refresh of series: {0}", seriesLocal.Title);
|
||||||
|
UpdateTags(series);
|
||||||
RescanSeries(seriesLocal, false, trigger);
|
RescanSeries(seriesLocal, false, trigger);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 Sonarr.Http;
|
||||||
|
using Sonarr.Http.REST;
|
||||||
|
using Sonarr.Http.REST.Attributes;
|
||||||
|
|
||||||
|
namespace Sonarr.Api.V3.AutoTagging
|
||||||
|
{
|
||||||
|
[V3ApiController]
|
||||||
|
public class AutoTaggingController : RestController<AutoTaggingResource>
|
||||||
|
{
|
||||||
|
private readonly IAutoTaggingService _autoTaggingService;
|
||||||
|
private readonly List<IAutoTaggingSpecification> _specifications;
|
||||||
|
|
||||||
|
public AutoTaggingController(IAutoTaggingService autoTaggingService,
|
||||||
|
List<IAutoTaggingSpecification> specifications)
|
||||||
|
{
|
||||||
|
_autoTaggingService = autoTaggingService;
|
||||||
|
_specifications = specifications;
|
||||||
|
|
||||||
|
SharedValidator.RuleFor(c => c.Name).NotEmpty();
|
||||||
|
SharedValidator.RuleFor(c => c.Name)
|
||||||
|
.Must((v, c) => !_autoTaggingService.All().Any(f => f.Name == c && f.Id != v.Id)).WithMessage("Must be unique.");
|
||||||
|
SharedValidator.RuleFor(c => c.Tags).NotEmpty();
|
||||||
|
SharedValidator.RuleFor(c => c.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<AutoTaggingResource> Create(AutoTaggingResource autoTagResource)
|
||||||
|
{
|
||||||
|
var model = autoTagResource.ToModel(_specifications);
|
||||||
|
return Created(_autoTaggingService.Insert(model).Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[RestPutById]
|
||||||
|
[Consumes("application/json")]
|
||||||
|
public ActionResult<AutoTaggingResource> Update(AutoTaggingResource resource)
|
||||||
|
{
|
||||||
|
var model = resource.ToModel(_specifications);
|
||||||
|
_autoTaggingService.Update(model);
|
||||||
|
|
||||||
|
return Accepted(model.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
[Produces("application/json")]
|
||||||
|
public List<AutoTaggingResource> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,75 @@
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using NzbDrone.Core.AutoTagging;
|
||||||
|
using NzbDrone.Core.AutoTagging.Specifications;
|
||||||
|
using Sonarr.Http.ClientSchema;
|
||||||
|
using Sonarr.Http.REST;
|
||||||
|
|
||||||
|
namespace Sonarr.Api.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<int> Tags { get; set; }
|
||||||
|
public List<AutoTaggingSpecificationSchema> Specifications { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AutoTaggingResourceMapper
|
||||||
|
{
|
||||||
|
public static AutoTaggingResource ToResource(this AutoTag model)
|
||||||
|
{
|
||||||
|
return new AutoTaggingResource
|
||||||
|
{
|
||||||
|
Id = model.Id,
|
||||||
|
Name = model.Name,
|
||||||
|
RemoveTagsAutomatically = model.RemoveTagsAutomatically,
|
||||||
|
Tags = model.Tags,
|
||||||
|
Specifications = model.Specifications.Select(x => x.ToSchema()).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public static List<AutoTaggingResource> ToResource(this IEnumerable<AutoTag> models)
|
||||||
|
{
|
||||||
|
return models.Select(m => m.ToResource()).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static AutoTag ToModel(this AutoTaggingResource resource, List<IAutoTaggingSpecification> specifications)
|
||||||
|
{
|
||||||
|
return new AutoTag
|
||||||
|
{
|
||||||
|
Id = resource.Id,
|
||||||
|
Name = resource.Name,
|
||||||
|
RemoveTagsAutomatically = resource.RemoveTagsAutomatically,
|
||||||
|
Tags = resource.Tags,
|
||||||
|
Specifications = resource.Specifications.Select(x => MapSpecification(x, specifications)).ToList()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IAutoTaggingSpecification MapSpecification(AutoTaggingSpecificationSchema resource, List<IAutoTaggingSpecification> specifications)
|
||||||
|
{
|
||||||
|
var matchingSpec =
|
||||||
|
specifications.SingleOrDefault(x => x.GetType().Name == resource.Implementation);
|
||||||
|
|
||||||
|
if (matchingSpec is null)
|
||||||
|
{
|
||||||
|
throw new ArgumentException(
|
||||||
|
$"{resource.Implementation} is not a valid specification implementation");
|
||||||
|
}
|
||||||
|
|
||||||
|
var type = matchingSpec.GetType();
|
||||||
|
|
||||||
|
// Finding the exact current specification isn't possible given the dynamic nature of them and the possibility that multiple
|
||||||
|
// of the same type exist within the same format. Passing in null is safe as long as there never exists a specification that
|
||||||
|
// relies on additional privacy.
|
||||||
|
var spec = (IAutoTaggingSpecification)SchemaBuilder.ReadFromSchema(resource.Fields, type, null);
|
||||||
|
spec.Name = resource.Name;
|
||||||
|
spec.Negate = resource.Negate;
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using NzbDrone.Core.AutoTagging.Specifications;
|
||||||
|
using Sonarr.Http.ClientSchema;
|
||||||
|
using Sonarr.Http.REST;
|
||||||
|
|
||||||
|
namespace Sonarr.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<Field> Fields { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class AutoTaggingSpecificationSchemaMapper
|
||||||
|
{
|
||||||
|
public static AutoTaggingSpecificationSchema ToSchema(this IAutoTaggingSpecification model)
|
||||||
|
{
|
||||||
|
return new AutoTaggingSpecificationSchema
|
||||||
|
{
|
||||||
|
Name = model.Name,
|
||||||
|
Implementation = model.GetType().Name,
|
||||||
|
ImplementationName = model.ImplementationName,
|
||||||
|
Negate = model.Negate,
|
||||||
|
Required = model.Required,
|
||||||
|
Fields = SchemaBuilder.ToSchema(model)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,6 +13,7 @@ namespace Sonarr.Api.V3.Tags
|
||||||
public List<int> NotificationIds { get; set; }
|
public List<int> NotificationIds { get; set; }
|
||||||
public List<int> RestrictionIds { get; set; }
|
public List<int> RestrictionIds { get; set; }
|
||||||
public List<int> IndexerIds { get; set; }
|
public List<int> IndexerIds { get; set; }
|
||||||
|
public List<int> AutoTagIds { get; set; }
|
||||||
public List<int> SeriesIds { get; set; }
|
public List<int> SeriesIds { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,6 +35,7 @@ namespace Sonarr.Api.V3.Tags
|
||||||
NotificationIds = model.NotificationIds,
|
NotificationIds = model.NotificationIds,
|
||||||
RestrictionIds = model.RestrictionIds,
|
RestrictionIds = model.RestrictionIds,
|
||||||
IndexerIds = model.IndexerIds,
|
IndexerIds = model.IndexerIds,
|
||||||
|
AutoTagIds = model.AutoTagIds,
|
||||||
SeriesIds = model.SeriesIds
|
SeriesIds = model.SeriesIds
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue