diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index 20f8a6ad6..6960db2f9 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -1,14 +1,33 @@ import AppSectionState, { AppSectionDeleteState, + AppSectionSaveState, AppSectionSchemaState, } from 'App/State/AppSectionState'; import Language from 'Language/Language'; import DownloadClient from 'typings/DownloadClient'; +import ImportList from 'typings/ImportList'; +import Indexer from 'typings/Indexer'; +import Notification from 'typings/Notification'; import QualityProfile from 'typings/QualityProfile'; import { UiSettings } from 'typings/UiSettings'; export interface DownloadClientAppState extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +export interface ImportListAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +export interface IndexerAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState {} + +export interface NotificationAppState + extends AppSectionState, AppSectionDeleteState {} export interface QualityProfilesAppState @@ -20,6 +39,9 @@ export type UiSettingsAppState = AppSectionState; interface SettingsAppState { downloadClients: DownloadClientAppState; + importLists: ImportListAppState; + indexers: IndexerAppState; + notifications: NotificationAppState; language: LanguageSettingsAppState; uiSettings: UiSettingsAppState; qualityProfiles: QualityProfilesAppState; diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index 074a1a477..c51b37f08 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -265,6 +265,8 @@ FormInputGroup.propTypes = { values: PropTypes.arrayOf(PropTypes.any), type: PropTypes.string.isRequired, kind: PropTypes.oneOf(kinds.all), + min: PropTypes.number, + max: PropTypes.number, unit: PropTypes.string, buttons: PropTypes.oneOfType([PropTypes.node, PropTypes.arrayOf(PropTypes.node)]), helpText: PropTypes.string, diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 7a0ac6318..a309fa03f 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -71,6 +71,7 @@ import { faLanguage as fasLanguage, faLaptop as fasLaptop, faLevelUpAlt as fasLevelUpAlt, + faListCheck as fasListCheck, faMedkit as fasMedkit, faMinus as fasMinus, faPause as fasPause, @@ -172,6 +173,7 @@ export const INFO = fasInfoCircle; export const INTERACTIVE = fasUser; export const KEYBOARD = farKeyboard; export const LOGOUT = fasSignOutAlt; +export const MANAGE = fasListCheck; export const MEDIA_INFO = farFileInvoice; export const MISSING = fasExclamationTriangle; export const MONITORED = fasBookmark; diff --git a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js index 12347e0c0..285ec15a0 100644 --- a/frontend/src/Settings/DownloadClients/DownloadClientSettings.js +++ b/frontend/src/Settings/DownloadClients/DownloadClientSettings.js @@ -8,6 +8,7 @@ import { icons } from 'Helpers/Props'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import translate from 'Utilities/String/translate'; import DownloadClientsConnector from './DownloadClients/DownloadClientsConnector'; +import ManageDownloadClientsModal from './DownloadClients/Manage/ManageDownloadClientsModal'; import DownloadClientOptionsConnector from './Options/DownloadClientOptionsConnector'; import RemotePathMappingsConnector from './RemotePathMappings/RemotePathMappingsConnector'; @@ -23,7 +24,8 @@ class DownloadClientSettings extends Component { this.state = { isSaving: false, - hasPendingChanges: false + hasPendingChanges: false, + isManageDownloadClientsOpen: false }; } @@ -38,6 +40,14 @@ class DownloadClientSettings extends Component { this.setState(payload); }; + onManageDownloadClientsPress = () => { + this.setState({ isManageDownloadClientsOpen: true }); + }; + + onManageDownloadClientsModalClose = () => { + this.setState({ isManageDownloadClientsOpen: false }); + }; + onSavePress = () => { if (this._saveCallback) { this._saveCallback(); @@ -55,7 +65,8 @@ class DownloadClientSettings extends Component { const { isSaving, - hasPendingChanges + hasPendingChanges, + isManageDownloadClientsOpen } = this.state; return ( @@ -73,6 +84,12 @@ class DownloadClientSettings extends Component { isSpinning={isTestingAll} onPress={dispatchTestAllDownloadClients} /> + + } onSavePress={this.onSavePress} @@ -87,6 +104,11 @@ class DownloadClientSettings extends Component { /> + + ); diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModal.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModal.tsx new file mode 100644 index 000000000..549a091ff --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModal.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageDownloadClientsEditModalContent from './ManageDownloadClientsEditModalContent'; + +interface ManageDownloadClientsEditModalProps { + isOpen: boolean; + downloadClientIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +function ManageDownloadClientsEditModal( + props: ManageDownloadClientsEditModalProps +) { + const { isOpen, downloadClientIds, onSavePress, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageDownloadClientsEditModal; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css new file mode 100644 index 000000000..ea406894e --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css @@ -0,0 +1,16 @@ +.modalFooter { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + justify-content: space-between; +} + +.selected { + font-weight: bold; +} + +@media only screen and (max-width: $breakpointExtraSmall) { + .modalFooter { + flex-direction: column; + gap: 10px; + } +} diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css.d.ts b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css.d.ts new file mode 100644 index 000000000..cbf2d6328 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'modalFooter': string; + 'selected': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx new file mode 100644 index 000000000..369a12b1a --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/Edit/ManageDownloadClientsEditModalContent.tsx @@ -0,0 +1,180 @@ +import React, { useCallback, useState } from 'react'; +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 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 } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './ManageDownloadClientsEditModalContent.css'; + +interface SavePayload { + enable?: boolean; + removeCompletedDownloads?: boolean; + removeFailedDownloads?: boolean; + priority?: number; +} + +interface ManageDownloadClientsEditModalContentProps { + downloadClientIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +const NO_CHANGE = 'noChange'; + +const enableOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'enabled', value: 'Enabled' }, + { key: 'disabled', value: 'Disabled' }, +]; + +function ManageDownloadClientsEditModalContent( + props: ManageDownloadClientsEditModalContentProps +) { + const { downloadClientIds, onSavePress, onModalClose } = props; + + const [enable, setEnable] = useState(NO_CHANGE); + const [removeCompletedDownloads, setRemoveCompletedDownloads] = + useState(NO_CHANGE); + const [removeFailedDownloads, setRemoveFailedDownloads] = useState(NO_CHANGE); + const [priority, setPriority] = useState(NO_CHANGE); + + const save = useCallback(() => { + let hasChanges = false; + const payload: SavePayload = {}; + + if (enable !== NO_CHANGE) { + hasChanges = true; + payload.enable = enable === 'enabled'; + } + + if (removeCompletedDownloads !== NO_CHANGE) { + hasChanges = true; + payload.removeCompletedDownloads = removeCompletedDownloads === 'enabled'; + } + + if (removeFailedDownloads !== NO_CHANGE) { + hasChanges = true; + payload.removeFailedDownloads = removeFailedDownloads === 'enabled'; + } + + if (priority !== NO_CHANGE) { + hasChanges = true; + payload.priority = priority as number; + } + + if (hasChanges) { + onSavePress(payload); + } + + onModalClose(); + }, [ + enable, + priority, + removeCompletedDownloads, + removeFailedDownloads, + onSavePress, + onModalClose, + ]); + + const onInputChange = useCallback( + ({ name, value }: { name: string; value: string }) => { + switch (name) { + case 'enable': + setEnable(value); + break; + case 'priority': + setPriority(value); + break; + case 'removeCompletedDownloads': + setRemoveCompletedDownloads(value); + break; + case 'removeFailedDownloads': + setRemoveFailedDownloads(value); + break; + default: + console.warn('EditDownloadClientsModalContent Unknown Input'); + } + }, + [] + ); + + const selectedCount = downloadClientIds.length; + + return ( + + {translate('EditSelectedDownloadClients')} + + + + {translate('Enabled')} + + + + + + {translate('Priority')} + + + + + + {translate('RemoveCompletedDownloads')} + + + + + + {translate('RemoveFailedDownloads')} + + + + + + +
+ {translate('{count} download clients selected', { + count: selectedCount, + })} +
+ +
+ + + +
+
+
+ ); +} + +export default ManageDownloadClientsEditModalContent; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModal.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModal.tsx new file mode 100644 index 000000000..0302f3544 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageDownloadClientsModalContent from './ManageDownloadClientsModalContent'; + +interface ManageDownloadClientsModalProps { + isOpen: boolean; + onModalClose(): void; +} + +function ManageDownloadClientsModal(props: ManageDownloadClientsModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageDownloadClientsModal; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css new file mode 100644 index 000000000..c106388ab --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css @@ -0,0 +1,16 @@ +.leftButtons, +.rightButtons { + display: flex; + flex: 1 0 50%; + flex-wrap: wrap; +} + +.rightButtons { + justify-content: flex-end; +} + +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: 10px; +} \ No newline at end of file diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css.d.ts b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css.d.ts new file mode 100644 index 000000000..7b392fff9 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'deleteButton': string; + 'leftButtons': string; + 'rightButtons': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx new file mode 100644 index 000000000..da9a81574 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalContent.tsx @@ -0,0 +1,241 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { DownloadClientAppState } from 'App/State/SettingsAppState'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +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 Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { kinds } from 'Helpers/Props'; +import { + bulkDeleteDownloadClients, + bulkEditDownloadClients, +} from 'Store/Actions/settingsActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { SelectStateInputProps } from 'typings/props'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import ManageDownloadClientsEditModal from './Edit/ManageDownloadClientsEditModal'; +import ManageDownloadClientsModalRow from './ManageDownloadClientsModalRow'; +import styles from './ManageDownloadClientsModalContent.css'; + +// TODO: This feels janky to do, but not sure of a better way currently +type OnSelectedChangeCallback = React.ComponentProps< + typeof ManageDownloadClientsModalRow +>['onSelectedChange']; + +const COLUMNS = [ + { + name: 'name', + label: 'Name', + isSortable: true, + isVisible: true, + }, + { + name: 'implementation', + label: 'Implementation', + isSortable: true, + isVisible: true, + }, + { + name: 'enable', + label: 'Enabled', + isSortable: true, + isVisible: true, + }, + { + name: 'priority', + label: 'Priority', + isSortable: true, + isVisible: true, + }, + { + name: 'removeCompletedDownloads', + label: 'Remove Completed', + isSortable: true, + isVisible: true, + }, + { + name: 'removeFailedDownloads', + label: 'Remove Failed', + isSortable: true, + isVisible: true, + }, +]; + +interface ManageDownloadClientsModalContentProps { + onModalClose(): void; +} + +function ManageDownloadClientsModalContent( + props: ManageDownloadClientsModalContentProps +) { + const { onModalClose } = props; + + const { + isFetching, + isPopulated, + isDeleting, + isSaving, + error, + items, + }: DownloadClientAppState = useSelector( + createClientSideCollectionSelector('settings.downloadClients') + ); + const dispatch = useDispatch(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + + const [selectState, setSelectState] = useSelectState(); + + const { allSelected, allUnselected, selectedState } = selectState; + + const selectedIds: number[] = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const selectedCount = selectedIds.length; + + const onDeletePress = useCallback(() => { + setIsDeleteModalOpen(true); + }, [setIsDeleteModalOpen]); + + const onDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + }, [setIsDeleteModalOpen]); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onConfirmDelete = useCallback(() => { + dispatch(bulkDeleteDownloadClients({ ids: selectedIds })); + setIsDeleteModalOpen(false); + }, [selectedIds, dispatch]); + + const onSavePress = useCallback( + (payload: object) => { + setIsEditModalOpen(false); + + dispatch( + bulkEditDownloadClients({ + ids: selectedIds, + ...payload, + }) + ); + }, + [selectedIds, dispatch] + ); + + const onSelectAllChange = useCallback( + ({ value }: SelectStateInputProps) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const onSelectedChange = useCallback( + ({ id, value, shiftKey = false }) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const errorMessage = getErrorMessage(error, 'Unable to load import lists.'); + const anySelected = selectedCount > 0; + + return ( + + Manage Import Lists + + {isFetching ? : null} + + {error ?
{errorMessage}
: null} + + {isPopulated && !!items.length && !isFetching && !isFetching ? ( + + + {items.map((item) => { + return ( + + ); + })} + +
+ ) : null} +
+ + +
+ + Delete + + + + Edit + +
+ + +
+ + + + +
+ ); +} + +export default ManageDownloadClientsModalContent; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css new file mode 100644 index 000000000..242e0c84e --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css @@ -0,0 +1,11 @@ +.name, +.enable, +.tags, +.priority, +.removeCompletedDownloads, +.removeFailedDownloads, +.implementation { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} \ No newline at end of file diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css.d.ts b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css.d.ts new file mode 100644 index 000000000..74553b4f9 --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.css.d.ts @@ -0,0 +1,13 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'enable': string; + 'implementation': string; + 'name': string; + 'priority': string; + 'removeCompletedDownloads': string; + 'removeFailedDownloads': string; + 'tags': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx new file mode 100644 index 000000000..ad291b1ed --- /dev/null +++ b/frontend/src/Settings/DownloadClients/DownloadClients/Manage/ManageDownloadClientsModalRow.tsx @@ -0,0 +1,87 @@ +import React, { useCallback } from 'react'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Column from 'Components/Table/Column'; +import TableRow from 'Components/Table/TableRow'; +import { SelectStateInputProps } from 'typings/props'; +import styles from './ManageDownloadClientsModalRow.css'; + +interface ManageDownloadClientsModalRowProps { + id: number; + name: string; + enable: boolean; + priority: number; + removeCompletedDownloads: boolean; + removeFailedDownloads: boolean; + implementation: string; + columns: Column[]; + isSelected?: boolean; + onSelectedChange(result: SelectStateInputProps): void; +} + +function ManageDownloadClientsModalRow( + props: ManageDownloadClientsModalRowProps +) { + const { + id, + isSelected, + name, + enable, + priority, + removeCompletedDownloads, + removeFailedDownloads, + implementation, + onSelectedChange, + } = props; + + const onSelectedChangeWrapper = useCallback( + (result: SelectStateInputProps) => { + onSelectedChange({ + ...result, + }); + }, + [onSelectedChange] + ); + + return ( + + + + + {name} + + + + {implementation} + + + + {enable ? 'Yes' : 'No'} + + + + {priority} + + + + {removeCompletedDownloads ? 'Yes' : 'No'} + + + + {removeFailedDownloads ? 'Yes' : 'No'} + + + ); +} + +export default ManageDownloadClientsModalRow; diff --git a/frontend/src/Settings/ImportLists/ImportListSettings.js b/frontend/src/Settings/ImportLists/ImportListSettings.js index ece550efd..9bdf9869c 100644 --- a/frontend/src/Settings/ImportLists/ImportListSettings.js +++ b/frontend/src/Settings/ImportLists/ImportListSettings.js @@ -9,6 +9,7 @@ import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import translate from 'Utilities/String/translate'; import ImportListExclusionsConnector from './ImportListExclusions/ImportListExclusionsConnector'; import ImportListsConnector from './ImportLists/ImportListsConnector'; +import ManageImportListsModal from './ImportLists/Manage/ManageImportListsModal'; import ImportListOptionsConnector from './Options/ImportListOptionsConnector'; class ImportListSettings extends Component { @@ -23,7 +24,8 @@ class ImportListSettings extends Component { this.state = { isSaving: false, - hasPendingChanges: false + hasPendingChanges: false, + isManageImportListsOpen: false }; } @@ -38,6 +40,14 @@ class ImportListSettings extends Component { this.setState(payload); }; + onManageImportListsPress = () => { + this.setState({ isManageImportListsOpen: true }); + }; + + onManageImportListsModalClose = () => { + this.setState({ isManageImportListsOpen: false }); + }; + onSavePress = () => { if (this._saveCallback) { this._saveCallback(); @@ -55,7 +65,8 @@ class ImportListSettings extends Component { const { isSaving, - hasPendingChanges + hasPendingChanges, + isManageImportListsOpen } = this.state; return ( @@ -73,6 +84,12 @@ class ImportListSettings extends Component { isSpinning={isTestingAll} onPress={dispatchTestAllImportList} /> + + } onSavePress={this.onSavePress} @@ -88,6 +105,11 @@ class ImportListSettings extends Component { + + ); diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModal.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModal.tsx new file mode 100644 index 000000000..0fed1c4b8 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModal.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageImportListsEditModalContent from './ManageImportListsEditModalContent'; + +interface ManageImportListsEditModalProps { + isOpen: boolean; + importListIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +function ManageImportListsEditModal(props: ManageImportListsEditModalProps) { + const { isOpen, importListIds, onSavePress, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageImportListsEditModal; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.css b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.css new file mode 100644 index 000000000..ea406894e --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.css @@ -0,0 +1,16 @@ +.modalFooter { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + justify-content: space-between; +} + +.selected { + font-weight: bold; +} + +@media only screen and (max-width: $breakpointExtraSmall) { + .modalFooter { + flex-direction: column; + gap: 10px; + } +} diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.css.d.ts b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.css.d.ts new file mode 100644 index 000000000..cbf2d6328 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'modalFooter': string; + 'selected': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx new file mode 100644 index 000000000..1ad110b80 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Edit/ManageImportListsEditModalContent.tsx @@ -0,0 +1,152 @@ +import React, { useCallback, useState } from 'react'; +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 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 } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './ManageImportListsEditModalContent.css'; + +interface SavePayload { + enableAuto?: boolean; + qualityProfileId?: number; + rootFolderPath?: string; +} + +interface ManageImportListsEditModalContentProps { + importListIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +const NO_CHANGE = 'noChange'; + +const autoAddOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'enabled', value: 'Enabled' }, + { key: 'disabled', value: 'Disabled' }, +]; + +function ManageImportListsEditModalContent( + props: ManageImportListsEditModalContentProps +) { + const { importListIds, onSavePress, onModalClose } = props; + + const [enableAuto, setenableAuto] = useState(NO_CHANGE); + const [qualityProfileId, setQualityProfileId] = useState( + NO_CHANGE + ); + const [rootFolderPath, setRootFolderPath] = useState(NO_CHANGE); + + const save = useCallback(() => { + let hasChanges = false; + const payload: SavePayload = {}; + + if (enableAuto !== NO_CHANGE) { + hasChanges = true; + payload.enableAuto = enableAuto === 'enabled'; + } + + if (qualityProfileId !== NO_CHANGE) { + hasChanges = true; + payload.qualityProfileId = qualityProfileId as number; + } + + if (rootFolderPath !== NO_CHANGE) { + hasChanges = true; + payload.rootFolderPath = rootFolderPath; + } + + if (hasChanges) { + onSavePress(payload); + } + + onModalClose(); + }, [enableAuto, qualityProfileId, rootFolderPath, onSavePress, onModalClose]); + + const onInputChange = useCallback( + ({ name, value }: { name: string; value: string }) => { + switch (name) { + case 'enableAuto': + setenableAuto(value); + break; + case 'qualityProfileId': + setQualityProfileId(value); + break; + case 'rootFolderPath': + setRootFolderPath(value); + break; + default: + console.warn('EditImportListModalContent Unknown Input'); + } + }, + [] + ); + + const selectedCount = importListIds.length; + + return ( + + {translate('EditSelectedImportLists')} + + + + {translate('AutomaticAdd')} + + + + + + {translate('QualityProfile')} + + + + + + {translate('RootFolder')} + + + + + + +
+ {translate('{count} import lists selected', { count: selectedCount })} +
+ +
+ + + +
+
+
+ ); +} + +export default ManageImportListsEditModalContent; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModal.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModal.tsx new file mode 100644 index 000000000..67a029d85 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageImportListsModalContent from './ManageImportListsModalContent'; + +interface ManageImportListsModalProps { + isOpen: boolean; + onModalClose(): void; +} + +function ManageImportListsModal(props: ManageImportListsModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageImportListsModal; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css new file mode 100644 index 000000000..c106388ab --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css @@ -0,0 +1,16 @@ +.leftButtons, +.rightButtons { + display: flex; + flex: 1 0 50%; + flex-wrap: wrap; +} + +.rightButtons { + justify-content: flex-end; +} + +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: 10px; +} \ No newline at end of file diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css.d.ts b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css.d.ts new file mode 100644 index 000000000..7b392fff9 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'deleteButton': string; + 'leftButtons': string; + 'rightButtons': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx new file mode 100644 index 000000000..9d6b2fae1 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalContent.tsx @@ -0,0 +1,283 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { ImportListAppState } from 'App/State/SettingsAppState'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +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 Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { kinds } from 'Helpers/Props'; +import { + bulkDeleteImportLists, + bulkEditImportLists, +} from 'Store/Actions/settingsActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { SelectStateInputProps } from 'typings/props'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import ManageImportListsEditModal from './Edit/ManageImportListsEditModal'; +import ManageImportListsModalRow from './ManageImportListsModalRow'; +import TagsModal from './Tags/TagsModal'; +import styles from './ManageImportListsModalContent.css'; + +// TODO: This feels janky to do, but not sure of a better way currently +type OnSelectedChangeCallback = React.ComponentProps< + typeof ManageImportListsModalRow +>['onSelectedChange']; + +const COLUMNS = [ + { + name: 'name', + label: 'Name', + isSortable: true, + isVisible: true, + }, + { + name: 'implementation', + label: 'Implementation', + isSortable: true, + isVisible: true, + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + isSortable: true, + isVisible: true, + }, + { + name: 'rootFolderPath', + label: 'Root Folder', + isSortable: true, + isVisible: true, + }, + { + name: 'enableAuto', + label: 'Auto Add', + isSortable: true, + isVisible: true, + }, + { + name: 'tags', + label: 'Tags', + isSortable: true, + isVisible: true, + }, +]; + +interface ManageImportListsModalContentProps { + onModalClose(): void; +} + +function ManageImportListsModalContent( + props: ManageImportListsModalContentProps +) { + const { onModalClose } = props; + + const { + isFetching, + isPopulated, + isDeleting, + isSaving, + error, + items, + }: ImportListAppState = useSelector( + createClientSideCollectionSelector('settings.importLists') + ); + const dispatch = useDispatch(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isTagsModalOpen, setIsTagsModalOpen] = useState(false); + const [isSavingTags, setIsSavingTags] = useState(false); + + const [selectState, setSelectState] = useSelectState(); + + const { allSelected, allUnselected, selectedState } = selectState; + + const selectedIds: number[] = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const selectedCount = selectedIds.length; + + const onDeletePress = useCallback(() => { + setIsDeleteModalOpen(true); + }, [setIsDeleteModalOpen]); + + const onDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + }, [setIsDeleteModalOpen]); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onConfirmDelete = useCallback(() => { + dispatch(bulkDeleteImportLists({ ids: selectedIds })); + setIsDeleteModalOpen(false); + }, [selectedIds, dispatch]); + + const onSavePress = useCallback( + (payload: object) => { + setIsEditModalOpen(false); + + dispatch( + bulkEditImportLists({ + ids: selectedIds, + ...payload, + }) + ); + }, + [selectedIds, dispatch] + ); + + const onTagsPress = useCallback(() => { + setIsTagsModalOpen(true); + }, [setIsTagsModalOpen]); + + const onTagsModalClose = useCallback(() => { + setIsTagsModalOpen(false); + }, [setIsTagsModalOpen]); + + const onApplyTagsPress = useCallback( + (tags: number[], applyTags: string) => { + setIsSavingTags(true); + setIsTagsModalOpen(false); + + dispatch( + bulkEditImportLists({ + ids: selectedIds, + tags, + applyTags, + }) + ); + }, + [selectedIds, dispatch] + ); + + const onSelectAllChange = useCallback( + ({ value }: SelectStateInputProps) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const onSelectedChange = useCallback( + ({ id, value, shiftKey = false }) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const errorMessage = getErrorMessage(error, 'Unable to load import lists.'); + const anySelected = selectedCount > 0; + + return ( + + Manage Import Lists + + {isFetching ? : null} + + {error ?
{errorMessage}
: null} + + {isPopulated && !!items.length && !isFetching && !isFetching ? ( + + + {items.map((item) => { + return ( + + ); + })} + +
+ ) : null} +
+ + +
+ + Delete + + + + Edit + + + + Set Tags + +
+ + +
+ + + + + + +
+ ); +} + +export default ManageImportListsModalContent; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.css b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.css new file mode 100644 index 000000000..0246a0f93 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.css @@ -0,0 +1,10 @@ +.name, +.tags, +.enableAuto, +.qualityProfileId, +.rootFolderPath, +.implementation { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} \ No newline at end of file diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.css.d.ts b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.css.d.ts new file mode 100644 index 000000000..874317cc3 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.css.d.ts @@ -0,0 +1,12 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'enableAuto': string; + 'implementation': string; + 'name': string; + 'qualityProfileId': string; + 'rootFolderPath': string; + 'tags': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.tsx new file mode 100644 index 000000000..7076ca822 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/ManageImportListsModalRow.tsx @@ -0,0 +1,89 @@ +import React, { useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Column from 'Components/Table/Column'; +import TableRow from 'Components/Table/TableRow'; +import TagListConnector from 'Components/TagListConnector'; +import { createQualityProfileSelectorForHook } from 'Store/Selectors/createQualityProfileSelector'; +import { SelectStateInputProps } from 'typings/props'; +import styles from './ManageImportListsModalRow.css'; + +interface ManageImportListsModalRowProps { + id: number; + name: string; + rootFolderPath: string; + qualityProfileId: number; + implementation: string; + tags: number[]; + enableAuto: boolean; + columns: Column[]; + isSelected?: boolean; + onSelectedChange(result: SelectStateInputProps): void; +} + +function ManageImportListsModalRow(props: ManageImportListsModalRowProps) { + const { + id, + isSelected, + name, + rootFolderPath, + qualityProfileId, + implementation, + enableAuto, + tags, + onSelectedChange, + } = props; + + const qualityProfile = useSelector( + createQualityProfileSelectorForHook(qualityProfileId) + ); + + const onSelectedChangeWrapper = useCallback( + (result: SelectStateInputProps) => { + onSelectedChange({ + ...result, + }); + }, + [onSelectedChange] + ); + + return ( + + + + + {name} + + + + {implementation} + + + + {qualityProfile?.name ?? 'None'} + + + + {rootFolderPath} + + + + {enableAuto ? 'Yes' : 'No'} + + + + + + + ); +} + +export default ManageImportListsModalRow; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModal.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModal.tsx new file mode 100644 index 000000000..2e24d60e8 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModal.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import TagsModalContent from './TagsModalContent'; + +interface TagsModalProps { + isOpen: boolean; + ids: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModal(props: TagsModalProps) { + const { isOpen, onModalClose, ...otherProps } = props; + + return ( + + + + ); +} + +export default TagsModal; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.css b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.css new file mode 100644 index 000000000..63be9aadd --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.css @@ -0,0 +1,12 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.result { + padding-top: 4px; +} diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.css.d.ts b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.css.d.ts new file mode 100644 index 000000000..9b4321dcc --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'message': string; + 'renameIcon': string; + 'result': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.tsx b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.tsx new file mode 100644 index 000000000..ad9ae4652 --- /dev/null +++ b/frontend/src/Settings/ImportLists/ImportLists/Manage/Tags/TagsModalContent.tsx @@ -0,0 +1,178 @@ +import { uniq } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import { ImportListAppState } from 'App/State/SettingsAppState'; +import { Tag } from 'App/State/TagsAppState'; +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 Label from 'Components/Label'; +import Button from 'Components/Link/Button'; +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, sizes } from 'Helpers/Props'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import ImportList from 'typings/ImportList'; +import styles from './TagsModalContent.css'; + +interface TagsModalContentProps { + ids: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModalContent(props: TagsModalContentProps) { + const { ids, onModalClose, onApplyTagsPress } = props; + + const allImportLists: ImportListAppState = useSelector( + (state: AppState) => state.settings.importLists + ); + const tagList: Tag[] = useSelector(createTagsSelector()); + + const [tags, setTags] = useState([]); + const [applyTags, setApplyTags] = useState('add'); + + const seriesTags = useMemo(() => { + const tags = ids.reduce((acc: number[], id) => { + const s = allImportLists.items.find((s: ImportList) => s.id === id); + + if (s) { + acc.push(...s.tags); + } + + return acc; + }, []); + + return uniq(tags); + }, [ids, allImportLists]); + + const onTagsChange = useCallback( + ({ value }: { value: number[] }) => { + setTags(value); + }, + [setTags] + ); + + const onApplyTagsChange = useCallback( + ({ value }: { value: string }) => { + setApplyTags(value); + }, + [setApplyTags] + ); + + const onApplyPress = useCallback(() => { + onApplyTagsPress(tags, applyTags); + }, [tags, applyTags, onApplyTagsPress]); + + const applyTagsOptions = [ + { key: 'add', value: 'Add' }, + { key: 'remove', value: 'Remove' }, + { key: 'replace', value: 'Replace' }, + ]; + + return ( + + Tags + + +
+ + Tags + + + + + + Apply Tags + + + + + + Result + +
+ {seriesTags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + const removeTag = + (applyTags === 'remove' && tags.indexOf(id) > -1) || + (applyTags === 'replace' && tags.indexOf(id) === -1); + + return ( + + ); + })} + + {(applyTags === 'add' || applyTags === 'replace') && + tags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + if (seriesTags.indexOf(id) > -1) { + return null; + } + + return ( + + ); + })} +
+
+
+
+ + + + + + +
+ ); +} + +export default TagsModalContent; diff --git a/frontend/src/Settings/Indexers/IndexerSettings.js b/frontend/src/Settings/Indexers/IndexerSettings.js index 1a3e12e7d..712d98497 100644 --- a/frontend/src/Settings/Indexers/IndexerSettings.js +++ b/frontend/src/Settings/Indexers/IndexerSettings.js @@ -8,6 +8,7 @@ import { icons } from 'Helpers/Props'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import translate from 'Utilities/String/translate'; import IndexersConnector from './Indexers/IndexersConnector'; +import ManageIndexersModal from './Indexers/Manage/ManageIndexersModal'; import IndexerOptionsConnector from './Options/IndexerOptionsConnector'; import RestrictionsConnector from './Restrictions/RestrictionsConnector'; @@ -23,7 +24,8 @@ class IndexerSettings extends Component { this.state = { isSaving: false, - hasPendingChanges: false + hasPendingChanges: false, + isManageIndexersOpen: false }; } @@ -38,6 +40,14 @@ class IndexerSettings extends Component { this.setState(payload); }; + onManageIndexersPress = () => { + this.setState({ isManageIndexersOpen: true }); + }; + + onManageIndexersModalClose = () => { + this.setState({ isManageIndexersOpen: false }); + }; + onSavePress = () => { if (this._saveCallback) { this._saveCallback(); @@ -55,7 +65,8 @@ class IndexerSettings extends Component { const { isSaving, - hasPendingChanges + hasPendingChanges, + isManageIndexersOpen } = this.state; return ( @@ -73,6 +84,12 @@ class IndexerSettings extends Component { isSpinning={isTestingAll} onPress={dispatchTestAllIndexers} /> + + } onSavePress={this.onSavePress} @@ -87,6 +104,11 @@ class IndexerSettings extends Component { /> + + ); diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModal.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModal.tsx new file mode 100644 index 000000000..15c16b980 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModal.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageIndexersEditModalContent from './ManageIndexersEditModalContent'; + +interface ManageIndexersEditModalProps { + isOpen: boolean; + indexerIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +function ManageIndexersEditModal(props: ManageIndexersEditModalProps) { + const { isOpen, indexerIds, onSavePress, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageIndexersEditModal; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.css b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.css new file mode 100644 index 000000000..ea406894e --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.css @@ -0,0 +1,16 @@ +.modalFooter { + composes: modalFooter from '~Components/Modal/ModalFooter.css'; + + justify-content: space-between; +} + +.selected { + font-weight: bold; +} + +@media only screen and (max-width: $breakpointExtraSmall) { + .modalFooter { + flex-direction: column; + gap: 10px; + } +} diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.css.d.ts b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.css.d.ts new file mode 100644 index 000000000..cbf2d6328 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.css.d.ts @@ -0,0 +1,8 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'modalFooter': string; + 'selected': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.tsx new file mode 100644 index 000000000..b6613f7da --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Edit/ManageIndexersEditModalContent.tsx @@ -0,0 +1,178 @@ +import React, { useCallback, useState } from 'react'; +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 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 } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './ManageIndexersEditModalContent.css'; + +interface SavePayload { + enableRss?: boolean; + enableAutomaticSearch?: boolean; + enableInteractiveSearch?: boolean; + priority?: number; +} + +interface ManageIndexersEditModalContentProps { + indexerIds: number[]; + onSavePress(payload: object): void; + onModalClose(): void; +} + +const NO_CHANGE = 'noChange'; + +const enableOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'enabled', value: 'Enabled' }, + { key: 'disabled', value: 'Disabled' }, +]; + +function ManageIndexersEditModalContent( + props: ManageIndexersEditModalContentProps +) { + const { indexerIds, onSavePress, onModalClose } = props; + + const [enableRss, setEnableRss] = useState(NO_CHANGE); + const [enableAutomaticSearch, setEnableAutomaticSearch] = useState(NO_CHANGE); + const [enableInteractiveSearch, setEnableInteractiveSearch] = + useState(NO_CHANGE); + const [priority, setPriority] = useState(NO_CHANGE); + + const save = useCallback(() => { + let hasChanges = false; + const payload: SavePayload = {}; + + if (enableRss !== NO_CHANGE) { + hasChanges = true; + payload.enableRss = enableRss === 'enabled'; + } + + if (enableAutomaticSearch !== NO_CHANGE) { + hasChanges = true; + payload.enableAutomaticSearch = enableAutomaticSearch === 'enabled'; + } + + if (enableInteractiveSearch !== NO_CHANGE) { + hasChanges = true; + payload.enableInteractiveSearch = enableInteractiveSearch === 'enabled'; + } + + if (priority !== NO_CHANGE) { + hasChanges = true; + payload.priority = priority as number; + } + + if (hasChanges) { + onSavePress(payload); + } + + onModalClose(); + }, [ + enableRss, + enableAutomaticSearch, + enableInteractiveSearch, + priority, + onSavePress, + onModalClose, + ]); + + const onInputChange = useCallback( + ({ name, value }: { name: string; value: string }) => { + switch (name) { + case 'enableRss': + setEnableRss(value); + break; + case 'enableAutomaticSearch': + setEnableAutomaticSearch(value); + break; + case 'enableInteractiveSearch': + setEnableInteractiveSearch(value); + break; + case 'priority': + setPriority(value); + break; + default: + console.warn('EditIndexersModalContent Unknown Input'); + } + }, + [] + ); + + const selectedCount = indexerIds.length; + + return ( + + {translate('EditSelectedIndexers')} + + + + {translate('EnableRss')} + + + + + + {translate('EnableAutomaticSearch')} + + + + + + {translate('EnableInteractiveSearch')} + + + + + + {translate('Priority')} + + + + + + +
+ {translate('{count} indexers selected', { count: selectedCount })} +
+ +
+ + + +
+
+
+ ); +} + +export default ManageIndexersEditModalContent; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModal.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModal.tsx new file mode 100644 index 000000000..afb90adab --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ManageIndexersModalContent from './ManageIndexersModalContent'; + +interface ManageIndexersModalProps { + isOpen: boolean; + onModalClose(): void; +} + +function ManageIndexersModal(props: ManageIndexersModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default ManageIndexersModal; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css new file mode 100644 index 000000000..c106388ab --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css @@ -0,0 +1,16 @@ +.leftButtons, +.rightButtons { + display: flex; + flex: 1 0 50%; + flex-wrap: wrap; +} + +.rightButtons { + justify-content: flex-end; +} + +.deleteButton { + composes: button from '~Components/Link/Button.css'; + + margin-right: 10px; +} \ No newline at end of file diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css.d.ts b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css.d.ts new file mode 100644 index 000000000..7b392fff9 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'deleteButton': string; + 'leftButtons': string; + 'rightButtons': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx new file mode 100644 index 000000000..e721a8193 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalContent.tsx @@ -0,0 +1,287 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { IndexerAppState } from 'App/State/SettingsAppState'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +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 Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import useSelectState from 'Helpers/Hooks/useSelectState'; +import { kinds } from 'Helpers/Props'; +import { + bulkDeleteIndexers, + bulkEditIndexers, +} from 'Store/Actions/settingsActions'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import { SelectStateInputProps } from 'typings/props'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; +import ManageIndexersEditModal from './Edit/ManageIndexersEditModal'; +import ManageIndexersModalRow from './ManageIndexersModalRow'; +import TagsModal from './Tags/TagsModal'; +import styles from './ManageIndexersModalContent.css'; + +// TODO: This feels janky to do, but not sure of a better way currently +type OnSelectedChangeCallback = React.ComponentProps< + typeof ManageIndexersModalRow +>['onSelectedChange']; + +const COLUMNS = [ + { + name: 'name', + label: 'Name', + isSortable: true, + isVisible: true, + }, + { + name: 'implementation', + label: 'Implementation', + isSortable: true, + isVisible: true, + }, + { + name: 'enableRss', + label: 'Enable RSS', + isSortable: true, + isVisible: true, + }, + { + name: 'enableAutomaticSearch', + label: 'Enable Automatic Search', + isSortable: true, + isVisible: true, + }, + { + name: 'enableInteractiveSearch', + label: 'Enable Interactive Search', + isSortable: true, + isVisible: true, + }, + { + name: 'priority', + label: 'Priority', + isSortable: true, + isVisible: true, + }, + { + name: 'tags', + label: 'Tags', + isSortable: true, + isVisible: true, + }, +]; + +interface ManageIndexersModalContentProps { + onModalClose(): void; +} + +function ManageIndexersModalContent(props: ManageIndexersModalContentProps) { + const { onModalClose } = props; + + const { + isFetching, + isPopulated, + isDeleting, + isSaving, + error, + items, + }: IndexerAppState = useSelector( + createClientSideCollectionSelector('settings.indexers') + ); + const dispatch = useDispatch(); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + const [isTagsModalOpen, setIsTagsModalOpen] = useState(false); + const [isSavingTags, setIsSavingTags] = useState(false); + + const [selectState, setSelectState] = useSelectState(); + + const { allSelected, allUnselected, selectedState } = selectState; + + const selectedIds: number[] = useMemo(() => { + return getSelectedIds(selectedState); + }, [selectedState]); + + const selectedCount = selectedIds.length; + + const onDeletePress = useCallback(() => { + setIsDeleteModalOpen(true); + }, [setIsDeleteModalOpen]); + + const onDeleteModalClose = useCallback(() => { + setIsDeleteModalOpen(false); + }, [setIsDeleteModalOpen]); + + const onEditPress = useCallback(() => { + setIsEditModalOpen(true); + }, [setIsEditModalOpen]); + + const onEditModalClose = useCallback(() => { + setIsEditModalOpen(false); + }, [setIsEditModalOpen]); + + const onConfirmDelete = useCallback(() => { + dispatch(bulkDeleteIndexers({ ids: selectedIds })); + setIsDeleteModalOpen(false); + }, [selectedIds, dispatch]); + + const onSavePress = useCallback( + (payload: object) => { + setIsEditModalOpen(false); + + dispatch( + bulkEditIndexers({ + ids: selectedIds, + ...payload, + }) + ); + }, + [selectedIds, dispatch] + ); + + const onTagsPress = useCallback(() => { + setIsTagsModalOpen(true); + }, [setIsTagsModalOpen]); + + const onTagsModalClose = useCallback(() => { + setIsTagsModalOpen(false); + }, [setIsTagsModalOpen]); + + const onApplyTagsPress = useCallback( + (tags: number[], applyTags: string) => { + setIsSavingTags(true); + setIsTagsModalOpen(false); + + dispatch( + bulkEditIndexers({ + ids: selectedIds, + tags, + applyTags, + }) + ); + }, + [selectedIds, dispatch] + ); + + const onSelectAllChange = useCallback( + ({ value }: SelectStateInputProps) => { + setSelectState({ type: value ? 'selectAll' : 'unselectAll', items }); + }, + [items, setSelectState] + ); + + const onSelectedChange = useCallback( + ({ id, value, shiftKey = false }) => { + setSelectState({ + type: 'toggleSelected', + items, + id, + isSelected: value, + shiftKey, + }); + }, + [items, setSelectState] + ); + + const errorMessage = getErrorMessage(error, 'Unable to load import lists.'); + const anySelected = selectedCount > 0; + + return ( + + Manage Import Lists + + {isFetching ? : null} + + {error ?
{errorMessage}
: null} + + {isPopulated && !!items.length && !isFetching && !isFetching ? ( + + + {items.map((item) => { + return ( + + ); + })} + +
+ ) : null} +
+ + +
+ + Delete + + + + Edit + + + + Set Tags + +
+ + +
+ + + + + + +
+ ); +} + +export default ManageIndexersModalContent; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css new file mode 100644 index 000000000..982495344 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css @@ -0,0 +1,11 @@ +.name, +.tags, +.enableRss, +.enableAutomaticSearch, +.enableInteractiveSearch, +.priority, +.implementation { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + word-break: break-all; +} \ No newline at end of file diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css.d.ts b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css.d.ts new file mode 100644 index 000000000..7991c19fd --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.css.d.ts @@ -0,0 +1,13 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'enableAutomaticSearch': string; + 'enableInteractiveSearch': string; + 'enableRss': string; + 'implementation': string; + 'name': string; + 'priority': string; + 'tags': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx new file mode 100644 index 000000000..a122d70f3 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/ManageIndexersModalRow.tsx @@ -0,0 +1,92 @@ +import React, { useCallback } from 'react'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import Column from 'Components/Table/Column'; +import TableRow from 'Components/Table/TableRow'; +import TagListConnector from 'Components/TagListConnector'; +import { SelectStateInputProps } from 'typings/props'; +import styles from './ManageIndexersModalRow.css'; + +interface ManageIndexersModalRowProps { + id: number; + name: string; + enableRss: boolean; + enableAutomaticSearch: boolean; + enableInteractiveSearch: boolean; + priority: number; + implementation: string; + tags: number[]; + columns: Column[]; + isSelected?: boolean; + onSelectedChange(result: SelectStateInputProps): void; +} + +function ManageIndexersModalRow(props: ManageIndexersModalRowProps) { + const { + id, + isSelected, + name, + enableRss, + enableAutomaticSearch, + enableInteractiveSearch, + priority, + implementation, + tags, + onSelectedChange, + } = props; + + const onSelectedChangeWrapper = useCallback( + (result: SelectStateInputProps) => { + onSelectedChange({ + ...result, + }); + }, + [onSelectedChange] + ); + + return ( + + + + + {name} + + + + {implementation} + + + + {enableRss ? 'Yes' : 'No'} + + + + {enableAutomaticSearch ? 'Yes' : 'No'} + + + + {enableInteractiveSearch ? 'Yes' : 'No'} + + + + {priority} + + + + + + + ); +} + +export default ManageIndexersModalRow; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModal.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModal.tsx new file mode 100644 index 000000000..2e24d60e8 --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModal.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import TagsModalContent from './TagsModalContent'; + +interface TagsModalProps { + isOpen: boolean; + ids: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModal(props: TagsModalProps) { + const { isOpen, onModalClose, ...otherProps } = props; + + return ( + + + + ); +} + +export default TagsModal; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.css b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.css new file mode 100644 index 000000000..63be9aadd --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.css @@ -0,0 +1,12 @@ +.renameIcon { + margin-left: 5px; +} + +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.result { + padding-top: 4px; +} diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.css.d.ts b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.css.d.ts new file mode 100644 index 000000000..9b4321dcc --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.css.d.ts @@ -0,0 +1,9 @@ +// This file is automatically generated. +// Please do not change this file! +interface CssExports { + 'message': string; + 'renameIcon': string; + 'result': string; +} +export const cssExports: CssExports; +export default cssExports; diff --git a/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx new file mode 100644 index 000000000..1f681707c --- /dev/null +++ b/frontend/src/Settings/Indexers/Indexers/Manage/Tags/TagsModalContent.tsx @@ -0,0 +1,178 @@ +import { uniq } from 'lodash'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import { IndexerAppState } from 'App/State/SettingsAppState'; +import { Tag } from 'App/State/TagsAppState'; +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 Label from 'Components/Label'; +import Button from 'Components/Link/Button'; +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, sizes } from 'Helpers/Props'; +import createTagsSelector from 'Store/Selectors/createTagsSelector'; +import Indexer from 'typings/Indexer'; +import styles from './TagsModalContent.css'; + +interface TagsModalContentProps { + ids: number[]; + onApplyTagsPress: (tags: number[], applyTags: string) => void; + onModalClose: () => void; +} + +function TagsModalContent(props: TagsModalContentProps) { + const { ids, onModalClose, onApplyTagsPress } = props; + + const allIndexers: IndexerAppState = useSelector( + (state: AppState) => state.settings.indexers + ); + const tagList: Tag[] = useSelector(createTagsSelector()); + + const [tags, setTags] = useState([]); + const [applyTags, setApplyTags] = useState('add'); + + const seriesTags = useMemo(() => { + const tags = ids.reduce((acc: number[], id) => { + const s = allIndexers.items.find((s: Indexer) => s.id === id); + + if (s) { + acc.push(...s.tags); + } + + return acc; + }, []); + + return uniq(tags); + }, [ids, allIndexers]); + + const onTagsChange = useCallback( + ({ value }: { value: number[] }) => { + setTags(value); + }, + [setTags] + ); + + const onApplyTagsChange = useCallback( + ({ value }: { value: string }) => { + setApplyTags(value); + }, + [setApplyTags] + ); + + const onApplyPress = useCallback(() => { + onApplyTagsPress(tags, applyTags); + }, [tags, applyTags, onApplyTagsPress]); + + const applyTagsOptions = [ + { key: 'add', value: 'Add' }, + { key: 'remove', value: 'Remove' }, + { key: 'replace', value: 'Replace' }, + ]; + + return ( + + Tags + + +
+ + Tags + + + + + + Apply Tags + + + + + + Result + +
+ {seriesTags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + const removeTag = + (applyTags === 'remove' && tags.indexOf(id) > -1) || + (applyTags === 'replace' && tags.indexOf(id) === -1); + + return ( + + ); + })} + + {(applyTags === 'add' || applyTags === 'replace') && + tags.map((id) => { + const tag = tagList.find((t) => t.id === id); + + if (!tag) { + return null; + } + + if (seriesTags.indexOf(id) > -1) { + return null; + } + + return ( + + ); + })} +
+
+
+
+ + + + + + +
+ ); +} + +export default TagsModalContent; diff --git a/frontend/src/Store/Actions/Creators/createBulkEditItemHandler.js b/frontend/src/Store/Actions/Creators/createBulkEditItemHandler.js new file mode 100644 index 000000000..f174dae54 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createBulkEditItemHandler.js @@ -0,0 +1,54 @@ +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set, updateItem } from '../baseActions'; + +function createBulkEditItemHandler(section, url) { + return function(getState, payload, dispatch) { + + dispatch(set({ section, isSaving: true })); + + const ajaxOptions = { + url: `${url}`, + method: 'PUT', + data: JSON.stringify(payload), + dataType: 'json' + }; + + const promise = createAjaxRequest(ajaxOptions).request; + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isSaving: false, + saveError: null + }), + + ...data.map((provider) => { + + const { + ...propsToUpdate + } = provider; + + return updateItem({ + id: provider.id, + section, + ...propsToUpdate + }); + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + + return promise; + }; +} + +export default createBulkEditItemHandler; diff --git a/frontend/src/Store/Actions/Creators/createBulkRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createBulkRemoveItemHandler.js new file mode 100644 index 000000000..3293ff1b5 --- /dev/null +++ b/frontend/src/Store/Actions/Creators/createBulkRemoveItemHandler.js @@ -0,0 +1,48 @@ +import { batchActions } from 'redux-batched-actions'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { removeItem, set } from '../baseActions'; + +function createBulkRemoveItemHandler(section, url) { + return function(getState, payload, dispatch) { + const { + ids + } = payload; + + dispatch(set({ section, isDeleting: true })); + + const ajaxOptions = { + url: `${url}`, + method: 'DELETE', + data: JSON.stringify(payload), + dataType: 'json' + }; + + const promise = createAjaxRequest(ajaxOptions).request; + + promise.done((data) => { + dispatch(batchActions([ + set({ + section, + isDeleting: false, + deleteError: null + }), + + ...ids.map((id) => { + return removeItem({ section, id }); + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + + return promise; + }; +} + +export default createBulkRemoveItemHandler; diff --git a/frontend/src/Store/Actions/Settings/downloadClients.js b/frontend/src/Store/Actions/Settings/downloadClients.js index c18b4db76..f5624ee2a 100644 --- a/frontend/src/Store/Actions/Settings/downloadClients.js +++ b/frontend/src/Store/Actions/Settings/downloadClients.js @@ -1,4 +1,6 @@ import { createAction } from 'redux-actions'; +import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler'; +import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler'; import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; @@ -30,6 +32,9 @@ export const TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/testDownloadClient export const CANCEL_TEST_DOWNLOAD_CLIENT = 'settings/downloadClients/cancelTestDownloadClient'; export const TEST_ALL_DOWNLOAD_CLIENTS = 'settings/downloadClients/testAllDownloadClients'; +export const BULK_DELETE_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkDeleteDownloadClients'; +export const BULK_EDIT_DOWNLOAD_CLIENTS = 'settings/downloadClients/bulkEditDownloadClients'; + // // Action Creators @@ -44,6 +49,9 @@ export const testDownloadClient = createThunk(TEST_DOWNLOAD_CLIENT); export const cancelTestDownloadClient = createThunk(CANCEL_TEST_DOWNLOAD_CLIENT); export const testAllDownloadClients = createThunk(TEST_ALL_DOWNLOAD_CLIENTS); +export const bulkDeleteDownloadClients = createThunk(BULK_DELETE_DOWNLOAD_CLIENTS); +export const bulkEditDownloadClients = createThunk(BULK_EDIT_DOWNLOAD_CLIENTS); + export const setDownloadClientValue = createAction(SET_DOWNLOAD_CLIENT_VALUE, (payload) => { return { section, @@ -95,7 +103,9 @@ export default { [DELETE_DOWNLOAD_CLIENT]: createRemoveItemHandler(section, '/downloadclient'), [TEST_DOWNLOAD_CLIENT]: createTestProviderHandler(section, '/downloadclient'), [CANCEL_TEST_DOWNLOAD_CLIENT]: createCancelTestProviderHandler(section), - [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient') + [TEST_ALL_DOWNLOAD_CLIENTS]: createTestAllProvidersHandler(section, '/downloadclient'), + [BULK_DELETE_DOWNLOAD_CLIENTS]: createBulkRemoveItemHandler(section, '/downloadclient/bulk'), + [BULK_EDIT_DOWNLOAD_CLIENTS]: createBulkEditItemHandler(section, '/downloadclient/bulk') }, // diff --git a/frontend/src/Store/Actions/Settings/importLists.js b/frontend/src/Store/Actions/Settings/importLists.js index 9f7854224..4c19a6f62 100644 --- a/frontend/src/Store/Actions/Settings/importLists.js +++ b/frontend/src/Store/Actions/Settings/importLists.js @@ -1,4 +1,6 @@ import { createAction } from 'redux-actions'; +import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler'; +import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler'; import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; import createFetchSchemaHandler from 'Store/Actions/Creators/createFetchSchemaHandler'; import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; @@ -30,6 +32,9 @@ export const TEST_IMPORT_LIST = 'settings/importLists/testImportList'; export const CANCEL_TEST_IMPORT_LIST = 'settings/importLists/cancelTestImportList'; export const TEST_ALL_IMPORT_LIST = 'settings/importLists/testAllImportList'; +export const BULK_DELETE_IMPORT_LISTS = 'settings/importlists/bulkDeleteImportLists'; +export const BULK_EDIT_IMPORT_LISTS = 'settings/importlists/bulkEditImportLists'; + // // Action Creators @@ -44,6 +49,9 @@ export const testImportList = createThunk(TEST_IMPORT_LIST); export const cancelTestImportList = createThunk(CANCEL_TEST_IMPORT_LIST); export const testAllImportList = createThunk(TEST_ALL_IMPORT_LIST); +export const bulkDeleteImportLists = createThunk(BULK_DELETE_IMPORT_LISTS); +export const bulkEditImportLists = createThunk(BULK_EDIT_IMPORT_LISTS); + export const setImportListValue = createAction(SET_IMPORT_LIST_VALUE, (payload) => { return { section, @@ -95,7 +103,10 @@ export default { [DELETE_IMPORT_LIST]: createRemoveItemHandler(section, '/importlist'), [TEST_IMPORT_LIST]: createTestProviderHandler(section, '/importlist'), [CANCEL_TEST_IMPORT_LIST]: createCancelTestProviderHandler(section), - [TEST_ALL_IMPORT_LIST]: createTestAllProvidersHandler(section, '/importlist') + [TEST_ALL_IMPORT_LIST]: createTestAllProvidersHandler(section, '/importlist'), + + [BULK_DELETE_IMPORT_LISTS]: createBulkRemoveItemHandler(section, '/importlist/bulk'), + [BULK_EDIT_IMPORT_LISTS]: createBulkEditItemHandler(section, '/importlist/bulk') }, // diff --git a/frontend/src/Store/Actions/Settings/indexers.js b/frontend/src/Store/Actions/Settings/indexers.js index e2e85cbb5..17fc6e0ce 100644 --- a/frontend/src/Store/Actions/Settings/indexers.js +++ b/frontend/src/Store/Actions/Settings/indexers.js @@ -11,6 +11,8 @@ import { createThunk } from 'Store/thunks'; import getSectionState from 'Utilities/State/getSectionState'; import selectProviderSchema from 'Utilities/State/selectProviderSchema'; import updateSectionState from 'Utilities/State/updateSectionState'; +import createBulkEditItemHandler from '../Creators/createBulkEditItemHandler'; +import createBulkRemoveItemHandler from '../Creators/createBulkRemoveItemHandler'; // // Variables @@ -33,6 +35,9 @@ export const TEST_INDEXER = 'settings/indexers/testIndexer'; export const CANCEL_TEST_INDEXER = 'settings/indexers/cancelTestIndexer'; export const TEST_ALL_INDEXERS = 'settings/indexers/testAllIndexers'; +export const BULK_DELETE_INDEXERS = 'settings/indexers/bulkDeleteIndexers'; +export const BULK_EDIT_INDEXERS = 'settings/indexers/bulkEditIndexers'; + // // Action Creators @@ -48,6 +53,9 @@ export const testIndexer = createThunk(TEST_INDEXER); export const cancelTestIndexer = createThunk(CANCEL_TEST_INDEXER); export const testAllIndexers = createThunk(TEST_ALL_INDEXERS); +export const bulkDeleteIndexers = createThunk(BULK_DELETE_INDEXERS); +export const bulkEditIndexers = createThunk(BULK_EDIT_INDEXERS); + export const setIndexerValue = createAction(SET_INDEXER_VALUE, (payload) => { return { section, @@ -99,7 +107,10 @@ export default { [DELETE_INDEXER]: createRemoveItemHandler(section, '/indexer'), [TEST_INDEXER]: createTestProviderHandler(section, '/indexer'), [CANCEL_TEST_INDEXER]: createCancelTestProviderHandler(section), - [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer') + [TEST_ALL_INDEXERS]: createTestAllProvidersHandler(section, '/indexer'), + + [BULK_DELETE_INDEXERS]: createBulkRemoveItemHandler(section, '/indexer/bulk'), + [BULK_EDIT_INDEXERS]: createBulkEditItemHandler(section, '/indexer/bulk') }, // diff --git a/frontend/src/Store/Selectors/createQualityProfileSelector.js b/frontend/src/Store/Selectors/createQualityProfileSelector.js index 451aacfd4..611dfc903 100644 --- a/frontend/src/Store/Selectors/createQualityProfileSelector.js +++ b/frontend/src/Store/Selectors/createQualityProfileSelector.js @@ -1,5 +1,16 @@ import { createSelector } from 'reselect'; +export function createQualityProfileSelectorForHook(qualityProfileId) { + return createSelector( + (state) => state.settings.qualityProfiles.items, + (qualityProfiles) => { + return qualityProfiles.find((profile) => { + return profile.id === qualityProfileId; + }); + } + ); +} + function createQualityProfileSelector() { return createSelector( (state, { qualityProfileId }) => qualityProfileId, diff --git a/frontend/src/typings/ImportList.ts b/frontend/src/typings/ImportList.ts new file mode 100644 index 000000000..f2e06cf58 --- /dev/null +++ b/frontend/src/typings/ImportList.ts @@ -0,0 +1,27 @@ +import ModelBase from 'App/ModelBase'; + +export interface Field { + order: number; + name: string; + label: string; + value: boolean | number | string; + type: string; + advanced: boolean; + privacy: string; +} + +interface ImportList extends ModelBase { + enable: boolean; + enableAuto: boolean; + qualityProfileId: number; + rootFolderPath: string; + name: string; + fields: Field[]; + implementationName: string; + implementation: string; + configContract: string; + infoLink: string; + tags: number[]; +} + +export default ImportList; diff --git a/frontend/src/typings/Indexer.ts b/frontend/src/typings/Indexer.ts new file mode 100644 index 000000000..e6c23eda2 --- /dev/null +++ b/frontend/src/typings/Indexer.ts @@ -0,0 +1,28 @@ +import ModelBase from 'App/ModelBase'; + +export interface Field { + order: number; + name: string; + label: string; + value: boolean | number | string; + type: string; + advanced: boolean; + privacy: string; +} + +interface Indexer extends ModelBase { + enableRss: boolean; + enableAutomaticSearch: boolean; + enableInteractiveSearch: boolean; + protocol: string; + priority: number; + name: string; + fields: Field[]; + implementationName: string; + implementation: string; + configContract: string; + infoLink: string; + tags: number[]; +} + +export default Indexer; diff --git a/frontend/src/typings/Notification.ts b/frontend/src/typings/Notification.ts new file mode 100644 index 000000000..e2b5ad7eb --- /dev/null +++ b/frontend/src/typings/Notification.ts @@ -0,0 +1,24 @@ +import ModelBase from 'App/ModelBase'; + +export interface Field { + order: number; + name: string; + label: string; + value: boolean | number | string; + type: string; + advanced: boolean; + privacy: string; +} + +interface Notification extends ModelBase { + enable: boolean; + name: string; + fields: Field[]; + implementationName: string; + implementation: string; + configContract: string; + infoLink: string; + tags: number[]; +} + +export default Notification; diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 427a9d40f..761166e45 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -7,9 +7,12 @@ "AddCustomFormat": "Add Custom Format", "AddDelayProfile": "Add Delay Profile", "AddDownloadClient": "Add Download Client", + "Added": "Added", + "AddedToDownloadQueue": "Added to download queue", "AddExclusion": "Add Exclusion", "AddImportExclusionHelpText": "Prevent movie from being added to Radarr by lists", "AddIndexer": "Add Indexer", + "AddingTag": "Adding tag", "AddList": "Add List", "AddListExclusion": "Add List Exclusion", "AddMovie": "Add Movie", @@ -24,21 +27,18 @@ "AddRestriction": "Add Restriction", "AddRootFolder": "Add Root Folder", "AddToDownloadQueue": "Add to download queue", - "Added": "Added", - "AddedToDownloadQueue": "Added to download queue", - "AddingTag": "Adding tag", "AfterManualRefresh": "After Manual Refresh", "Age": "Age", - "AgeWhenGrabbed": "Age (when grabbed)", "Agenda": "Agenda", + "AgeWhenGrabbed": "Age (when grabbed)", "All": "All", "AllCollectionsHiddenDueToFilter": "All collections are hidden due to applied filter.", "AllFiles": "All Files", "AllMoviesHiddenDueToFilter": "All movies are hidden due to applied filter.", "AllMoviesInPathHaveBeenImported": "All movies in {0} have been imported", - "AllResultsHiddenFilter": "All results are hidden by the applied filter", "AllowHardcodedSubs": "Allow Hardcoded Subs", "AllowHardcodedSubsHelpText": "Detected hardcoded subs will be automatically downloaded", + "AllResultsHiddenFilter": "All results are hidden by the applied filter", "AlreadyInYourLibrary": "Already in your library", "AlternativeTitle": "Alternative Title", "Always": "Always", @@ -72,14 +72,14 @@ "AsAllDayHelpText": "Events will appear as all-day events in your calendar", "AudioInfo": "Audio Info", "AuthBasic": "Basic (Browser Popup)", - "AuthForm": "Forms (Login Page)", "Authentication": "Authentication", "AuthenticationMethodHelpText": "Require Username and Password to access Radarr", + "AuthForm": "Forms (Login Page)", "Auto": "Auto", - "AutoRedownloadFailedHelpText": "Automatically search for and attempt to download a different release", - "AutoUnmonitorPreviouslyDownloadedMoviesHelpText": "Movies deleted from the disk are automatically unmonitored in Radarr", "Automatic": "Automatic", "AutomaticSearch": "Automatic Search", + "AutoRedownloadFailedHelpText": "Automatically search for and attempt to download a different release", + "AutoUnmonitorPreviouslyDownloadedMoviesHelpText": "Movies deleted from the disk are automatically unmonitored in Radarr", "AvailabilityDelay": "Availability Delay", "AvailabilityDelayHelpText": "Amount of time before or after available date to search for Movie", "Backup": "Backup", @@ -92,10 +92,10 @@ "BindAddress": "Bind Address", "BindAddressHelpText": "Valid IP address, localhost or '*' for all interfaces", "Blocklist": "Blocklist", + "Blocklisted": "Blocklisted", "BlocklistHelpText": "Prevents Radarr from automatically grabbing this release again", "BlocklistRelease": "Blocklist Release", "BlocklistReleases": "Blocklist Releases", - "Blocklisted": "Blocklisted", "Branch": "Branch", "BranchUpdate": "Branch to use to update Radarr", "BranchUpdateMechanism": "Branch used by external update mechanism", @@ -110,12 +110,12 @@ "CancelProcessing": "Cancel Processing", "CantFindMovie": "Why can't I find my movie?", "Cast": "Cast", - "CertValidationNoLocal": "Disabled for Local Addresses", "CertificateValidation": "Certificate Validation", "CertificateValidationHelpText": "Change how strict HTTPS certification validation is. Do not change unless you understand the risks.", "Certification": "Certification", "CertificationCountry": "Certification Country", "CertificationCountryHelpText": "Select Country for Movie Certifications", + "CertValidationNoLocal": "Disabled for Local Addresses", "ChangeFileDate": "Change File Date", "ChangeHasNotBeenSavedYet": "Change has not been saved yet", "CheckDownloadClientForDetails": "check download client for more details", @@ -143,10 +143,10 @@ "CloseCurrentModal": "Close Current Modal", "Collection": "Collection", "CollectionOptions": "Collection Options", + "Collections": "Collections", "CollectionShowDetailsHelpText": "Show collection status and properties", "CollectionShowOverviewsHelpText": "Show collection overviews", "CollectionShowPostersHelpText": "Show Collection item posters", - "Collections": "Collections", "CollectionsSelectedInterp": "{0} Collections(s) Selected", "ColonReplacement": "Colon Replacement", "ColonReplacementFormatHelpText": "Change how Radarr handles colon replacement", @@ -155,13 +155,13 @@ "Component": "Component", "Conditions": "Conditions", "Connect": "Connect", - "ConnectSettings": "Connect Settings", - "ConnectSettingsSummary": "Notifications, connections to media servers/players, and custom scripts", "Connection": "Connection", "ConnectionLost": "Connection Lost", "ConnectionLostAutomaticMessage": "Radarr will try to connect automatically, or you can click reload below.", "ConnectionLostMessage": "Radarr has lost its connection to the backend and will need to be reloaded to restore functionality.", "Connections": "Connections", + "ConnectSettings": "Connect Settings", + "ConnectSettingsSummary": "Notifications, connections to media servers/players, and custom scripts", "ConsideredAvailable": "Considered Available", "CopyToClipboard": "Copy to Clipboard", "CopyUsingHardlinksHelpText": "Hardlinks allow Radarr to import seeding torrents to the movie folder without taking extra disk space or copying the entire contents of the file. Hardlinks will only work if the source and destination are on the same volume", @@ -178,33 +178,35 @@ "CustomFormat": "Custom Format", "CustomFormatHelpText": "Radarr scores each release using the sum of scores for matching custom formats. If a new release would improve the score, at the same or better quality, then Radarr will grab it.", "CustomFormatJSON": "Custom Format JSON", - "CustomFormatScore": "Custom Format Score", - "CustomFormatUnknownCondition": "Unknown Custom Format condition '{0}'", - "CustomFormatUnknownConditionOption": "Unknown option '{0}' for condition '{1}'", "CustomFormats": "Custom Formats", + "CustomFormatScore": "Custom Format Score", "CustomFormatsSettings": "Custom Formats Settings", "CustomFormatsSettingsSummary": "Custom Formats and Settings", + "CustomFormatUnknownCondition": "Unknown Custom Format condition '{0}'", + "CustomFormatUnknownConditionOption": "Unknown option '{0}' for condition '{1}'", "Cutoff": "Cutoff", "CutoffFormatScoreHelpText": "Once this custom format score is reached Radarr will no longer download movies", "CutoffHelpText": "Once this quality is reached Radarr will no longer download movies", "CutoffUnmet": "Cut-off Unmet", - "DBMigration": "DB Migration", "Database": "Database", "Date": "Date", "Dates": "Dates", "Day": "Day", "Days": "Days", + "DBMigration": "DB Migration", "Debug": "Debug", "DefaultCase": "Default Case", "DefaultDelayProfile": "This is the default profile. It applies to all movies that don't have an explicit profile.", + "DelayingDownloadUntilInterp": "Delaying download until {0} at {1}", "DelayProfile": "Delay Profile", "DelayProfiles": "Delay Profiles", - "DelayingDownloadUntilInterp": "Delaying download until {0} at {1}", "Delete": "Delete", "DeleteBackup": "Delete Backup", "DeleteBackupMessageText": "Are you sure you want to delete the backup '{0}'?", "DeleteCustomFormat": "Delete Custom Format", + "Deleted": "Deleted", "DeleteDelayProfile": "Delete Delay Profile", + "DeletedMsg": "Movie was deleted from TMDb", "DeleteDownloadClient": "Delete Download Client", "DeleteDownloadClientMessageText": "Are you sure you want to delete the download client '{0}'?", "DeleteEmptyFolders": "Delete empty folders", @@ -232,8 +234,6 @@ "DeleteTag": "Delete Tag", "DeleteTagMessageText": "Are you sure you want to delete the tag '{0}'?", "DeleteTheMovieFolder": "The movie folder '{0}' and all its content will be deleted.", - "Deleted": "Deleted", - "DeletedMsg": "Movie was deleted from TMDb", "DestinationPath": "Destination Path", "DestinationRelativePath": "Destination Relative Path", "DetailedProgressBar": "Detailed Progress Bar", @@ -245,37 +245,37 @@ "DiscordUrlInSlackNotification": "You have a Discord notification setup as a Slack notification. Set this up as a Discord notification for better functionality. The effected notifications are: {0}", "Discover": "Discover", "DiskSpace": "Disk Space", - "DoNotPrefer": "Do Not Prefer", - "DoNotUpgradeAutomatically": "Do not Upgrade Automatically", "Docker": "Docker", "DockerUpdater": "update the docker container to receive the update", "Donations": "Donations", "DoneEditingGroups": "Done Editing Groups", + "DoNotPrefer": "Do Not Prefer", + "DoNotUpgradeAutomatically": "Do not Upgrade Automatically", "Download": "Download", "DownloadClient": "Download Client", "DownloadClientCheckDownloadingToRoot": "Download client {0} places downloads in the root folder {1}. You should not download to a root folder.", "DownloadClientCheckNoneAvailableMessage": "No download client is available", "DownloadClientCheckUnableToCommunicateMessage": "Unable to communicate with {0}.", + "DownloadClients": "Download Clients", "DownloadClientSettings": "Download Client Settings", "DownloadClientSortingCheckMessage": "Download client {0} has {1} sorting enabled for Radarr's category. You should disable sorting in your download client to avoid import issues.", + "DownloadClientsSettingsSummary": "Download clients, download handling and remote path mappings", "DownloadClientStatusCheckAllClientMessage": "All download clients are unavailable due to failures", "DownloadClientStatusCheckSingleClientMessage": "Download clients unavailable due to failures: {0}", "DownloadClientUnavailable": "Download client is unavailable", - "DownloadClients": "Download Clients", - "DownloadClientsSettingsSummary": "Download clients, download handling and remote path mappings", + "Downloaded": "Downloaded", + "DownloadedAndMonitored": "Downloaded (Monitored)", + "DownloadedButNotMonitored": "Downloaded (Unmonitored)", "DownloadFailed": "Download failed", "DownloadFailedCheckDownloadClientForMoreDetails": "Download failed: check download client for more details", "DownloadFailedInterp": "Download failed: {0}", + "Downloading": "Downloading", "DownloadPropersAndRepacks": "Propers and Repacks", "DownloadPropersAndRepacksHelpText1": "Whether or not to automatically upgrade to Propers/Repacks", "DownloadPropersAndRepacksHelpText2": "Use 'Do not Prefer' to sort by custom format score over Propers/Repacks", "DownloadPropersAndRepacksHelpTextWarning": "Use custom formats for automatic upgrades to Propers/Repacks", "DownloadWarning": "Download warning: {0}", "DownloadWarningCheckDownloadClientForMoreDetails": "Download warning: check download client for more details", - "Downloaded": "Downloaded", - "DownloadedAndMonitored": "Downloaded (Monitored)", - "DownloadedButNotMonitored": "Downloaded (Unmonitored)", - "Downloading": "Downloading", "Duration": "Duration", "Edit": "Edit", "EditCollection": "Edit Collection", @@ -283,6 +283,7 @@ "EditDelayProfile": "Edit Delay Profile", "EditGroups": "Edit Groups", "EditIndexer": "Edit Indexer", + "Edition": "Edition", "EditListExclusion": "Edit List Exclusion", "EditMovie": "Edit Movie", "EditMovieFile": "Edit Movie File", @@ -292,7 +293,6 @@ "EditRemotePathMapping": "Edit Remote Path Mapping", "EditRestriction": "Edit Restriction", "EditSelectedMovies": "Edit Selected Movies", - "Edition": "Edition", "Enable": "Enable", "EnableAutoHelpText": "If enabled, Movies will be automatically added to Radarr from this list", "EnableAutomaticAdd": "Enable Automatic Add", @@ -302,6 +302,8 @@ "EnableColorImpairedMode": "Enable Color-Impaired Mode", "EnableColorImpairedModeHelpText": "Altered style to allow color-impaired users to better distinguish color coded information", "EnableCompletedDownloadHandlingHelpText": "Automatically import completed downloads from download client", + "Enabled": "Enabled", + "EnabledHelpText": "Enable this list for use in Radarr", "EnableHelpText": "Enable metadata file creation for this metadata type", "EnableInteractiveSearch": "Enable Interactive Search", "EnableInteractiveSearchHelpText": "Will be used when interactive search is used", @@ -310,19 +312,17 @@ "EnableRSS": "Enable RSS", "EnableSSL": "Enable SSL", "EnableSslHelpText": " Requires restart running as administrator to take effect", - "Enabled": "Enabled", - "EnabledHelpText": "Enable this list for use in Radarr", "Ended": "Ended", "Error": "Error", "ErrorLoadingContents": "Error loading contents", "ErrorLoadingPreviews": "Error loading previews", "ErrorRestoringBackup": "Error restoring backup", - "EventType": "Event Type", "Events": "Events", + "EventType": "Event Type", "Exception": "Exception", + "Excluded": "Excluded", "ExcludeMovie": "Exclude Movie", "ExcludeTitle": "Exclude {0}? This will prevent Radarr from automatically adding via list sync.", - "Excluded": "Excluded", "Existing": "Existing", "ExistingMovies": "Existing Movie(s)", "ExistingTag": "Existing tag", @@ -340,12 +340,12 @@ "File": "File", "FileDateHelpText": "Change file date on import/rescan", "FileManagement": "File Management", - "FileNameTokens": "File Name Tokens", + "Filename": "Filename", "FileNames": "File Names", + "FileNameTokens": "File Name Tokens", + "Files": "Files", "FileWasDeletedByUpgrade": "File was deleted to import an upgrade", "FileWasDeletedByViaUI": "File was deleted via the UI", - "Filename": "Filename", - "Files": "Files", "Filter": "Filter", "FilterPlaceHolder": "Search movies", "Filters": "Filters", @@ -356,11 +356,11 @@ "FolderMoveRenameWarning": "This will also rename the movie folder per the movie folder format in settings.", "Folders": "Folders", "FollowPerson": "Follow Person", + "Forecast": "Forecast", + "Formats": "Formats", "ForMoreInformationOnTheIndividualDownloadClients": "For more information on the individual download clients, click the more info buttons.", "ForMoreInformationOnTheIndividualImportListsClinkOnTheInfoButtons": "For more information on the individual import lists, click on the info buttons.", "ForMoreInformationOnTheIndividualIndexers": "For more information on the individual indexers, click on the info buttons.", - "Forecast": "Forecast", - "Formats": "Formats", "FreeSpace": "Free Space", "From": "from", "General": "General", @@ -370,11 +370,11 @@ "Global": "Global", "GoToInterp": "Go to {0}", "Grab": "Grab", + "Grabbed": "Grabbed", "GrabID": "Grab ID", "GrabRelease": "Grab Release", "GrabReleaseMessageText": "Radarr was unable to determine which movie this release was for. Radarr may be unable to automatically import this release. Do you want to grab '{0}'?", "GrabSelected": "Grab Selected", - "Grabbed": "Grabbed", "Group": "Group", "HardlinkCopyFiles": "Hardlink/Copy Files", "HaveNotAddedMovies": "You haven't added any movies yet, do you want to import some or all of your movies first?", @@ -390,19 +390,22 @@ "HttpHttps": "HTTP(S)", "ICalFeed": "iCal Feed", "ICalHttpUrlHelpText": "Copy this URL to your client(s) or click to subscribe if your browser supports webcal", - "IMDb": "IMDb", + "iCalLink": "iCal Link", "IconForCutoffUnmet": "Icon for Cutoff Unmet", - "IgnoreDeletedMovies": "Unmonitor Deleted Movies", "Ignored": "Ignored", "IgnoredAddresses": "Ignored Addresses", + "IgnoreDeletedMovies": "Unmonitor Deleted Movies", "IgnoredHelpText": "The release will be rejected if it contains one or more of the terms (case insensitive)", "IgnoredPlaceHolder": "Add new restriction", "IllRestartLater": "I'll restart later", "Images": "Images", + "IMDb": "IMDb", "ImdbRating": "IMDb Rating", "ImdbVotes": "IMDb Votes", "Import": "Import", "ImportCustomFormat": "Import Custom Format", + "Imported": "Imported", + "ImportedTo": "Imported To", "ImportErrors": "Import Errors", "ImportExistingMovies": "Import Existing Movies", "ImportExtraFiles": "Import Extra Files", @@ -411,6 +414,7 @@ "ImportFailedInterp": "Import failed: {0}", "ImportHeader": "Import an existing organized library to add movies to Radarr", "ImportIncludeQuality": "Make sure that your files include the quality in their filenames. e.g. {0}", + "Importing": "Importing", "ImportLibrary": "Library Import", "ImportListMissingRoot": "Missing root folder for import list(s): {0}", "ImportListMultipleMissingRoots": "Multiple root folders are missing for import lists: {0}", @@ -422,9 +426,6 @@ "ImportNotForDownloads": "Do not use for importing downloads from your download client, this is only for existing organized libraries, not unsorted files.", "ImportRootPath": "Point Radarr to the folder containing all of your movies, not a specific movie. e.g. {0} and not {1}. Additionally, each movie must be in its own folder within the root/library folder.", "ImportTipsMessage": "Some tips to ensure the import goes smoothly:", - "Imported": "Imported", - "ImportedTo": "Imported To", - "Importing": "Importing", "InCinemas": "In Cinemas", "InCinemasDate": "In Cinemas Date", "InCinemasMsg": "Movie is in Cinemas", @@ -445,15 +446,15 @@ "IndexerPriorityHelpText": "Indexer Priority from 1 (Highest) to 50 (Lowest). Default: 25. Used when grabbing releases as a tiebreaker for otherwise equal releases, Radarr will still use all enabled indexers for RSS Sync and Searching", "IndexerRssHealthCheckNoAvailableIndexers": "All rss-capable indexers are temporarily unavailable due to recent indexer errors", "IndexerRssHealthCheckNoIndexers": "No indexers available with RSS sync enabled, Radarr will not grab new releases automatically", + "Indexers": "Indexers", "IndexerSearchCheckNoAutomaticMessage": "No indexers available with Automatic Search enabled, Radarr will not provide any automatic search results", "IndexerSearchCheckNoAvailableIndexersMessage": "All search-capable indexers are temporarily unavailable due to recent indexer errors", "IndexerSearchCheckNoInteractiveMessage": "No indexers available with Interactive Search enabled, Radarr will not provide any interactive search results", "IndexerSettings": "Indexer Settings", + "IndexersSettingsSummary": "Indexers and release restrictions", "IndexerStatusCheckAllClientMessage": "All indexers are unavailable due to failures", "IndexerStatusCheckSingleClientMessage": "Indexers unavailable due to failures: {0}", "IndexerTagHelpText": "Only use this indexer for movies with at least one matching tag. Leave blank to use with all movies.", - "Indexers": "Indexers", - "IndexersSettingsSummary": "Indexers and release restrictions", "Info": "Info", "InstallLatest": "Install Latest", "InstanceName": "Instance Name", @@ -482,13 +483,13 @@ "Links": "Links", "List": "List", "ListExclusions": "List Exclusions", + "Lists": "Lists", "ListSettings": "List Settings", + "ListsSettingsSummary": "Import Lists, list exclusions", "ListSyncLevelHelpText": "Movies in library will be handled based on your selection if they fall off or do not appear on your list(s)", "ListSyncLevelHelpTextWarning": "Movie files will be permanently deleted, this can result in wiping your library if your lists are empty", "ListTagsHelpText": "Tags list items will be added with", "ListUpdateInterval": "List Update Interval", - "Lists": "Lists", - "ListsSettingsSummary": "Import Lists, list exclusions", "Loading": "Loading", "LoadingMovieCreditsFailed": "Loading movie credits failed", "LoadingMovieExtraFilesFailed": "Loading movie extra files failed", @@ -497,15 +498,14 @@ "LocalPath": "Local Path", "Location": "Location", "LogFiles": "Log Files", + "Logging": "Logging", "LogLevel": "Log Level", "LogLevelTraceHelpTextWarning": "Trace logging should only be enabled temporarily", "LogOnly": "Log Only", - "Logging": "Logging", "Logs": "Logs", "LookingForReleaseProfiles1": "Looking for Release Profiles? Try", "LookingForReleaseProfiles2": "instead.", "LowerCase": "Lowercase", - "MIA": "MIA", "MaintenanceRelease": "Maintenance Release: bug fixes and other improvements. See Github Commit History for more details", "Manual": "Manual", "ManualImport": "Manual Import", @@ -532,6 +532,7 @@ "Metadata": "Metadata", "MetadataSettings": "Metadata Settings", "MetadataSettingsSummary": "Create metadata files when movies are imported or refreshed", + "MIA": "MIA", "Min": "Min", "MinAvailability": "Min Availability", "MinFormatScoreHelpText": "Minimum custom format score allowed to download", @@ -554,13 +555,13 @@ "Monday": "Monday", "Monitor": "Monitor", "MonitorCollection": "Monitor Collection", - "MonitorMovie": "Monitor Movie", - "MonitorMovies": "Monitor Movies", "Monitored": "Monitored", "MonitoredCollectionHelpText": "Monitor to automatically have movies from this collection added to the library", "MonitoredHelpText": "Download movie if available", "MonitoredOnly": "Monitored Only", "MonitoredStatus": "Monitored/Status", + "MonitorMovie": "Monitor Movie", + "MonitorMovies": "Monitor Movies", "Month": "Month", "Months": "Months", "More": "More", @@ -601,20 +602,20 @@ "MovieMatchType": "Movie Match Type", "MovieNaming": "Movie Naming", "MovieOnly": "Movie Only", + "Movies": "Movies", + "MoviesSelectedInterp": "{0} Movie(s) Selected", "MovieTitle": "Movie Title", "MovieTitleHelpText": "The title of the movie to exclude (can be anything meaningful)", "MovieYear": "Movie Year", "MovieYearHelpText": "The year of the movie to exclude", - "Movies": "Movies", - "MoviesSelectedInterp": "{0} Movie(s) Selected", "MultiLanguage": "Multi-Language", "MustContain": "Must Contain", "MustNotContain": "Must Not Contain", "Name": "Name", "NamingSettings": "Naming Settings", "Negate": "Negate", - "NegateHelpText": "If checked, the custom format will not apply if this {0} condition matches.", "Negated": "Negated", + "NegateHelpText": "If checked, the custom format will not apply if this {0} condition matches.", "NetCore": ".NET", "Never": "Never", "New": "New", @@ -635,15 +636,15 @@ "NoMinimumForAnyRuntime": "No minimum for any runtime", "NoMoveFilesSelf": " No, I'll Move the Files Myself", "NoMoviesExist": "No movies found, to get started you'll want to add a new movie or import some existing ones.", + "None": "None", "NoResultsFound": "No results found", "NoTagsHaveBeenAddedYet": "No tags have been added yet", - "NoUpdatesAreAvailable": "No updates are available", - "NoVideoFilesFoundSelectedFolder": "No video files were found in the selected folder", - "None": "None", "NotAvailable": "Not Available", - "NotMonitored": "Not Monitored", "NotificationTriggers": "Notification Triggers", "NotificationTriggersHelpText": "Select which events should trigger this notification", + "NotMonitored": "Not Monitored", + "NoUpdatesAreAvailable": "No updates are available", + "NoVideoFilesFoundSelectedFolder": "No video files were found in the selected folder", "OAuthPopupMessage": "Pop-ups are being blocked by your browser", "Ok": "Ok", "OnApplicationUpdate": "On Application Update", @@ -657,6 +658,8 @@ "OnHealthRestoredHelpText": "On Health Restored", "OnImport": "On Import", "OnLatestVersion": "The latest version of Radarr is already installed", + "OnlyTorrent": "Only Torrent", + "OnlyUsenet": "Only Usenet", "OnManualInteractionRequired": "On Manual Interaction Required", "OnManualInteractionRequiredHelpText": "On Manual Interaction Required", "OnMovieAdded": "On Movie Added", @@ -671,8 +674,6 @@ "OnRenameHelpText": "On Rename", "OnUpgrade": "On Upgrade", "OnUpgradeHelpText": "On Upgrade", - "OnlyTorrent": "Only Torrent", - "OnlyUsenet": "Only Usenet", "OpenBrowserOnStart": "Open browser on start", "OpenThisModal": "Open This Modal", "Options": "Options", @@ -707,16 +708,16 @@ "Port": "Port", "PortNumber": "Port Number", "PosterOptions": "Poster Options", - "PosterSize": "Poster Size", "Posters": "Posters", + "PosterSize": "Poster Size", "PreferAndUpgrade": "Prefer and Upgrade", "PreferIndexerFlags": "Prefer Indexer Flags", "PreferIndexerFlagsHelpText": "Prioritize releases with special flags", - "PreferTorrent": "Prefer Torrent", - "PreferUsenet": "Prefer Usenet", "Preferred": "Preferred", "PreferredProtocol": "Preferred Protocol", "PreferredSize": "Preferred Size", + "PreferTorrent": "Prefer Torrent", + "PreferUsenet": "Prefer Usenet", "Presets": "Presets", "PreviewRename": "Preview Rename", "PreviewRenameHelpText": "Tip: To preview a rename... select 'Cancel' then click any movie title and use the", @@ -754,15 +755,9 @@ "QualitySettings": "Quality Settings", "QualitySettingsSummary": "Quality sizes and naming", "Queue": "Queue", - "QueueIsEmpty": "Queue is empty", "Queued": "Queued", + "QueueIsEmpty": "Queue is empty", "QuickImport": "Move Automatically", - "RSS": "RSS", - "RSSHelpText": "Will be used when Radarr periodically looks for releases via RSS Sync", - "RSSIsNotSupportedWithThisIndexer": "RSS is not supported with this indexer", - "RSSSync": "RSS Sync", - "RSSSyncInterval": "RSS Sync Interval", - "RSSSyncIntervalHelpTextWarning": "This will apply to all indexers, please follow the rules set forth by them", "RadarrCalendarFeed": "Radarr Calendar Feed", "RadarrSupportsAnyDownloadClient": "Radarr supports many popular torrent and usenet download clients.", "RadarrSupportsAnyIndexer": "Radarr supports any indexer that uses the Newznab standard, as well as other indexers listed below.", @@ -796,14 +791,14 @@ "RejectionCount": "Rejection Count", "RelativePath": "Relative Path", "ReleaseBranchCheckOfficialBranchMessage": "Branch {0} is not a valid Radarr release branch, you will not receive updates", + "Released": "Released", "ReleaseDates": "Release Dates", + "ReleasedMsg": "Movie is released", "ReleaseGroup": "Release Group", "ReleaseRejected": "Release Rejected", "ReleaseStatus": "Release Status", "ReleaseTitle": "Release Title", "ReleaseWillBeProcessedInterp": "Release will be processed {0}", - "Released": "Released", - "ReleasedMsg": "Movie is released", "Reload": "Reload", "RemotePath": "Remote Path", "RemotePathMappingCheckBadDockerPath": "You are using docker; download client {0} places downloads in {1} but this is not a valid {2} path. Review your remote path mappings and download client settings.", @@ -824,9 +819,14 @@ "RemotePathMappings": "Remote Path Mappings", "Remove": "Remove", "RemoveCompleted": "Remove Completed", + "RemoveCompletedDownloads": "Remove Completed Downloads", "RemoveCompletedDownloadsHelpText": "Remove imported downloads from download client history", + "RemovedFromTaskQueue": "Removed from task queue", + "RemovedMovieCheckMultipleMessage": "Movies {0} were removed from TMDb", + "RemovedMovieCheckSingleMessage": "Movie {0} was removed from TMDb", "RemoveDownloadsAlert": "The Remove settings were moved to the individual Download Client settings in the table above.", "RemoveFailed": "Remove Failed", + "RemoveFailedDownloads": "Remove Failed Downloads", "RemoveFailedDownloadsHelpText": "Remove failed downloads from download client history", "RemoveFilter": "Remove filter", "RemoveFromBlocklist": "Remove from blocklist", @@ -840,14 +840,11 @@ "RemoveSelected": "Remove Selected", "RemoveSelectedItem": "Remove Selected Item", "RemoveSelectedItems": "Remove Selected Items", - "RemovedFromTaskQueue": "Removed from task queue", - "RemovedMovieCheckMultipleMessage": "Movies {0} were removed from TMDb", - "RemovedMovieCheckSingleMessage": "Movie {0} was removed from TMDb", "RemovingTag": "Removing tag", + "Renamed": "Renamed", "RenameFiles": "Rename Files", "RenameMovies": "Rename Movies", "RenameMoviesHelpText": "Radarr will use the existing file name if renaming is disabled", - "Renamed": "Renamed", "Reorder": "Reorder", "Replace": "Replace", "ReplaceIllegalCharacters": "Replace Illegal Characters", @@ -885,13 +882,14 @@ "RootFolderCheckSingleMessage": "Missing root folder: {0}", "RootFolders": "Root Folders", "RottenTomatoesRating": "Tomato Rating", + "RSS": "RSS", + "RSSHelpText": "Will be used when Radarr periodically looks for releases via RSS Sync", + "RSSIsNotSupportedWithThisIndexer": "RSS is not supported with this indexer", + "RSSSync": "RSS Sync", "RssSyncHelpText": "Interval in minutes. Set to zero to disable (this will stop all automatic release grabbing)", + "RSSSyncInterval": "RSS Sync Interval", + "RSSSyncIntervalHelpTextWarning": "This will apply to all indexers, please follow the rules set forth by them", "Runtime": "Runtime", - "SSLCertPassword": "SSL Cert Password", - "SSLCertPasswordHelpText": "Password for pfx file", - "SSLCertPath": "SSL Cert Path", - "SSLCertPathHelpText": "Path to pfx file", - "SSLPort": "SSL Port", "Save": "Save", "SaveChanges": "Save Changes", "SaveSettings": "Save Settings", @@ -963,6 +961,7 @@ "ShowMonitoredHelpText": "Show monitored status under poster", "ShowMovieInformation": "Show Movie Information", "ShowMovieInformationHelpText": "Show movie genres and certification", + "ShownClickToHide": "Shown, click to hide", "ShowOverview": "Show Overview", "ShowPath": "Show Path", "ShowPosters": "Show Posters", @@ -979,7 +978,6 @@ "ShowTitleHelpText": "Show movie title under poster", "ShowUnknownMovieItems": "Show Unknown Movie Items", "ShowYear": "Show Year", - "ShownClickToHide": "Shown, click to hide", "Shutdown": "Shutdown", "Size": "Size", "SizeLimit": "Size Limit", @@ -997,12 +995,17 @@ "SourceRelativePath": "Source Relative Path", "SourceTitle": "Source Title", "SqliteVersionCheckUpgradeRequiredMessage": "Currently installed SQLite version {0} is no longer supported. Please upgrade SQLite to at least version {1}.", + "SSLCertPassword": "SSL Cert Password", + "SSLCertPasswordHelpText": "Password for pfx file", + "SSLCertPath": "SSL Cert Path", + "SSLCertPathHelpText": "Path to pfx file", + "SSLPort": "SSL Port", "StandardMovieFormat": "Standard Movie Format", + "Started": "Started", "StartImport": "Start Import", "StartProcessing": "Start Processing", "StartSearchForMissingMovie": "Start search for missing movie", "StartTypingOrSelectAPathBelow": "Start typing or select a path below", - "Started": "Started", "StartupDirectory": "Startup directory", "Status": "Status", "StopSelecting": "Stop Selecting", @@ -1013,8 +1016,6 @@ "Sunday": "Sunday", "System": "System", "SystemTimeCheckMessage": "System time is off by more than 1 day. Scheduled tasks may not run correctly until the time is corrected", - "TMDBId": "TMDb Id", - "TMDb": "TMDb", "Table": "Table", "TableOptions": "Table Options", "TableOptionsColumnsMessage": "Choose which columns are visible and which order they appear in", @@ -1024,8 +1025,8 @@ "Tags": "Tags", "TagsHelpText": "Applies to movies with at least one matching tag", "TagsSettingsSummary": "See all tags and how they are used. Unused tags can be removed", - "TaskUserAgentTooltip": "User-Agent provided by the app that called the API", "Tasks": "Tasks", + "TaskUserAgentTooltip": "User-Agent provided by the app that called the API", "Test": "Test", "TestAll": "Test All", "TestAllClients": "Test All Clients", @@ -1041,6 +1042,8 @@ "Timeleft": "Time Left", "Title": "Title", "Titles": "Titles", + "TMDb": "TMDb", + "TMDBId": "TMDb Id", "TmdbIdHelpText": "The TMDb Id of the movie to exclude", "TmdbRating": "TMDb Rating", "TmdbVotes": "TMDb Votes", @@ -1066,7 +1069,6 @@ "UISettings": "UI Settings", "UISettingsSummary": "Calendar, date and color impaired options", "UMask": "UMask", - "URLBase": "URL Base", "UnableToAddANewConditionPleaseTryAgain": "Unable to add a new condition, please try again.", "UnableToAddANewCustomFormatPleaseTryAgain": "Unable to add a new custom format, please try again.", "UnableToAddANewDownloadClientPleaseTryAgain": "Unable to add a new download client, please try again.", @@ -1129,24 +1131,25 @@ "UpdateCheckUINotWritableMessage": "Cannot install update because UI folder '{0}' is not writable by the user '{1}'.", "UpdateFiltered": "Update Filtered", "UpdateMechanismHelpText": "Use Radarr's built-in updater or a script", + "Updates": "Updates", "UpdateScriptPathHelpText": "Path to a custom script that takes an extracted update package and handle the remainder of the update process", "UpdateSelected": "Update Selected", - "Updates": "Updates", "UpgradeAllowedHelpText": "If disabled qualities will not be upgraded", + "UpgradesAllowed": "Upgrades Allowed", "UpgradeUntilCustomFormatScore": "Upgrade Until Custom Format Score", "UpgradeUntilQuality": "Upgrade Until Quality", "UpgradeUntilThisQualityIsMetOrExceeded": "Upgrade until this quality is met or exceeded", - "UpgradesAllowed": "Upgrades Allowed", "UpperCase": "Uppercase", "Uptime": "Uptime", + "URLBase": "URL Base", "UrlBaseHelpText": "For reverse proxy support, default is empty", "UseHardlinksInsteadOfCopy": "Use Hardlinks instead of Copy", - "UseProxy": "Use Proxy", "Usenet": "Usenet", "UsenetDelay": "Usenet Delay", "UsenetDelayHelpText": "Delay in minutes to wait before grabbing a release from Usenet", "UsenetDelayTime": "Usenet Delay: {0}", "UsenetDisabled": "Usenet Disabled", + "UseProxy": "Use Proxy", "Username": "Username", "Version": "Version", "VersionUpdateText": "Version {0} of Radarr has been installed, in order to get the latest changes you'll need to reload Radarr.", @@ -1170,6 +1173,5 @@ "YesCancel": "Yes, Cancel", "YesMoveFiles": "Yes, Move the Files", "Yesterday": "Yesterday", - "YouCanAlsoSearch": "You can also search using TMDb ID or IMDb ID of a movie. e.g. `tmdb:71663`", - "iCalLink": "iCal Link" + "YouCanAlsoSearch": "You can also search using TMDb ID or IMDb ID of a movie. e.g. `tmdb:71663`" } diff --git a/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs index 3945d2a69..0cfeed9a8 100644 --- a/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/IProviderFactory.cs @@ -12,9 +12,12 @@ public interface IProviderFactory bool Exists(int id); TProviderDefinition Find(int id); TProviderDefinition Get(int id); + IEnumerable Get(IEnumerable ids); TProviderDefinition Create(TProviderDefinition definition); void Update(TProviderDefinition definition); + IEnumerable Update(IEnumerable definitions); void Delete(int id); + void Delete(IEnumerable ids); IEnumerable GetDefaultDefinitions(); IEnumerable GetPresetDefinitions(TProviderDefinition providerDefinition); void SetProviderCharacteristics(TProviderDefinition definition); diff --git a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs index b81b64446..782141963 100644 --- a/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs +++ b/src/NzbDrone.Core/ThingiProvider/ProviderFactory.cs @@ -101,6 +101,11 @@ public TProviderDefinition Get(int id) return _providerRepository.Get(id); } + public IEnumerable Get(IEnumerable ids) + { + return _providerRepository.Get(ids); + } + public TProviderDefinition Find(int id) { return _providerRepository.Find(id); @@ -120,12 +125,34 @@ public virtual void Update(TProviderDefinition definition) _eventAggregator.PublishEvent(new ProviderUpdatedEvent(definition)); } + public virtual IEnumerable Update(IEnumerable definitions) + { + _providerRepository.UpdateMany(definitions.ToList()); + + foreach (var definition in definitions) + { + _eventAggregator.PublishEvent(new ProviderUpdatedEvent(definition)); + } + + return definitions; + } + public void Delete(int id) { _providerRepository.Delete(id); _eventAggregator.PublishEvent(new ProviderDeletedEvent(id)); } + public void Delete(IEnumerable ids) + { + _providerRepository.DeleteMany(ids); + + foreach (var id in ids) + { + _eventAggregator.PublishEvent(new ProviderDeletedEvent(id)); + } + } + public TProvider GetInstance(TProviderDefinition definition) { var type = GetImplementation(definition); diff --git a/src/Radarr.Api.V3/DownloadClient/DownloadClientBulkResource.cs b/src/Radarr.Api.V3/DownloadClient/DownloadClientBulkResource.cs new file mode 100644 index 000000000..ea6ccba7c --- /dev/null +++ b/src/Radarr.Api.V3/DownloadClient/DownloadClientBulkResource.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using NzbDrone.Core.Download; + +namespace Radarr.Api.V3.DownloadClient +{ + public class DownloadClientBulkResource : ProviderBulkResource + { + public bool? Enable { get; set; } + public int? Priority { get; set; } + public bool? RemoveCompletedDownloads { get; set; } + public bool? RemoveFailedDownloads { get; set; } + } + + public class DownloadClientBulkResourceMapper : ProviderBulkResourceMapper + { + public override List UpdateModel(DownloadClientBulkResource resource, List existingDefinitions) + { + if (resource == null) + { + return new List(); + } + + existingDefinitions.ForEach(existing => + { + existing.Enable = resource.Enable ?? existing.Enable; + existing.Priority = resource.Priority ?? existing.Priority; + existing.RemoveCompletedDownloads = resource.RemoveCompletedDownloads ?? existing.RemoveCompletedDownloads; + existing.RemoveFailedDownloads = resource.RemoveFailedDownloads ?? existing.RemoveFailedDownloads; + }); + + return existingDefinitions; + } + } +} diff --git a/src/Radarr.Api.V3/DownloadClient/DownloadClientController.cs b/src/Radarr.Api.V3/DownloadClient/DownloadClientController.cs index a1f602a80..e834fc3a8 100644 --- a/src/Radarr.Api.V3/DownloadClient/DownloadClientController.cs +++ b/src/Radarr.Api.V3/DownloadClient/DownloadClientController.cs @@ -4,12 +4,13 @@ namespace Radarr.Api.V3.DownloadClient { [V3ApiController] - public class DownloadClientController : ProviderControllerBase + public class DownloadClientController : ProviderControllerBase { public static readonly DownloadClientResourceMapper ResourceMapper = new DownloadClientResourceMapper(); + public static readonly DownloadClientBulkResourceMapper BulkResourceMapper = new DownloadClientBulkResourceMapper(); public DownloadClientController(IDownloadClientFactory downloadClientFactory) - : base(downloadClientFactory, "downloadclient", ResourceMapper) + : base(downloadClientFactory, "downloadclient", ResourceMapper, BulkResourceMapper) { } } diff --git a/src/Radarr.Api.V3/ImportLists/ImportListBulkResource.cs b/src/Radarr.Api.V3/ImportLists/ImportListBulkResource.cs new file mode 100644 index 000000000..ac76f10dd --- /dev/null +++ b/src/Radarr.Api.V3/ImportLists/ImportListBulkResource.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using NzbDrone.Core.ImportLists; + +namespace Radarr.Api.V3.ImportLists +{ + public class ImportListBulkResource : ProviderBulkResource + { + public bool? EnableAuto { get; set; } + public string RootFolderPath { get; set; } + public int? ProfileId { get; set; } + } + + public class ImportListBulkResourceMapper : ProviderBulkResourceMapper + { + public override List UpdateModel(ImportListBulkResource resource, List existingDefinitions) + { + if (resource == null) + { + return new List(); + } + + existingDefinitions.ForEach(existing => + { + existing.EnableAuto = resource.EnableAuto ?? existing.EnableAuto; + existing.RootFolderPath = resource.RootFolderPath ?? existing.RootFolderPath; + existing.ProfileId = resource.ProfileId ?? existing.ProfileId; + }); + + return existingDefinitions; + } + } +} diff --git a/src/Radarr.Api.V3/ImportLists/ImportListController.cs b/src/Radarr.Api.V3/ImportLists/ImportListController.cs index 2b035eeb4..3f7306805 100644 --- a/src/Radarr.Api.V3/ImportLists/ImportListController.cs +++ b/src/Radarr.Api.V3/ImportLists/ImportListController.cs @@ -7,13 +7,13 @@ namespace Radarr.Api.V3.ImportLists { [V3ApiController] - public class ImportListController : ProviderControllerBase + public class ImportListController : ProviderControllerBase { public static readonly ImportListResourceMapper ResourceMapper = new ImportListResourceMapper(); + public static readonly ImportListBulkResourceMapper BulkResourceMapper = new ImportListBulkResourceMapper(); - public ImportListController(IImportListFactory importListFactory, - ProfileExistsValidator profileExistsValidator) - : base(importListFactory, "importlist", ResourceMapper) + public ImportListController(IImportListFactory importListFactory, ProfileExistsValidator profileExistsValidator) + : base(importListFactory, "importlist", ResourceMapper, BulkResourceMapper) { SharedValidator.RuleFor(c => c.RootFolderPath).IsValidPath(); SharedValidator.RuleFor(c => c.MinimumAvailability).NotNull(); diff --git a/src/Radarr.Api.V3/Indexers/IndexerBulkResource.cs b/src/Radarr.Api.V3/Indexers/IndexerBulkResource.cs new file mode 100644 index 000000000..d27f55268 --- /dev/null +++ b/src/Radarr.Api.V3/Indexers/IndexerBulkResource.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using NzbDrone.Core.Indexers; + +namespace Radarr.Api.V3.Indexers +{ + public class IndexerBulkResource : ProviderBulkResource + { + public bool? EnableRss { get; set; } + public bool? EnableAutomaticSearch { get; set; } + public bool? EnableInteractiveSearch { get; set; } + public int? Priority { get; set; } + } + + public class IndexerBulkResourceMapper : ProviderBulkResourceMapper + { + public override List UpdateModel(IndexerBulkResource resource, List existingDefinitions) + { + if (resource == null) + { + return new List(); + } + + existingDefinitions.ForEach(existing => + { + existing.EnableRss = resource.EnableRss ?? existing.EnableRss; + existing.EnableAutomaticSearch = resource.EnableAutomaticSearch ?? existing.EnableAutomaticSearch; + existing.EnableInteractiveSearch = resource.EnableInteractiveSearch ?? existing.EnableInteractiveSearch; + existing.Priority = resource.Priority ?? existing.Priority; + }); + + return existingDefinitions; + } + } +} diff --git a/src/Radarr.Api.V3/Indexers/IndexerController.cs b/src/Radarr.Api.V3/Indexers/IndexerController.cs index 2347d36a6..f372ea019 100644 --- a/src/Radarr.Api.V3/Indexers/IndexerController.cs +++ b/src/Radarr.Api.V3/Indexers/IndexerController.cs @@ -4,12 +4,13 @@ namespace Radarr.Api.V3.Indexers { [V3ApiController] - public class IndexerController : ProviderControllerBase + public class IndexerController : ProviderControllerBase { public static readonly IndexerResourceMapper ResourceMapper = new IndexerResourceMapper(); + public static readonly IndexerBulkResourceMapper BulkResourceMapper = new IndexerBulkResourceMapper(); public IndexerController(IndexerFactory indexerFactory) - : base(indexerFactory, "indexer", ResourceMapper) + : base(indexerFactory, "indexer", ResourceMapper, BulkResourceMapper) { } } diff --git a/src/Radarr.Api.V3/Metadata/MetadataBulkResource.cs b/src/Radarr.Api.V3/Metadata/MetadataBulkResource.cs new file mode 100644 index 000000000..287ab28ec --- /dev/null +++ b/src/Radarr.Api.V3/Metadata/MetadataBulkResource.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.Extras.Metadata; + +namespace Radarr.Api.V3.Metadata +{ + public class MetadataBulkResource : ProviderBulkResource + { + } + + public class MetadataBulkResourceMapper : ProviderBulkResourceMapper + { + } +} diff --git a/src/Radarr.Api.V3/Metadata/MetadataController.cs b/src/Radarr.Api.V3/Metadata/MetadataController.cs index 8342f5a6b..f07a8a303 100644 --- a/src/Radarr.Api.V3/Metadata/MetadataController.cs +++ b/src/Radarr.Api.V3/Metadata/MetadataController.cs @@ -4,12 +4,13 @@ namespace Radarr.Api.V3.Metadata { [V3ApiController] - public class MetadataController : ProviderControllerBase + public class MetadataController : ProviderControllerBase { public static readonly MetadataResourceMapper ResourceMapper = new MetadataResourceMapper(); + public static readonly MetadataBulkResourceMapper BulkResourceMapper = new MetadataBulkResourceMapper(); public MetadataController(IMetadataFactory metadataFactory) - : base(metadataFactory, "metadata", ResourceMapper) + : base(metadataFactory, "metadata", ResourceMapper, BulkResourceMapper) { } } diff --git a/src/Radarr.Api.V3/Notifications/NotificationBulkResource.cs b/src/Radarr.Api.V3/Notifications/NotificationBulkResource.cs new file mode 100644 index 000000000..1354234e9 --- /dev/null +++ b/src/Radarr.Api.V3/Notifications/NotificationBulkResource.cs @@ -0,0 +1,12 @@ +using NzbDrone.Core.Notifications; + +namespace Radarr.Api.V3.Notifications +{ + public class NotificationBulkResource : ProviderBulkResource + { + } + + public class NotificationBulkResourceMapper : ProviderBulkResourceMapper + { + } +} diff --git a/src/Radarr.Api.V3/Notifications/NotificationController.cs b/src/Radarr.Api.V3/Notifications/NotificationController.cs index 7cf5b3b2f..610973e9a 100644 --- a/src/Radarr.Api.V3/Notifications/NotificationController.cs +++ b/src/Radarr.Api.V3/Notifications/NotificationController.cs @@ -4,12 +4,13 @@ namespace Radarr.Api.V3.Notifications { [V3ApiController] - public class NotificationController : ProviderControllerBase + public class NotificationController : ProviderControllerBase { public static readonly NotificationResourceMapper ResourceMapper = new NotificationResourceMapper(); + public static readonly NotificationBulkResourceMapper BulkResourceMapper = new NotificationBulkResourceMapper(); public NotificationController(NotificationFactory notificationFactory) - : base(notificationFactory, "notification", ResourceMapper) + : base(notificationFactory, "notification", ResourceMapper, BulkResourceMapper) { } } diff --git a/src/Radarr.Api.V3/ProviderBulkResource.cs b/src/Radarr.Api.V3/ProviderBulkResource.cs new file mode 100644 index 000000000..4eb85a604 --- /dev/null +++ b/src/Radarr.Api.V3/ProviderBulkResource.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using NzbDrone.Core.ThingiProvider; +using Radarr.Api.V3.Movies; + +namespace Radarr.Api.V3 +{ + public class ProviderBulkResource + { + public List Ids { get; set; } + public List Tags { get; set; } + public ApplyTags ApplyTags { get; set; } + } + + public class ProviderBulkResourceMapper + where TProviderBulkResource : ProviderBulkResource, new() + where TProviderDefinition : ProviderDefinition, new() + { + public virtual List UpdateModel(TProviderBulkResource resource, List existingDefinitions) + { + if (resource == null) + { + return new List(); + } + + return existingDefinitions; + } + } +} diff --git a/src/Radarr.Api.V3/ProviderControllerBase.cs b/src/Radarr.Api.V3/ProviderControllerBase.cs index 9dad6d36d..86d137c14 100644 --- a/src/Radarr.Api.V3/ProviderControllerBase.cs +++ b/src/Radarr.Api.V3/ProviderControllerBase.cs @@ -6,23 +6,31 @@ using NzbDrone.Common.Serializer; using NzbDrone.Core.ThingiProvider; using NzbDrone.Core.Validation; +using Radarr.Api.V3.Movies; using Radarr.Http.REST; using Radarr.Http.REST.Attributes; namespace Radarr.Api.V3 { - public abstract class ProviderControllerBase : RestController + public abstract class ProviderControllerBase : RestController where TProviderDefinition : ProviderDefinition, new() where TProvider : IProvider where TProviderResource : ProviderResource, new() + where TBulkProviderResource : ProviderBulkResource, new() { private readonly IProviderFactory _providerFactory; private readonly ProviderResourceMapper _resourceMapper; + private readonly ProviderBulkResourceMapper _bulkResourceMapper; - protected ProviderControllerBase(IProviderFactory providerFactory, string resource, ProviderResourceMapper resourceMapper) + protected ProviderControllerBase(IProviderFactory providerFactory, + string resource, + ProviderResourceMapper resourceMapper, + ProviderBulkResourceMapper bulkResourceMapper) { _providerFactory = providerFactory; _resourceMapper = resourceMapper; + _bulkResourceMapper = bulkResourceMapper; SharedValidator.RuleFor(c => c.Name).NotEmpty(); SharedValidator.RuleFor(c => c.Name).Must((v, c) => !_providerFactory.All().Any(p => p.Name == c && p.Id != v.Id)).WithMessage("Should be unique"); @@ -88,6 +96,39 @@ public ActionResult UpdateProvider([FromBody] TProviderResour return Accepted(providerResource.Id); } + [HttpPut("bulk")] + [Consumes("application/json")] + public ActionResult UpdateProvider([FromBody] TBulkProviderResource providerResource) + { + var definitionsToUpdate = _providerFactory.Get(providerResource.Ids).ToList(); + + foreach (var definition in definitionsToUpdate) + { + if (providerResource.Tags != null) + { + var newTags = providerResource.Tags; + var applyTags = providerResource.ApplyTags; + + switch (applyTags) + { + case ApplyTags.Add: + newTags.ForEach(t => definition.Tags.Add(t)); + break; + case ApplyTags.Remove: + newTags.ForEach(t => definition.Tags.Remove(t)); + break; + case ApplyTags.Replace: + definition.Tags = new HashSet(newTags); + break; + } + } + } + + _bulkResourceMapper.UpdateModel(providerResource, definitionsToUpdate); + + return Accepted(_providerFactory.Update(definitionsToUpdate).Select(x => _resourceMapper.ToResource(x))); + } + private TProviderDefinition GetDefinition(TProviderResource providerResource, bool validate, bool includeWarnings, bool forceValidate) { var definition = _resourceMapper.ToModel(providerResource); @@ -107,6 +148,15 @@ public object DeleteProvider(int id) return new { }; } + [HttpDelete("bulk")] + [Consumes("application/json")] + public object DeleteProviders([FromBody] TBulkProviderResource resource) + { + _providerFactory.Delete(resource.Ids); + + return new { }; + } + [HttpGet("schema")] public List GetTemplates() {