From c3a89c46f58aca4f4649f235e981b43b397d151b Mon Sep 17 00:00:00 2001 From: ta264 Date: Mon, 7 Sep 2020 21:26:25 +0100 Subject: [PATCH] New: Import and export custom formats --- frontend/src/Helpers/Props/icons.js | 2 + .../CustomFormats/CustomFormat.js | 37 ++++- .../EditCustomFormatModalContent.js | 31 +++- .../CustomFormats/ExportCustomFormatModal.js | 61 +++++++ .../ExportCustomFormatModalContent.css | 5 + .../ExportCustomFormatModalContent.js | 84 ++++++++++ ...ExportCustomFormatModalContentConnector.js | 83 ++++++++++ .../CustomFormats/ImportCustomFormatModal.js | 61 +++++++ .../ImportCustomFormatModalContent.css | 5 + .../ImportCustomFormatModalContent.js | 152 ++++++++++++++++++ ...ImportCustomFormatModalContentConnector.js | 146 +++++++++++++++++ .../Settings/customFormatSpecifications.js | 9 ++ src/NzbDrone.Core/Localization/Core/en.json | 5 + 13 files changed, 673 insertions(+), 8 deletions(-) create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/ExportCustomFormatModal.js create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/ExportCustomFormatModalContent.css create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/ExportCustomFormatModalContent.js create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/ExportCustomFormatModalContentConnector.js create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/ImportCustomFormatModal.js create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/ImportCustomFormatModalContent.css create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/ImportCustomFormatModalContent.js create mode 100644 frontend/src/Settings/CustomFormats/CustomFormats/ImportCustomFormatModalContentConnector.js diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 0e90d9287..8cea4ebc3 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -55,6 +55,7 @@ import { faEye as fasEye, faFastBackward as fasFastBackward, faFastForward as fasFastForward, + faFileExport as fasFileExport, faFileInvoice as farFileInvoice, faFilm as fasFilm, faFilter as fasFilter, @@ -145,6 +146,7 @@ export const EDIT = fasWrench; export const MOVIE_FILE = farFileVideo; export const EXPAND = fasChevronCircleDown; export const EXPAND_INDETERMINATE = fasChevronCircleRight; +export const EXPORT = fasFileExport; export const EXTERNAL_LINK = fasExternalLinkAlt; export const FATAL = fasTimesCircle; export const FILE = farFile; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormat.js b/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormat.js index 30959c432..ddf111c48 100644 --- a/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormat.js +++ b/frontend/src/Settings/CustomFormats/CustomFormats/CustomFormat.js @@ -7,6 +7,7 @@ import ConfirmModal from 'Components/Modal/ConfirmModal'; import { icons, kinds } from 'Helpers/Props'; import translate from 'Utilities/String/translate'; import EditCustomFormatModalConnector from './EditCustomFormatModalConnector'; +import ExportCustomFormatModal from './ExportCustomFormatModal'; import styles from './CustomFormat.css'; class CustomFormat extends Component { @@ -19,6 +20,7 @@ class CustomFormat extends Component { this.state = { isEditCustomFormatModalOpen: false, + isExportCustomFormatModalOpen: false, isDeleteCustomFormatModalOpen: false }; } @@ -34,6 +36,14 @@ class CustomFormat extends Component { this.setState({ isEditCustomFormatModalOpen: false }); } + onExportCustomFormatPress = () => { + this.setState({ isExportCustomFormatModalOpen: true }); + } + + onExportCustomFormatModalClose = () => { + this.setState({ isExportCustomFormatModalOpen: false }); + } + onDeleteCustomFormatPress = () => { this.setState({ isEditCustomFormatModalOpen: false, @@ -80,12 +90,21 @@ class CustomFormat extends Component { {name} - +
+ + + +
@@ -122,6 +141,12 @@ class CustomFormat extends Component { onDeleteCustomFormatPress={this.onDeleteCustomFormatPress} /> + + { + this.setState({ isImportCustomFormatModalOpen: true }); + } + + onImportCustomFormatModalClose = () => { + this.setState({ isImportCustomFormatModalOpen: false }); + } + // // Render @@ -76,7 +86,8 @@ class EditCustomFormatModalContent extends Component { const { isAddSpecificationModalOpen, - isEditSpecificationModalOpen + isEditSpecificationModalOpen, + isImportCustomFormatModalOpen } = this.state; const { @@ -176,6 +187,12 @@ class EditCustomFormatModalContent extends Component { isOpen={isEditSpecificationModalOpen} onModalClose={this.onEditSpecificationModalClose} /> + + +
} @@ -192,6 +209,16 @@ class EditCustomFormatModalContent extends Component { } + { + !id && + + } + + + + ); + } +} + +ExportCustomFormatModalContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + json: PropTypes.string.isRequired, + specificationsPopulated: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ExportCustomFormatModalContent; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/ExportCustomFormatModalContentConnector.js b/frontend/src/Settings/CustomFormats/CustomFormats/ExportCustomFormatModalContentConnector.js new file mode 100644 index 000000000..f762f44e7 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/ExportCustomFormatModalContentConnector.js @@ -0,0 +1,83 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchCustomFormatSpecifications } from 'Store/Actions/settingsActions'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import ExportCustomFormatModalContent from './ExportCustomFormatModalContent'; + +const blacklistedProperties = ['id', 'implementationName', 'infoLink']; + +function replacer(key, value) { + if (blacklistedProperties.includes(key)) { + return undefined; + } + + // provider fields + if (key === 'fields') { + return value.reduce((acc, cur) => { + acc[cur.name] = cur.value; + return acc; + }, {}); + } + + // regular setting values + if (value.hasOwnProperty('value')) { + return value.value; + } + + return value; +} + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('customFormats'), + (state) => state.settings.customFormatSpecifications, + (advancedSettings, customFormat, specifications) => { + const json = customFormat.item ? JSON.stringify(customFormat.item, replacer, 2) : ''; + return { + advancedSettings, + ...customFormat, + json, + specificationsPopulated: specifications.isPopulated, + specifications: specifications.items + }; + } + ); +} + +const mapDispatchToProps = { + fetchCustomFormatSpecifications +}; + +class ExportCustomFormatModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + id + } = this.props; + this.props.fetchCustomFormatSpecifications({ id }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ExportCustomFormatModalContentConnector.propTypes = { + id: PropTypes.number, + fetchCustomFormatSpecifications: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ExportCustomFormatModalContentConnector); diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/ImportCustomFormatModal.js b/frontend/src/Settings/CustomFormats/CustomFormats/ImportCustomFormatModal.js new file mode 100644 index 000000000..2a4d9735e --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/ImportCustomFormatModal.js @@ -0,0 +1,61 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Modal from 'Components/Modal/Modal'; +import { sizes } from 'Helpers/Props'; +import ImportCustomFormatModalContentConnector from './ImportCustomFormatModalContentConnector'; + +class ImportCustomFormatModal extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + height: 'auto' + }; + } + + // + // Listeners + + onContentHeightChange = (height) => { + if (this.state.height === 'auto' || height > this.state.height) { + this.setState({ height }); + } + } + + // + // Render + + render() { + const { + isOpen, + onModalClose, + ...otherProps + } = this.props; + + return ( + + + + ); + } +} + +ImportCustomFormatModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ImportCustomFormatModal; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/ImportCustomFormatModalContent.css b/frontend/src/Settings/CustomFormats/CustomFormats/ImportCustomFormatModalContent.css new file mode 100644 index 000000000..028df2455 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/ImportCustomFormatModalContent.css @@ -0,0 +1,5 @@ +.input { + composes: input from '~Components/Form/TextArea.css'; + + font-family: $monoSpaceFontFamily; +} diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/ImportCustomFormatModalContent.js b/frontend/src/Settings/CustomFormats/CustomFormats/ImportCustomFormatModalContent.js new file mode 100644 index 000000000..cfcd2d30a --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/ImportCustomFormatModalContent.js @@ -0,0 +1,152 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +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 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 { inputTypes, sizes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './ImportCustomFormatModalContent.css'; + +class ImportCustomFormatModalContent extends Component { + + // + // Lifecycle + constructor(props, context) { + super(props, context); + + this._importTimeout = null; + + this.state = { + json: '', + isSpinning: false, + parseError: null + }; + } + + componentWillUnmount() { + if (this._importTimeout) { + clearTimeout(this._importTimeout); + } + } + + // + // Control + + onChange = (event) => { + this.setState({ json: event.value }); + } + + onImportPress = () => { + this.setState({ isSpinning: true }); + // this is a bodge as we need to register a isSpinning: true to get the spinner button to update + this._importTimeout = setTimeout(this.doImport, 250); + } + + doImport = () => { + const parseError = this.props.onImportPress(this.state.json); + this.setState({ + parseError, + isSpinning: false + }); + + if (!parseError) { + this.props.onModalClose(); + } + } + + // + // Render + + render() { + const { + isFetching, + error, + specificationsPopulated, + onModalClose + } = this.props; + + const { + json, + isSpinning, + parseError + } = this.state; + + return ( + + + + {translate('ImportCustomFormat')} + + + +
+ { + isFetching && + + } + + { + !isFetching && !!error && +
+ {translate('UnableToLoadCustomFormats')} +
+ } + + { + !isFetching && !error && specificationsPopulated && +
+ + + {translate('CustomFormatJSON')} + + + +
+ } +
+
+ + + + {translate('Import')} + + +
+ ); + } +} + +ImportCustomFormatModalContent.propTypes = { + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + specificationsPopulated: PropTypes.bool.isRequired, + onImportPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ImportCustomFormatModalContent; diff --git a/frontend/src/Settings/CustomFormats/CustomFormats/ImportCustomFormatModalContentConnector.js b/frontend/src/Settings/CustomFormats/CustomFormats/ImportCustomFormatModalContentConnector.js new file mode 100644 index 000000000..f7b01fa95 --- /dev/null +++ b/frontend/src/Settings/CustomFormats/CustomFormats/ImportCustomFormatModalContentConnector.js @@ -0,0 +1,146 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { clearCustomFormatSpecificationPending, deleteAllCustomFormatSpecification, fetchCustomFormatSpecificationSchema, saveCustomFormatSpecification, selectCustomFormatSpecificationSchema, setCustomFormatSpecificationFieldValue, setCustomFormatSpecificationValue, setCustomFormatValue } from 'Store/Actions/settingsActions'; +import createProviderSettingsSelector from 'Store/Selectors/createProviderSettingsSelector'; +import translate from 'Utilities/String/translate'; +import ImportCustomFormatModalContent from './ImportCustomFormatModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.settings.advancedSettings, + createProviderSettingsSelector('customFormats'), + (state) => state.settings.customFormatSpecifications, + (advancedSettings, customFormat, specifications) => { + return { + advancedSettings, + ...customFormat, + specificationsPopulated: specifications.isPopulated, + specificationSchema: specifications.schema + }; + } + ); +} + +const mapDispatchToProps = { + deleteAllCustomFormatSpecification, + clearCustomFormatSpecificationPending, + clearPendingChanges, + saveCustomFormatSpecification, + selectCustomFormatSpecificationSchema, + setCustomFormatSpecificationFieldValue, + setCustomFormatSpecificationValue, + setCustomFormatValue, + fetchCustomFormatSpecificationSchema +}; + +class ImportCustomFormatModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.props.fetchCustomFormatSpecificationSchema(); + } + + // + // Listeners + + clearPending = () => { + this.props.clearPendingChanges({ section: 'settings.customFormats' }); + this.props.clearCustomFormatSpecificationPending(); + this.props.deleteAllCustomFormatSpecification(); + } + + onImportPress = (payload) => { + + this.clearPending(); + + try { + const cf = JSON.parse(payload); + this.parseCf(cf); + } catch (err) { + this.clearPending(); + return { + message: err.message, + detailedMessage: err.stack + }; + } + + return null; + } + + parseCf = (cf) => { + for (const [key, value] of Object.entries(cf)) { + if (key === 'specifications') { + for (const spec of value) { + this.parseSpecification(spec); + } + } else if (key !== 'id') { + this.props.setCustomFormatValue({ name: key, value }); + } + } + } + + parseSpecification = (spec) => { + const selectedImplementation = _.find(this.props.specificationSchema, { implementation: spec.implementation }); + + if (!selectedImplementation) { + throw new Error(translate('CustomFormatUnknownCondition', [spec.implementation])); + } + + this.props.selectCustomFormatSpecificationSchema({ implementation: spec.implementation }); + + for (const [key, value] of Object.entries(spec)) { + if (key === 'fields') { + this.parseFields(value, selectedImplementation); + } else if (key !== 'id') { + this.props.setCustomFormatSpecificationValue({ name: key, value }); + } + } + + this.props.saveCustomFormatSpecification(); + } + + parseFields = (fields, schema) => { + for (const [key, value] of Object.entries(fields)) { + const field = _.find(schema.fields, { name: key }); + if (!field) { + throw new Error(translate('CustomFormatUnknownConditionOption', [key, schema.implementationName])); + } + + this.props.setCustomFormatSpecificationFieldValue({ name: key, value }); + } + } + + // + // Render + + render() { + return ( + + ); + } +} + +ImportCustomFormatModalContentConnector.propTypes = { + specificationSchema: PropTypes.arrayOf(PropTypes.object).isRequired, + clearPendingChanges: PropTypes.func.isRequired, + deleteAllCustomFormatSpecification: PropTypes.func.isRequired, + clearCustomFormatSpecificationPending: PropTypes.func.isRequired, + saveCustomFormatSpecification: PropTypes.func.isRequired, + fetchCustomFormatSpecificationSchema: PropTypes.func.isRequired, + selectCustomFormatSpecificationSchema: PropTypes.func.isRequired, + setCustomFormatSpecificationValue: PropTypes.func.isRequired, + setCustomFormatSpecificationFieldValue: PropTypes.func.isRequired, + setCustomFormatValue: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ImportCustomFormatModalContentConnector); diff --git a/frontend/src/Store/Actions/Settings/customFormatSpecifications.js b/frontend/src/Store/Actions/Settings/customFormatSpecifications.js index 5f2675e7f..25e5cf595 100644 --- a/frontend/src/Store/Actions/Settings/customFormatSpecifications.js +++ b/frontend/src/Store/Actions/Settings/customFormatSpecifications.js @@ -27,6 +27,7 @@ export const SET_CUSTOM_FORMAT_SPECIFICATION_VALUE = 'settings/customFormatSpeci export const SET_CUSTOM_FORMAT_SPECIFICATION_FIELD_VALUE = 'settings/customFormatSpecifications/setCustomFormatSpecificationFieldValue'; export const SAVE_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/saveCustomFormatSpecification'; export const DELETE_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/deleteCustomFormatSpecification'; +export const DELETE_ALL_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/deleteCustomFormatSpecification'; export const CLONE_CUSTOM_FORMAT_SPECIFICATION = 'settings/customFormatSpecifications/cloneCustomFormatSpecification'; export const CLEAR_CUSTOM_FORMAT_SPECIFICATIONS = 'settings/customFormatSpecifications/clearCustomFormatSpecifications'; export const CLEAR_CUSTOM_FORMAT_SPECIFICATION_PENDING = 'settings/customFormatSpecifications/clearCustomFormatSpecificationPending'; @@ -39,6 +40,7 @@ export const selectCustomFormatSpecificationSchema = createAction(SELECT_CUSTOM_ export const saveCustomFormatSpecification = createThunk(SAVE_CUSTOM_FORMAT_SPECIFICATION); export const deleteCustomFormatSpecification = createThunk(DELETE_CUSTOM_FORMAT_SPECIFICATION); +export const deleteAllCustomFormatSpecification = createThunk(DELETE_ALL_CUSTOM_FORMAT_SPECIFICATION); export const setCustomFormatSpecificationValue = createAction(SET_CUSTOM_FORMAT_SPECIFICATION_VALUE, (payload) => { return { @@ -137,6 +139,13 @@ export default { return dispatch(removeItem({ section, id })); }, + [DELETE_ALL_CUSTOM_FORMAT_SPECIFICATION]: (getState, payload, dispatch) => { + return dispatch(set({ + section, + items: [] + })); + }, + [CLEAR_CUSTOM_FORMAT_SPECIFICATION_PENDING]: (getState, payload, dispatch) => { return dispatch(set({ section, diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 64d0b6744..e16a9c865 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -115,6 +115,9 @@ "CreateGroup": "Create group", "Crew": "Crew", "CustomFilters": "Custom Filters", + "CustomFormatJSON": "Custom Format JSON", + "CustomFormatUnknownCondition": "Unknown Custom Format condition '{0}'", + "CustomFormatUnknownConditionOption": "Unknown option '{0}' for condition '{1}'", "CustomFormats": "Custom Formats", "CustomFormatScore": "Custom Format score", "CustomFormatsSettings": "Custom Formats Settings", @@ -219,6 +222,7 @@ "ExcludeMovie": "Exclude Movie", "ExistingMovies": "Existing Movie(s)", "ExistingTag": "Existing tag", + "ExportCustomFormat": "Export Custom Format", "Extension": "Extension", "ExtraFileExtensionsHelpTexts1": "Comma separated list of extra files to import (.nfo will be imported as .nfo-orig)", "ExtraFileExtensionsHelpTexts2": "Examples: '.sub, .nfo' or 'sub,nfo'", @@ -281,6 +285,7 @@ "IgnoredPlaceHolder": "Add new restriction", "IllRestartLater": "I'll restart later", "Import": "Import", + "ImportCustomFormat": "Import Custom Format", "Imported": "Imported", "ImportedTo": "Imported To", "ImportExistingMovies": "Import Existing Movies",