diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index 9f3c03abf..40dd2656d 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -16,6 +16,8 @@ import IndexerFlag from 'typings/IndexerFlag'; import Notification from 'typings/Notification'; import QualityProfile from 'typings/QualityProfile'; import General from 'typings/Settings/General'; +import NamingConfig from 'typings/Settings/NamingConfig'; +import NamingExample from 'typings/Settings/NamingExample'; import ReleaseProfile from 'typings/Settings/ReleaseProfile'; import UiSettings from 'typings/Settings/UiSettings'; @@ -30,6 +32,13 @@ export interface GeneralAppState extends AppSectionItemState, AppSectionSaveState {} +export interface NamingAppState + extends AppSectionItemState, + AppSectionSaveState {} + +export interface NamingExamplesAppState + extends AppSectionItemState {} + export interface ImportListAppState extends AppSectionState, AppSectionDeleteState, @@ -88,6 +97,8 @@ interface SettingsAppState { indexerFlags: IndexerFlagSettingsAppState; indexers: IndexerAppState; languages: LanguageSettingsAppState; + naming: NamingAppState; + namingExamples: NamingExamplesAppState; notifications: NotificationAppState; qualityProfiles: QualityProfilesAppState; releaseProfiles: ReleaseProfilesAppState; diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js index 16a3669bd..dd1d0fd9b 100644 --- a/frontend/src/Settings/MediaManagement/MediaManagement.js +++ b/frontend/src/Settings/MediaManagement/MediaManagement.js @@ -13,7 +13,7 @@ import { inputTypes, kinds, sizes } from 'Helpers/Props'; import RootFolders from 'RootFolder/RootFolders'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import translate from 'Utilities/String/translate'; -import NamingConnector from './Naming/NamingConnector'; +import Naming from './Naming/Naming'; import AddRootFolder from './RootFolder/AddRootFolder'; const rescanAfterRefreshOptions = [ @@ -106,7 +106,7 @@ class MediaManagement extends Component { /> - + { isFetching ? diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.js b/frontend/src/Settings/MediaManagement/Naming/Naming.js deleted file mode 100644 index 350c07b4e..000000000 --- a/frontend/src/Settings/MediaManagement/Naming/Naming.js +++ /dev/null @@ -1,229 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Alert from 'Components/Alert'; -import FieldSet from 'Components/FieldSet'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputButton from 'Components/Form/FormInputButton'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import { inputTypes, kinds, sizes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import NamingModal from './NamingModal'; -import styles from './Naming.css'; - -class Naming extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isNamingModalOpen: false, - namingModalOptions: null - }; - } - - // - // Listeners - - onStandardNamingModalOpenClick = () => { - this.setState({ - isNamingModalOpen: true, - namingModalOptions: { - name: 'standardMovieFormat', - additional: true - } - }); - }; - - onMovieFolderNamingModalOpenClick = () => { - this.setState({ - isNamingModalOpen: true, - namingModalOptions: { - name: 'movieFolderFormat' - } - }); - }; - - onNamingModalClose = () => { - this.setState({ isNamingModalOpen: false }); - }; - - // - // Render - - render() { - const { - advancedSettings, - isFetching, - error, - settings, - hasSettings, - examples, - examplesPopulated, - onInputChange - } = this.props; - - const { - isNamingModalOpen, - namingModalOptions - } = this.state; - - const renameMovies = hasSettings && settings.renameMovies.value; - const replaceIllegalCharacters = hasSettings && settings.replaceIllegalCharacters.value; - - const colonReplacementOptions = [ - { key: 'delete', value: translate('Delete') }, - { key: 'dash', value: translate('ReplaceWithDash') }, - { key: 'spaceDash', value: translate('ReplaceWithSpaceDash') }, - { key: 'spaceDashSpace', value: translate('ReplaceWithSpaceDashSpace') }, - { key: 'smart', value: translate('SmartReplace'), hint: translate('SmartReplaceHint') } - ]; - - const standardMovieFormatHelpTexts = []; - const standardMovieFormatErrors = []; - const movieFolderFormatHelpTexts = []; - const movieFolderFormatErrors = []; - - if (examplesPopulated) { - if (examples.movieExample) { - standardMovieFormatHelpTexts.push(`${translate('Movie')}: ${examples.movieExample}`); - } else { - standardMovieFormatErrors.push({ message: translate('MovieInvalidFormat') }); - } - - if (examples.movieFolderExample) { - movieFolderFormatHelpTexts.push(`${translate('Example')}: ${examples.movieFolderExample}`); - } else { - movieFolderFormatErrors.push({ message: translate('InvalidFormat') }); - } - } - - return ( -
- { - isFetching && - - } - - { - !isFetching && error && - - {translate('NamingSettingsLoadError')} - - } - - { - hasSettings && !isFetching && !error && -
- - {translate('RenameMovies')} - - - - - - {translate('ReplaceIllegalCharacters')} - - - - - { - replaceIllegalCharacters && - - {translate('ColonReplacement')} - - - - } - - { - renameMovies && - - {translate('StandardMovieFormat')} - - ?} - onChange={onInputChange} - {...settings.standardMovieFormat} - helpTexts={standardMovieFormatHelpTexts} - errors={[...standardMovieFormatErrors, ...settings.standardMovieFormat.errors]} - /> - - } - - - {translate('MovieFolderFormat')} - - ?} - onChange={onInputChange} - {...settings.movieFolderFormat} - helpTexts={[translate('MovieFolderFormatHelpText'), ...movieFolderFormatHelpTexts]} - errors={[...movieFolderFormatErrors, ...settings.movieFolderFormat.errors]} - /> - - - { - namingModalOptions && - - } - - } -
- ); - } - -} - -Naming.propTypes = { - advancedSettings: PropTypes.bool.isRequired, - isFetching: PropTypes.bool.isRequired, - error: PropTypes.object, - settings: PropTypes.object.isRequired, - hasSettings: PropTypes.bool.isRequired, - examples: PropTypes.object.isRequired, - examplesPopulated: PropTypes.bool.isRequired, - onInputChange: PropTypes.func.isRequired -}; - -export default Naming; diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.tsx b/frontend/src/Settings/MediaManagement/Naming/Naming.tsx new file mode 100644 index 000000000..329eddbf0 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/Naming.tsx @@ -0,0 +1,274 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import Alert from 'Components/Alert'; +import FieldSet from 'Components/FieldSet'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputButton from 'Components/Form/FormInputButton'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import useModalOpenState from 'Helpers/Hooks/useModalOpenState'; +import { inputTypes, kinds, sizes } from 'Helpers/Props'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import { + fetchNamingExamples, + fetchNamingSettings, + setNamingSettingsValue, +} from 'Store/Actions/settingsActions'; +import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; +import NamingConfig from 'typings/Settings/NamingConfig'; +import translate from 'Utilities/String/translate'; +import NamingModal from './NamingModal'; +import styles from './Naming.css'; + +const SECTION = 'naming'; + +function createNamingSelector() { + return createSelector( + (state: AppState) => state.settings.advancedSettings, + (state: AppState) => state.settings.namingExamples, + createSettingsSectionSelector(SECTION), + (advancedSettings, namingExamples, sectionSettings) => { + return { + advancedSettings, + examples: namingExamples.item, + examplesPopulated: namingExamples.isPopulated, + ...sectionSettings, + }; + } + ); +} + +interface NamingModalOptions { + name: keyof Pick; + movie?: boolean; + additional?: boolean; +} + +function Naming() { + const { + advancedSettings, + isFetching, + error, + settings, + hasSettings, + examples, + examplesPopulated, + } = useSelector(createNamingSelector()); + + const dispatch = useDispatch(); + + const [isNamingModalOpen, setNamingModalOpen, setNamingModalClosed] = + useModalOpenState(false); + const [namingModalOptions, setNamingModalOptions] = + useState(null); + const namingExampleTimeout = useRef>(); + + useEffect(() => { + dispatch(fetchNamingSettings()); + dispatch(fetchNamingExamples()); + + return () => { + dispatch(clearPendingChanges({ section: SECTION })); + }; + }, [dispatch]); + + const handleInputChange = useCallback( + ({ name, value }: { name: string; value: string }) => { + // @ts-expect-error 'setNamingSettingsValue' isn't typed yet + dispatch(setNamingSettingsValue({ name, value })); + + if (namingExampleTimeout.current) { + clearTimeout(namingExampleTimeout.current); + } + + namingExampleTimeout.current = setTimeout(() => { + dispatch(fetchNamingExamples()); + }, 1000); + }, + [dispatch] + ); + + const onStandardNamingModalOpenClick = useCallback(() => { + setNamingModalOpen(); + + setNamingModalOptions({ + name: 'standardMovieFormat', + movie: true, + additional: true, + }); + }, [setNamingModalOpen, setNamingModalOptions]); + + const onMovieFolderNamingModalOpenClick = useCallback(() => { + setNamingModalOpen(); + + setNamingModalOptions({ + name: 'movieFolderFormat', + }); + }, [setNamingModalOpen, setNamingModalOptions]); + + const renameMovies = hasSettings && settings.renameMovies.value; + const replaceIllegalCharacters = + hasSettings && settings.replaceIllegalCharacters.value; + + const colonReplacementOptions = [ + { key: 'delete', value: translate('Delete') }, + { key: 'dash', value: translate('ReplaceWithDash') }, + { key: 'spaceDash', value: translate('ReplaceWithSpaceDash') }, + { key: 'spaceDashSpace', value: translate('ReplaceWithSpaceDashSpace') }, + { + key: 'smart', + value: translate('SmartReplace'), + hint: translate('SmartReplaceHint'), + }, + ]; + + const standardMovieFormatHelpTexts = []; + const standardMovieFormatErrors = []; + const movieFolderFormatHelpTexts = []; + const movieFolderFormatErrors = []; + + if (examplesPopulated) { + if (examples.movieExample) { + standardMovieFormatHelpTexts.push( + `${translate('Movie')}: ${examples.movieExample}` + ); + } else { + standardMovieFormatErrors.push({ + message: translate('MovieInvalidFormat'), + }); + } + + if (examples.movieFolderExample) { + movieFolderFormatHelpTexts.push( + `${translate('Example')}: ${examples.movieFolderExample}` + ); + } else { + movieFolderFormatErrors.push({ message: translate('InvalidFormat') }); + } + } + + return ( +
+ {isFetching ? : null} + + {!isFetching && error ? ( + + {translate('NamingSettingsLoadError')} + + ) : null} + + {hasSettings && !isFetching && !error ? ( +
+ + {translate('RenameMovies')} + + + + + + {translate('ReplaceIllegalCharacters')} + + + + + {replaceIllegalCharacters ? ( + + {translate('ColonReplacement')} + + + + ) : null} + + {renameMovies ? ( + + {translate('StandardMovieFormat')} + + + ? + + } + onChange={handleInputChange} + {...settings.standardMovieFormat} + helpTexts={standardMovieFormatHelpTexts} + errors={[ + ...standardMovieFormatErrors, + ...settings.standardMovieFormat.errors, + ]} + /> + + ) : null} + + + {translate('MovieFolderFormat')} + + + ? + + } + onChange={handleInputChange} + {...settings.movieFolderFormat} + helpTexts={[ + translate('MovieFolderFormatHelpText'), + ...movieFolderFormatHelpTexts, + ]} + errors={[ + ...movieFolderFormatErrors, + ...settings.movieFolderFormat.errors, + ]} + /> + + + {namingModalOptions ? ( + + ) : null} + + ) : null} +
+ ); +} + +export default Naming; diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js b/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js deleted file mode 100644 index 55c3bc597..000000000 --- a/frontend/src/Settings/MediaManagement/Naming/NamingConnector.js +++ /dev/null @@ -1,96 +0,0 @@ -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 { fetchNamingExamples, fetchNamingSettings, setNamingSettingsValue } from 'Store/Actions/settingsActions'; -import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector'; -import Naming from './Naming'; - -const SECTION = 'naming'; - -function createMapStateToProps() { - return createSelector( - (state) => state.settings.advancedSettings, - (state) => state.settings.namingExamples, - createSettingsSectionSelector(SECTION), - (advancedSettings, namingExamples, sectionSettings) => { - return { - advancedSettings, - examples: namingExamples.item, - examplesPopulated: namingExamples.isPopulated, - ...sectionSettings - }; - } - ); -} - -const mapDispatchToProps = { - fetchNamingSettings, - setNamingSettingsValue, - fetchNamingExamples, - clearPendingChanges -}; - -class NamingConnector extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._namingExampleTimeout = null; - } - - componentDidMount() { - this.props.fetchNamingSettings(); - this.props.fetchNamingExamples(); - } - - componentWillUnmount() { - this.props.clearPendingChanges({ section: `settings.${SECTION}` }); - } - - // - // Control - - _fetchNamingExamples = () => { - this.props.fetchNamingExamples(); - }; - - // - // Listeners - - onInputChange = ({ name, value }) => { - this.props.setNamingSettingsValue({ name, value }); - - if (this._namingExampleTimeout) { - clearTimeout(this._namingExampleTimeout); - } - - this._namingExampleTimeout = setTimeout(this._fetchNamingExamples, 1000); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -NamingConnector.propTypes = { - fetchNamingSettings: PropTypes.func.isRequired, - setNamingSettingsValue: PropTypes.func.isRequired, - fetchNamingExamples: PropTypes.func.isRequired, - clearPendingChanges: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(NamingConnector); diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js deleted file mode 100644 index 9917c2caa..000000000 --- a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js +++ /dev/null @@ -1,508 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import FieldSet from 'Components/FieldSet'; -import SelectInput from 'Components/Form/SelectInput'; -import TextInput from 'Components/Form/TextInput'; -import Icon from 'Components/Icon'; -import Button from 'Components/Link/Button'; -import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; -import Modal from 'Components/Modal/Modal'; -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, sizes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import NamingOption from './NamingOption'; -import styles from './NamingModal.css'; - -const separatorOptions = [ - { - key: ' ', - get value() { - return `${translate('Space')} ( )`; - } - }, - { - key: '.', - get value() { - return `${translate('Period')} (.)`; - } - }, - { - key: '_', - get value() { - return `${translate('Underscore')} (_)`; - } - }, - { - key: '-', - get value() { - return `${translate('Dash')} (-)`; - } - } -]; - -const caseOptions = [ - { - key: 'title', - get value() { - return translate('DefaultCase'); - } - }, - { - key: 'lower', - get value() { - return translate('Lowercase'); - } - }, - { - key: 'upper', - get value() { - return translate('Uppercase'); - } - } -]; - -const fileNameTokens = [ - { - token: '{Movie Title} - {Quality Full}', - example: 'Movie Title (2010) - HDTV-720p Proper' - } -]; - -const movieTokens = [ - { token: '{Movie Title}', example: 'Movie\'s Title', footNote: 1 }, - { token: '{Movie Title:DE}', example: 'Titel des Films', footNote: 1 }, - { token: '{Movie CleanTitle}', example: 'Movies Title', footNote: 1 }, - { token: '{Movie CleanTitle:DE}', example: 'Titel des Films', footNote: 1 }, - { token: '{Movie TitleThe}', example: 'Movie\'s Title, The', footNote: 1 }, - { token: '{Movie CleanTitleThe}', example: 'Movies Title, The', footNote: 1 }, - { token: '{Movie OriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 }, - { token: '{Movie CleanOriginalTitle}', example: 'Τίτλος ταινίας', footNote: 1 }, - { token: '{Movie TitleFirstCharacter}', example: 'M' }, - { token: '{Movie TitleFirstCharacter:DE}', example: 'T' }, - { token: '{Movie Collection}', example: 'The Movie Collection', footNote: 1 }, - { token: '{Movie Certification}', example: 'R' }, - { token: '{Release Year}', example: '2009' } -]; - -const movieIdTokens = [ - { token: '{ImdbId}', example: 'tt12345' }, - { token: '{TmdbId}', example: '123456' } -]; - -const qualityTokens = [ - { token: '{Quality Full}', example: 'HDTV-720p Proper' }, - { token: '{Quality Title}', example: 'HDTV-720p' } -]; - -const mediaInfoTokens = [ - { token: '{MediaInfo Simple}', example: 'x264 DTS' }, - { token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]', footNote: 1 }, - - { token: '{MediaInfo AudioCodec}', example: 'DTS' }, - { token: '{MediaInfo AudioChannels}', example: '5.1' }, - { token: '{MediaInfo AudioLanguages}', example: '[EN+DE]', footNote: 1 }, - { token: '{MediaInfo SubtitleLanguages}', example: '[DE]', footNote: 1 }, - - { token: '{MediaInfo VideoCodec}', example: 'x264' }, - { token: '{MediaInfo VideoBitDepth}', example: '10' }, - { token: '{MediaInfo VideoDynamicRange}', example: 'HDR' }, - { token: '{MediaInfo VideoDynamicRangeType}', example: 'DV HDR10' }, - { token: '{MediaInfo 3D}', example: '3D' } -]; - -const releaseGroupTokens = [ - { token: '{Release Group}', example: 'Rls Grp', footNote: 1 } -]; - -const editionTokens = [ - { token: '{Edition Tags}', example: 'IMAX', footNote: 1 } -]; - -const customFormatTokens = [ - { token: '{Custom Formats}', example: 'Surround Sound x264' }, - { token: '{Custom Format:FormatName}', example: 'AMZN' } -]; - -const originalTokens = [ - { token: '{Original Title}', example: 'Movie.Title.HDTV.x264-EVOLVE' }, - { token: '{Original Filename}', example: 'movie title hdtv.x264-Evolve' } -]; - -class NamingModal extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._selectionStart = null; - this._selectionEnd = null; - - this.state = { - separator: ' ', - case: 'title' - }; - } - - // - // Listeners - - onTokenSeparatorChange = (event) => { - this.setState({ separator: event.value }); - }; - - onTokenCaseChange = (event) => { - this.setState({ case: event.value }); - }; - - onInputSelectionChange = (selectionStart, selectionEnd) => { - this._selectionStart = selectionStart; - this._selectionEnd = selectionEnd; - }; - - onOptionPress = ({ isFullFilename, tokenValue }) => { - const { - name, - value, - onInputChange - } = this.props; - - const selectionStart = this._selectionStart; - const selectionEnd = this._selectionEnd; - - if (isFullFilename) { - onInputChange({ name, value: tokenValue }); - } else if (selectionStart == null) { - onInputChange({ - name, - value: `${value}${tokenValue}` - }); - } else { - const start = value.substring(0, selectionStart); - const end = value.substring(selectionEnd); - const newValue = `${start}${tokenValue}${end}`; - - onInputChange({ name, value: newValue }); - this._selectionStart = newValue.length - 1; - this._selectionEnd = newValue.length - 1; - } - }; - - // - // Render - - render() { - const { - name, - value, - isOpen, - advancedSettings, - additional, - onInputChange, - onModalClose - } = this.props; - - const { - separator: tokenSeparator, - case: tokenCase - } = this.state; - - return ( - - - - {translate('FileNameTokens')} - - - -
- - - -
- - { - !advancedSettings && -
-
- { - fileNameTokens.map(({ token, example }) => { - return ( - - ); - } - ) - } -
-
- } - -
-
- { - movieTokens.map(({ token, example, footNote }) => { - return ( - - ); - } - ) - } -
- -
- - -
-
- -
-
- { - movieIdTokens.map(({ token, example }) => { - return ( - - ); - } - ) - } -
-
- - { - additional && -
-
-
- { - qualityTokens.map(({ token, example }) => { - return ( - - ); - } - ) - } -
-
- -
-
- { - mediaInfoTokens.map(({ token, example, footNote }) => { - return ( - - ); - } - ) - } -
- -
- - -
-
- -
-
- { - releaseGroupTokens.map(({ token, example, footNote }) => { - return ( - - ); - } - ) - } -
- -
- - -
-
- -
-
- { - editionTokens.map(({ token, example, footNote }) => { - return ( - - ); - } - ) - } -
- -
- - -
-
- -
-
- { - customFormatTokens.map(({ token, example }) => { - return ( - - ); - } - ) - } -
-
- -
-
- { - originalTokens.map(({ token, example }) => { - return ( - - ); - } - ) - } -
-
-
- } -
- - - - - -
-
- ); - } -} - -NamingModal.propTypes = { - name: PropTypes.string.isRequired, - value: PropTypes.string.isRequired, - isOpen: PropTypes.bool.isRequired, - advancedSettings: PropTypes.bool.isRequired, - additional: PropTypes.bool.isRequired, - onInputChange: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -NamingModal.defaultProps = { - additional: false -}; - -export default NamingModal; diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.tsx b/frontend/src/Settings/MediaManagement/Naming/NamingModal.tsx new file mode 100644 index 000000000..bc189d521 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.tsx @@ -0,0 +1,457 @@ +import React, { useCallback, useState } from 'react'; +import FieldSet from 'Components/FieldSet'; +import SelectInput from 'Components/Form/SelectInput'; +import TextInput from 'Components/Form/TextInput'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import InlineMarkdown from 'Components/Markdown/InlineMarkdown'; +import Modal from 'Components/Modal/Modal'; +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, sizes } from 'Helpers/Props'; +import NamingConfig from 'typings/Settings/NamingConfig'; +import translate from 'Utilities/String/translate'; +import NamingOption from './NamingOption'; +import TokenCase from './TokenCase'; +import TokenSeparator from './TokenSeparator'; +import styles from './NamingModal.css'; + +const separatorOptions: { key: TokenSeparator; value: string }[] = [ + { + key: ' ', + get value() { + return `${translate('Space')} ( )`; + }, + }, + { + key: '.', + get value() { + return `${translate('Period')} (.)`; + }, + }, + { + key: '_', + get value() { + return `${translate('Underscore')} (_)`; + }, + }, + { + key: '-', + get value() { + return `${translate('Dash')} (-)`; + }, + }, +]; + +const caseOptions: { key: TokenCase; value: string }[] = [ + { + key: 'title', + get value() { + return translate('DefaultCase'); + }, + }, + { + key: 'lower', + get value() { + return translate('Lowercase'); + }, + }, + { + key: 'upper', + get value() { + return translate('Uppercase'); + }, + }, +]; + +const fileNameTokens = [ + { + token: '{Movie Title} - {Quality Full}', + example: 'Movie Title (2010) - HDTV-720p Proper', + }, +]; + +const movieTokens = [ + { token: '{Movie Title}', example: "Movie's Title", footNote: true }, + { token: '{Movie Title:DE}', example: 'Titel des Films', footNote: true }, + { token: '{Movie CleanTitle}', example: 'Movies Title', footNote: true }, + { + token: '{Movie CleanTitle:DE}', + example: 'Titel des Films', + footNote: true, + }, + { token: '{Movie TitleThe}', example: "Movie's Title, The", footNote: true }, + { + token: '{Movie CleanTitleThe}', + example: 'Movies Title, The', + footNote: true, + }, + { token: '{Movie OriginalTitle}', example: 'Τίτλος ταινίας', footNote: true }, + { + token: '{Movie CleanOriginalTitle}', + example: 'Τίτλος ταινίας', + footNote: true, + }, + { token: '{Movie TitleFirstCharacter}', example: 'M' }, + { token: '{Movie TitleFirstCharacter:DE}', example: 'T' }, + { + token: '{Movie Collection}', + example: 'The Movie Collection', + footNote: true, + }, + { token: '{Movie Certification}', example: 'R' }, + { token: '{Release Year}', example: '2009' }, +]; + +const movieIdTokens = [ + { token: '{ImdbId}', example: 'tt12345' }, + { token: '{TmdbId}', example: '123456' }, +]; + +const qualityTokens = [ + { token: '{Quality Full}', example: 'HDTV-720p Proper' }, + { token: '{Quality Title}', example: 'HDTV-720p' }, +]; + +const mediaInfoTokens = [ + { token: '{MediaInfo Simple}', example: 'x264 DTS' }, + { token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]', footNote: true }, + + { token: '{MediaInfo AudioCodec}', example: 'DTS' }, + { token: '{MediaInfo AudioChannels}', example: '5.1' }, + { token: '{MediaInfo AudioLanguages}', example: '[EN+DE]', footNote: true }, + { token: '{MediaInfo SubtitleLanguages}', example: '[DE]', footNote: true }, + + { token: '{MediaInfo VideoCodec}', example: 'x264' }, + { token: '{MediaInfo VideoBitDepth}', example: '10' }, + { token: '{MediaInfo VideoDynamicRange}', example: 'HDR' }, + { token: '{MediaInfo VideoDynamicRangeType}', example: 'DV HDR10' }, + { token: '{MediaInfo 3D}', example: '3D' }, +]; + +const releaseGroupTokens = [ + { token: '{Release Group}', example: 'Rls Grp', footNote: true }, +]; + +const editionTokens = [ + { token: '{Edition Tags}', example: 'IMAX', footNote: true }, +]; + +const customFormatTokens = [ + { token: '{Custom Formats}', example: 'Surround Sound x264' }, + { token: '{Custom Format:FormatName}', example: 'AMZN' }, +]; + +const originalTokens = [ + { token: '{Original Title}', example: 'Movie.Title.HDTV.x264-EVOLVE' }, + { token: '{Original Filename}', example: 'movie title hdtv.x264-Evolve' }, +]; + +interface NamingModalProps { + isOpen: boolean; + name: keyof Pick; + value: string; + advancedSettings: boolean; + movie?: boolean; + additional?: boolean; + onInputChange: ({ name, value }: { name: string; value: string }) => void; + onModalClose: () => void; +} + +function NamingModal(props: NamingModalProps) { + const { + isOpen, + name, + value, + advancedSettings, + movie = false, + additional = false, + onInputChange, + onModalClose, + } = props; + + const [tokenSeparator, setTokenSeparator] = useState(' '); + const [tokenCase, setTokenCase] = useState('title'); + const [selectionStart, setSelectionStart] = useState(null); + const [selectionEnd, setSelectionEnd] = useState(null); + + const handleTokenSeparatorChange = useCallback( + ({ value }: { value: TokenSeparator }) => { + setTokenSeparator(value); + }, + [setTokenSeparator] + ); + + const handleTokenCaseChange = useCallback( + ({ value }: { value: TokenCase }) => { + setTokenCase(value); + }, + [setTokenCase] + ); + + const handleInputSelectionChange = useCallback( + (selectionStart: number, selectionEnd: number) => { + setSelectionStart(selectionStart); + setSelectionEnd(selectionEnd); + }, + [setSelectionStart, setSelectionEnd] + ); + + const handleOptionPress = useCallback( + ({ + isFullFilename, + tokenValue, + }: { + isFullFilename: boolean; + tokenValue: string; + }) => { + if (isFullFilename) { + onInputChange({ name, value: tokenValue }); + } else if (selectionStart == null || selectionEnd == null) { + onInputChange({ + name, + value: `${value}${tokenValue}`, + }); + } else { + const start = value.substring(0, selectionStart); + const end = value.substring(selectionEnd); + const newValue = `${start}${tokenValue}${end}`; + + onInputChange({ name, value: newValue }); + + setSelectionStart(newValue.length - 1); + setSelectionEnd(newValue.length - 1); + } + }, + [name, value, selectionEnd, selectionStart, onInputChange] + ); + + return ( + + + + {movie ? translate('FileNameTokens') : translate('FolderNameTokens')} + + + +
+ + + +
+ + {advancedSettings ? null : ( +
+
+ {fileNameTokens.map(({ token, example }) => ( + + ))} +
+
+ )} + +
+
+ {movieTokens.map(({ token, example, footNote }) => { + return ( + + ); + })} +
+ +
+ + +
+
+ +
+
+ {movieIdTokens.map(({ token, example }) => { + return ( + + ); + })} +
+
+ + {additional ? ( +
+
+
+ {qualityTokens.map(({ token, example }) => { + return ( + + ); + })} +
+
+ +
+
+ {mediaInfoTokens.map(({ token, example, footNote }) => { + return ( + + ); + })} +
+ +
+ + +
+
+ +
+
+ {releaseGroupTokens.map(({ token, example, footNote }) => { + return ( + + ); + })} +
+ +
+ + +
+
+ +
+
+ {editionTokens.map(({ token, example, footNote }) => { + return ( + + ); + })} +
+ +
+ + +
+
+ +
+
+ {customFormatTokens.map(({ token, example }) => { + return ( + + ); + })} +
+
+ +
+
+ {originalTokens.map(({ token, example }) => { + return ( + + ); + })} +
+
+
+ ) : null} +
+ + + + + + +
+
+ ); +} + +export default NamingModal; diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.css b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css index a891a5ddd..c74498997 100644 --- a/frontend/src/Settings/MediaManagement/Naming/NamingOption.css +++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css @@ -46,6 +46,10 @@ } } +.title { + text-transform: none; +} + .lower { text-transform: lowercase; } diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.css.d.ts b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css.d.ts index a060f6218..5c50bfab2 100644 --- a/frontend/src/Settings/MediaManagement/Naming/NamingOption.css.d.ts +++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.css.d.ts @@ -8,6 +8,7 @@ interface CssExports { 'lower': string; 'option': string; 'small': string; + 'title': string; 'token': string; 'upper': string; } diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.js b/frontend/src/Settings/MediaManagement/Naming/NamingOption.js deleted file mode 100644 index 6373c11e3..000000000 --- a/frontend/src/Settings/MediaManagement/Naming/NamingOption.js +++ /dev/null @@ -1,93 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import { icons, sizes } from 'Helpers/Props'; -import styles from './NamingOption.css'; - -class NamingOption extends Component { - - // - // Listeners - - onPress = () => { - const { - token, - tokenSeparator, - tokenCase, - isFullFilename, - onPress - } = this.props; - - let tokenValue = token; - - tokenValue = tokenValue.replace(/ /g, tokenSeparator); - - if (tokenCase === 'lower') { - tokenValue = token.toLowerCase(); - } else if (tokenCase === 'upper') { - tokenValue = token.toUpperCase(); - } - - onPress({ isFullFilename, tokenValue }); - }; - - // - // Render - render() { - const { - token, - tokenSeparator, - example, - footNote, - tokenCase, - isFullFilename, - size - } = this.props; - - return ( - -
- {token.replace(/ /g, tokenSeparator)} -
- -
- {example.replace(/ /g, tokenSeparator)} - - { - footNote !== 0 && - - } -
- - ); - } -} - -NamingOption.propTypes = { - token: PropTypes.string.isRequired, - example: PropTypes.string.isRequired, - footNote: PropTypes.number.isRequired, - tokenSeparator: PropTypes.string.isRequired, - tokenCase: PropTypes.string.isRequired, - isFullFilename: PropTypes.bool.isRequired, - size: PropTypes.oneOf([sizes.SMALL, sizes.LARGE]), - onPress: PropTypes.func.isRequired -}; - -NamingOption.defaultProps = { - footNote: 0, - size: sizes.SMALL, - isFullFilename: false -}; - -export default NamingOption; diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingOption.tsx b/frontend/src/Settings/MediaManagement/Naming/NamingOption.tsx new file mode 100644 index 000000000..e9bcf11ff --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/NamingOption.tsx @@ -0,0 +1,77 @@ +import classNames from 'classnames'; +import React, { useCallback } from 'react'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import { icons } from 'Helpers/Props'; +import { Size } from 'Helpers/Props/sizes'; +import TokenCase from './TokenCase'; +import TokenSeparator from './TokenSeparator'; +import styles from './NamingOption.css'; + +interface NamingOptionProps { + token: string; + tokenSeparator: TokenSeparator; + example: string; + tokenCase: TokenCase; + isFullFilename?: boolean; + footNote?: boolean; + size?: Extract; + onPress: ({ + isFullFilename, + tokenValue, + }: { + isFullFilename: boolean; + tokenValue: string; + }) => void; +} + +function NamingOption(props: NamingOptionProps) { + const { + token, + tokenSeparator, + example, + tokenCase, + isFullFilename = false, + footNote = false, + size = 'small', + onPress, + } = props; + + const handlePress = useCallback(() => { + let tokenValue = token; + + tokenValue = tokenValue.replace(/ /g, tokenSeparator); + + if (tokenCase === 'lower') { + tokenValue = token.toLowerCase(); + } else if (tokenCase === 'upper') { + tokenValue = token.toUpperCase(); + } + + onPress({ isFullFilename, tokenValue }); + }, [token, tokenCase, tokenSeparator, isFullFilename, onPress]); + + return ( + +
{token.replace(/ /g, tokenSeparator)}
+ +
+ {example.replace(/ /g, tokenSeparator)} + + {footNote ? ( + + ) : null} +
+ + ); +} + +export default NamingOption; diff --git a/frontend/src/Settings/MediaManagement/Naming/TokenCase.ts b/frontend/src/Settings/MediaManagement/Naming/TokenCase.ts new file mode 100644 index 000000000..280ef307d --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/TokenCase.ts @@ -0,0 +1,3 @@ +type TokenCase = 'title' | 'lower' | 'upper'; + +export default TokenCase; diff --git a/frontend/src/Settings/MediaManagement/Naming/TokenSeparator.ts b/frontend/src/Settings/MediaManagement/Naming/TokenSeparator.ts new file mode 100644 index 000000000..5ef86a6a1 --- /dev/null +++ b/frontend/src/Settings/MediaManagement/Naming/TokenSeparator.ts @@ -0,0 +1,3 @@ +type TokenSeparator = ' ' | '.' | '_' | '-'; + +export default TokenSeparator; diff --git a/frontend/src/typings/Settings/NamingConfig.ts b/frontend/src/typings/Settings/NamingConfig.ts new file mode 100644 index 000000000..054208395 --- /dev/null +++ b/frontend/src/typings/Settings/NamingConfig.ts @@ -0,0 +1,14 @@ +type ColonReplacementFormat = + | 'delete' + | 'dash' + | 'spaceDash' + | 'spaceDashSpace' + | 'smart'; + +export default interface NamingConfig { + renameMovies: boolean; + replaceIllegalCharacters: boolean; + colonReplacementFormat: ColonReplacementFormat; + standardMovieFormat: string; + movieFolderFormat: string; +} diff --git a/frontend/src/typings/Settings/NamingExample.ts b/frontend/src/typings/Settings/NamingExample.ts new file mode 100644 index 000000000..8f738362c --- /dev/null +++ b/frontend/src/typings/Settings/NamingExample.ts @@ -0,0 +1,4 @@ +export default interface NamingExample { + movieExample: string; + movieFolderExample: string; +} diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index a3beafbbd..ae7a54cc5 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -641,6 +641,7 @@ "FocusSearchBox": "Focus Search Box", "Folder": "Folder", "FolderMoveRenameWarning": "This will also rename the movie folder per the movie folder format in settings.", + "FolderNameTokens": "Folder Name Tokens", "Folders": "Folders", "FollowPerson": "Follow Person", "Forecast": "Forecast",