1
0
mirror of https://github.com/Sonarr/Sonarr.git synced 2024-10-29 23:12:39 +01:00

New: Bulk manage custom formats

This commit is contained in:
Bogdan 2024-08-19 02:55:13 +03:00 committed by Mark McDowall
parent a9b93dd9c6
commit 4e14ce022c
28 changed files with 697 additions and 30 deletions

View File

@ -6,6 +6,7 @@ import AppSectionState, {
PagedAppSectionState, PagedAppSectionState,
} from 'App/State/AppSectionState'; } from 'App/State/AppSectionState';
import Language from 'Language/Language'; import Language from 'Language/Language';
import CustomFormat from 'typings/CustomFormat';
import DownloadClient from 'typings/DownloadClient'; import DownloadClient from 'typings/DownloadClient';
import ImportList from 'typings/ImportList'; import ImportList from 'typings/ImportList';
import ImportListExclusion from 'typings/ImportListExclusion'; import ImportListExclusion from 'typings/ImportListExclusion';
@ -48,6 +49,11 @@ export interface QualityProfilesAppState
extends AppSectionState<QualityProfile>, extends AppSectionState<QualityProfile>,
AppSectionItemSchemaState<QualityProfile> {} AppSectionItemSchemaState<QualityProfile> {}
export interface CustomFormatAppState
extends AppSectionState<CustomFormat>,
AppSectionDeleteState,
AppSectionSaveState {}
export interface ImportListOptionsSettingsAppState export interface ImportListOptionsSettingsAppState
extends AppSectionItemState<ImportListOptionsSettings>, extends AppSectionItemState<ImportListOptionsSettings>,
AppSectionSaveState {} AppSectionSaveState {}
@ -66,6 +72,7 @@ export type UiSettingsAppState = AppSectionItemState<UiSettings>;
interface SettingsAppState { interface SettingsAppState {
advancedSettings: boolean; advancedSettings: boolean;
customFormats: CustomFormatAppState;
downloadClients: DownloadClientAppState; downloadClients: DownloadClientAppState;
general: GeneralAppState; general: GeneralAppState;
importListExclusions: ImportListExclusionsSettingsAppState; importListExclusions: ImportListExclusionsSettingsAppState;

View File

@ -8,6 +8,7 @@ import ParseToolbarButton from 'Parse/ParseToolbarButton';
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector'; import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector';
import ManageCustomFormatsToolbarButton from './CustomFormats/Manage/ManageCustomFormatsToolbarButton';
function CustomFormatSettingsPage() { function CustomFormatSettingsPage() {
return ( return (
@ -21,6 +22,8 @@ function CustomFormatSettingsPage() {
<PageToolbarSeparator /> <PageToolbarSeparator />
<ParseToolbarButton /> <ParseToolbarButton />
<ManageCustomFormatsToolbarButton />
</> </>
} }
/> />

View File

@ -0,0 +1,28 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageCustomFormatsEditModalContent from './ManageCustomFormatsEditModalContent';
interface ManageCustomFormatsEditModalProps {
isOpen: boolean;
customFormatIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
function ManageCustomFormatsEditModal(
props: ManageCustomFormatsEditModalProps
) {
const { isOpen, customFormatIds, onSavePress, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageCustomFormatsEditModalContent
customFormatIds={customFormatIds}
onSavePress={onSavePress}
onModalClose={onModalClose}
/>
</Modal>
);
}
export default ManageCustomFormatsEditModal;

View File

@ -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;
}
}

View File

@ -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;

View File

@ -0,0 +1,125 @@
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 './ManageCustomFormatsEditModalContent.css';
interface SavePayload {
includeCustomFormatWhenRenaming?: boolean;
}
interface ManageCustomFormatsEditModalContentProps {
customFormatIds: number[];
onSavePress(payload: object): void;
onModalClose(): void;
}
const NO_CHANGE = 'noChange';
const enableOptions = [
{
key: NO_CHANGE,
get value() {
return translate('NoChange');
},
isDisabled: true,
},
{
key: 'enabled',
get value() {
return translate('Enabled');
},
},
{
key: 'disabled',
get value() {
return translate('Disabled');
},
},
];
function ManageCustomFormatsEditModalContent(
props: ManageCustomFormatsEditModalContentProps
) {
const { customFormatIds, onSavePress, onModalClose } = props;
const [includeCustomFormatWhenRenaming, setIncludeCustomFormatWhenRenaming] =
useState(NO_CHANGE);
const save = useCallback(() => {
let hasChanges = false;
const payload: SavePayload = {};
if (includeCustomFormatWhenRenaming !== NO_CHANGE) {
hasChanges = true;
payload.includeCustomFormatWhenRenaming =
includeCustomFormatWhenRenaming === 'enabled';
}
if (hasChanges) {
onSavePress(payload);
}
onModalClose();
}, [includeCustomFormatWhenRenaming, onSavePress, onModalClose]);
const onInputChange = useCallback(
({ name, value }: { name: string; value: string }) => {
switch (name) {
case 'includeCustomFormatWhenRenaming':
setIncludeCustomFormatWhenRenaming(value);
break;
default:
console.warn(
`EditCustomFormatsModalContent Unknown Input: '${name}'`
);
}
},
[]
);
const selectedCount = customFormatIds.length;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('EditSelectedCustomFormats')}</ModalHeader>
<ModalBody>
<FormGroup>
<FormLabel>{translate('IncludeCustomFormatWhenRenaming')}</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="includeCustomFormatWhenRenaming"
value={includeCustomFormatWhenRenaming}
values={enableOptions}
onChange={onInputChange}
/>
</FormGroup>
</ModalBody>
<ModalFooter className={styles.modalFooter}>
<div className={styles.selected}>
{translate('CountCustomFormatsSelected', {
count: selectedCount,
})}
</div>
<div>
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
<Button onPress={save}>{translate('ApplyChanges')}</Button>
</div>
</ModalFooter>
</ModalContent>
);
}
export default ManageCustomFormatsEditModalContent;

View File

@ -0,0 +1,20 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import ManageCustomFormatsModalContent from './ManageCustomFormatsModalContent';
interface ManageCustomFormatsModalProps {
isOpen: boolean;
onModalClose(): void;
}
function ManageCustomFormatsModal(props: ManageCustomFormatsModalProps) {
const { isOpen, onModalClose } = props;
return (
<Modal isOpen={isOpen} onModalClose={onModalClose}>
<ManageCustomFormatsModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default ManageCustomFormatsModal;

View File

@ -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;
}

View File

@ -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;

View File

@ -0,0 +1,241 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { CustomFormatAppState } from 'App/State/SettingsAppState';
import Alert from 'Components/Alert';
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 SortDirection from 'Helpers/Props/SortDirection';
import {
bulkDeleteCustomFormats,
bulkEditCustomFormats,
setManageCustomFormatsSort,
} from 'Store/Actions/settingsActions';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import { SelectStateInputProps } from 'typings/props';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import ManageCustomFormatsEditModal from './Edit/ManageCustomFormatsEditModal';
import ManageCustomFormatsModalRow from './ManageCustomFormatsModalRow';
import styles from './ManageCustomFormatsModalContent.css';
// TODO: This feels janky to do, but not sure of a better way currently
type OnSelectedChangeCallback = React.ComponentProps<
typeof ManageCustomFormatsModalRow
>['onSelectedChange'];
const COLUMNS = [
{
name: 'name',
label: () => translate('Name'),
isSortable: true,
isVisible: true,
},
{
name: 'includeCustomFormatWhenRenaming',
label: () => translate('IncludeCustomFormatWhenRenaming'),
isSortable: true,
isVisible: true,
},
];
interface ManageCustomFormatsModalContentProps {
onModalClose(): void;
sortKey?: string;
sortDirection?: SortDirection;
}
function ManageCustomFormatsModalContent(
props: ManageCustomFormatsModalContentProps
) {
const { onModalClose } = props;
const {
isFetching,
isPopulated,
isDeleting,
isSaving,
error,
items,
sortKey,
sortDirection,
}: CustomFormatAppState = useSelector(
createClientSideCollectionSelector('settings.customFormats')
);
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 onSortPress = useCallback(
(value: string) => {
dispatch(setManageCustomFormatsSort({ sortKey: value }));
},
[dispatch]
);
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(bulkDeleteCustomFormats({ ids: selectedIds }));
setIsDeleteModalOpen(false);
}, [selectedIds, dispatch]);
const onSavePress = useCallback(
(payload: object) => {
setIsEditModalOpen(false);
dispatch(
bulkEditCustomFormats({
ids: selectedIds,
...payload,
})
);
},
[selectedIds, dispatch]
);
const onSelectAllChange = useCallback(
({ value }: SelectStateInputProps) => {
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
},
[items, setSelectState]
);
const onSelectedChange = useCallback<OnSelectedChangeCallback>(
({ id, value, shiftKey = false }) => {
setSelectState({
type: 'toggleSelected',
items,
id,
isSelected: value,
shiftKey,
});
},
[items, setSelectState]
);
const errorMessage = getErrorMessage(error, 'Unable to load custom formats.');
const anySelected = selectedCount > 0;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>{translate('ManageCustomFormats')}</ModalHeader>
<ModalBody>
{isFetching ? <LoadingIndicator /> : null}
{error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length ? (
<Alert kind={kinds.INFO}>{translate('NoCustomFormatsFound')}</Alert>
) : null}
{isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table
columns={COLUMNS}
horizontalScroll={true}
selectAll={true}
allSelected={allSelected}
allUnselected={allUnselected}
sortKey={sortKey}
sortDirection={sortDirection}
onSelectAllChange={onSelectAllChange}
onSortPress={onSortPress}
>
<TableBody>
{items.map((item) => {
return (
<ManageCustomFormatsModalRow
key={item.id}
isSelected={selectedState[item.id]}
{...item}
columns={COLUMNS}
onSelectedChange={onSelectedChange}
/>
);
})}
</TableBody>
</Table>
) : null}
</ModalBody>
<ModalFooter>
<div className={styles.leftButtons}>
<SpinnerButton
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!anySelected}
onPress={onDeletePress}
>
{translate('Delete')}
</SpinnerButton>
<SpinnerButton
isSpinning={isSaving}
isDisabled={!anySelected}
onPress={onEditPress}
>
{translate('Edit')}
</SpinnerButton>
</div>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
<ManageCustomFormatsEditModal
isOpen={isEditModalOpen}
customFormatIds={selectedIds}
onModalClose={onEditModalClose}
onSavePress={onSavePress}
/>
<ConfirmModal
isOpen={isDeleteModalOpen}
kind={kinds.DANGER}
title={translate('DeleteSelectedCustomFormats')}
message={translate('DeleteSelectedCustomFormatsMessageText', {
count: selectedIds.length,
})}
confirmLabel={translate('Delete')}
onConfirm={onConfirmDelete}
onCancel={onDeleteModalClose}
/>
</ModalContent>
);
}
export default ManageCustomFormatsModalContent;

View File

@ -0,0 +1,6 @@
.name,
.includeCustomFormatWhenRenaming {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
word-break: break-all;
}

View File

@ -0,0 +1,8 @@
// This file is automatically generated.
// Please do not change this file!
interface CssExports {
'includeCustomFormatWhenRenaming': string;
'name': string;
}
export const cssExports: CssExports;
export default cssExports;

View File

@ -0,0 +1,54 @@
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 translate from 'Utilities/String/translate';
import styles from './ManageCustomFormatsModalRow.css';
interface ManageCustomFormatsModalRowProps {
id: number;
name: string;
includeCustomFormatWhenRenaming: boolean;
columns: Column[];
isSelected?: boolean;
onSelectedChange(result: SelectStateInputProps): void;
}
function ManageCustomFormatsModalRow(props: ManageCustomFormatsModalRowProps) {
const {
id,
isSelected,
name,
includeCustomFormatWhenRenaming,
onSelectedChange,
} = props;
const onSelectedChangeWrapper = useCallback(
(result: SelectStateInputProps) => {
onSelectedChange({
...result,
});
},
[onSelectedChange]
);
return (
<TableRow>
<TableSelectCell
id={id}
isSelected={isSelected}
onSelectedChange={onSelectedChangeWrapper}
/>
<TableRowCell className={styles.name}>{name}</TableRowCell>
<TableRowCell className={styles.includeCustomFormatWhenRenaming}>
{includeCustomFormatWhenRenaming ? translate('Yes') : translate('No')}
</TableRowCell>
</TableRow>
);
}
export default ManageCustomFormatsModalRow;

View File

@ -0,0 +1,28 @@
import React from 'react';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import ManageCustomFormatsModal from './ManageCustomFormatsModal';
function ManageCustomFormatsToolbarButton() {
const [isManageModalOpen, openManageModal, closeManageModal] =
useModalOpenState(false);
return (
<>
<PageToolbarButton
label={translate('ManageCustomFormats')}
iconName={icons.MANAGE}
onPress={openManageModal}
/>
<ManageCustomFormatsModal
isOpen={isManageModalOpen}
onModalClose={closeManageModal}
/>
</>
);
}
export default ManageCustomFormatsToolbarButton;

View File

@ -220,9 +220,9 @@ function ManageDownloadClientsModalContent(
{error ? <div>{errorMessage}</div> : null} {error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length && ( {isPopulated && !error && !items.length ? (
<Alert kind={kinds.INFO}>{translate('NoDownloadClientsFound')}</Alert> <Alert kind={kinds.INFO}>{translate('NoDownloadClientsFound')}</Alert>
)} ) : null}
{isPopulated && !!items.length && !isFetching && !isFetching ? ( {isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table <Table

View File

@ -198,9 +198,9 @@ function ManageImportListsModalContent(
{error ? <div>{errorMessage}</div> : null} {error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length && ( {isPopulated && !error && !items.length ? (
<Alert kind={kinds.INFO}>{translate('NoImportListsFound')}</Alert> <Alert kind={kinds.INFO}>{translate('NoImportListsFound')}</Alert>
)} ) : null}
{isPopulated && !!items.length && !isFetching && !isFetching ? ( {isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table <Table

View File

@ -215,9 +215,9 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
{error ? <div>{errorMessage}</div> : null} {error ? <div>{errorMessage}</div> : null}
{isPopulated && !error && !items.length && ( {isPopulated && !error && !items.length ? (
<Alert kind={kinds.INFO}>{translate('NoIndexersFound')}</Alert> <Alert kind={kinds.INFO}>{translate('NoIndexersFound')}</Alert>
)} ) : null}
{isPopulated && !!items.length && !isFetching && !isFetching ? ( {isPopulated && !!items.length && !isFetching && !isFetching ? (
<Table <Table

View File

@ -1,7 +1,12 @@
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import { sortDirections } from 'Helpers/Props';
import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler'; import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler'; import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler'; import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
import createSetClientSideCollectionSortReducer
from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer';
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer'; import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
import { createThunk } from 'Store/thunks'; import { createThunk } from 'Store/thunks';
import getSectionState from 'Utilities/State/getSectionState'; import getSectionState from 'Utilities/State/getSectionState';
@ -22,6 +27,9 @@ export const SAVE_CUSTOM_FORMAT = 'settings/customFormats/saveCustomFormat';
export const DELETE_CUSTOM_FORMAT = 'settings/customFormats/deleteCustomFormat'; export const DELETE_CUSTOM_FORMAT = 'settings/customFormats/deleteCustomFormat';
export const SET_CUSTOM_FORMAT_VALUE = 'settings/customFormats/setCustomFormatValue'; export const SET_CUSTOM_FORMAT_VALUE = 'settings/customFormats/setCustomFormatValue';
export const CLONE_CUSTOM_FORMAT = 'settings/customFormats/cloneCustomFormat'; export const CLONE_CUSTOM_FORMAT = 'settings/customFormats/cloneCustomFormat';
export const BULK_EDIT_CUSTOM_FORMATS = 'settings/downloadClients/bulkEditCustomFormats';
export const BULK_DELETE_CUSTOM_FORMATS = 'settings/downloadClients/bulkDeleteCustomFormats';
export const SET_MANAGE_CUSTOM_FORMATS_SORT = 'settings/downloadClients/setManageCustomFormatsSort';
// //
// Action Creators // Action Creators
@ -29,6 +37,9 @@ export const CLONE_CUSTOM_FORMAT = 'settings/customFormats/cloneCustomFormat';
export const fetchCustomFormats = createThunk(FETCH_CUSTOM_FORMATS); export const fetchCustomFormats = createThunk(FETCH_CUSTOM_FORMATS);
export const saveCustomFormat = createThunk(SAVE_CUSTOM_FORMAT); export const saveCustomFormat = createThunk(SAVE_CUSTOM_FORMAT);
export const deleteCustomFormat = createThunk(DELETE_CUSTOM_FORMAT); export const deleteCustomFormat = createThunk(DELETE_CUSTOM_FORMAT);
export const bulkEditCustomFormats = createThunk(BULK_EDIT_CUSTOM_FORMATS);
export const bulkDeleteCustomFormats = createThunk(BULK_DELETE_CUSTOM_FORMATS);
export const setManageCustomFormatsSort = createAction(SET_MANAGE_CUSTOM_FORMATS_SORT);
export const setCustomFormatValue = createAction(SET_CUSTOM_FORMAT_VALUE, (payload) => { export const setCustomFormatValue = createAction(SET_CUSTOM_FORMAT_VALUE, (payload) => {
return { return {
@ -48,20 +59,30 @@ export default {
// State // State
defaultState: { defaultState: {
isSchemaFetching: false,
isSchemaPopulated: false,
isFetching: false, isFetching: false,
isPopulated: false, isPopulated: false,
error: null,
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null,
items: [],
pendingChanges: {},
isSchemaFetching: false,
isSchemaPopulated: false,
schemaError: null,
schema: { schema: {
includeCustomFormatWhenRenaming: false includeCustomFormatWhenRenaming: false
}, },
error: null,
isDeleting: false, sortKey: 'name',
deleteError: null, sortDirection: sortDirections.ASCENDING,
isSaving: false, sortPredicates: {
saveError: null, name: ({ name }) => {
items: [], return name.toLocaleLowerCase();
pendingChanges: {} }
}
}, },
// //
@ -83,7 +104,10 @@ export default {
})); }));
createSaveProviderHandler(section, '/customformat')(getState, payload, dispatch); createSaveProviderHandler(section, '/customformat')(getState, payload, dispatch);
} },
[BULK_EDIT_CUSTOM_FORMATS]: createBulkEditItemHandler(section, '/customformat/bulk'),
[BULK_DELETE_CUSTOM_FORMATS]: createBulkRemoveItemHandler(section, '/customformat/bulk')
}, },
// //
@ -103,7 +127,9 @@ export default {
newState.pendingChanges = pendingChanges; newState.pendingChanges = pendingChanges;
return updateSectionState(state, section, newState); return updateSectionState(state, section, newState);
} },
[SET_MANAGE_CUSTOM_FORMATS_SORT]: createSetClientSideCollectionSortReducer(section)
} }
}; };

View File

@ -96,8 +96,8 @@ export default {
sortKey: 'name', sortKey: 'name',
sortDirection: sortDirections.ASCENDING, sortDirection: sortDirections.ASCENDING,
sortPredicates: { sortPredicates: {
name: function(item) { name: ({ name }) => {
return item.name.toLowerCase(); return name.toLocaleLowerCase();
} }
} }
}, },

View File

@ -101,8 +101,8 @@ export default {
sortKey: 'name', sortKey: 'name',
sortDirection: sortDirections.ASCENDING, sortDirection: sortDirections.ASCENDING,
sortPredicates: { sortPredicates: {
name: function(item) { name: ({ name }) => {
return item.name.toLowerCase(); return name.toLocaleLowerCase();
} }
} }
}, },

View File

@ -1,12 +1,14 @@
import ModelBase from 'App/ModelBase';
export interface QualityProfileFormatItem { export interface QualityProfileFormatItem {
format: number; format: number;
name: string; name: string;
score: number; score: number;
} }
interface CustomFormat { interface CustomFormat extends ModelBase {
id: number;
name: string; name: string;
includeCustomFormatWhenRenaming: boolean;
} }
export default CustomFormat; export default CustomFormat;

View File

@ -9,10 +9,12 @@ namespace NzbDrone.Core.CustomFormats
public interface ICustomFormatService public interface ICustomFormatService
{ {
void Update(CustomFormat customFormat); void Update(CustomFormat customFormat);
void Update(List<CustomFormat> customFormat);
CustomFormat Insert(CustomFormat customFormat); CustomFormat Insert(CustomFormat customFormat);
List<CustomFormat> All(); List<CustomFormat> All();
CustomFormat GetById(int id); CustomFormat GetById(int id);
void Delete(int id); void Delete(int id);
void Delete(List<int> ids);
} }
public class CustomFormatService : ICustomFormatService public class CustomFormatService : ICustomFormatService
@ -51,6 +53,12 @@ namespace NzbDrone.Core.CustomFormats
_cache.Clear(); _cache.Clear();
} }
public void Update(List<CustomFormat> customFormat)
{
_formatRepository.UpdateMany(customFormat);
_cache.Clear();
}
public CustomFormat Insert(CustomFormat customFormat) public CustomFormat Insert(CustomFormat customFormat)
{ {
// Add to DB then insert into profiles // Add to DB then insert into profiles
@ -72,5 +80,20 @@ namespace NzbDrone.Core.CustomFormats
_formatRepository.Delete(id); _formatRepository.Delete(id);
_cache.Clear(); _cache.Clear();
} }
public void Delete(List<int> ids)
{
foreach (var id in ids)
{
var format = _formatRepository.Get(id);
// Remove from profiles before removing from DB
_eventAggregator.PublishEvent(new CustomFormatDeletedEvent(format));
_formatRepository.Delete(id);
}
_cache.Clear();
}
} }
} }

View File

@ -258,6 +258,7 @@
"CopyUsingHardlinksHelpTextWarning": "Occasionally, file locks may prevent renaming files that are being seeded. You may temporarily disable seeding and use {appName}'s rename function as a work around.", "CopyUsingHardlinksHelpTextWarning": "Occasionally, file locks may prevent renaming files that are being seeded. You may temporarily disable seeding and use {appName}'s rename function as a work around.",
"CopyUsingHardlinksSeriesHelpText": "Hardlinks allow {appName} to import seeding torrents to the series 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", "CopyUsingHardlinksSeriesHelpText": "Hardlinks allow {appName} to import seeding torrents to the series 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",
"CouldNotFindResults": "Couldn't find any results for '{term}'", "CouldNotFindResults": "Couldn't find any results for '{term}'",
"CountCustomFormatsSelected": "{count} custom formats(s) selected",
"CountDownloadClientsSelected": "{count} download client(s) selected", "CountDownloadClientsSelected": "{count} download client(s) selected",
"CountImportListsSelected": "{count} import list(s) selected", "CountImportListsSelected": "{count} import list(s) selected",
"CountIndexersSelected": "{count} indexer(s) selected", "CountIndexersSelected": "{count} indexer(s) selected",
@ -364,6 +365,8 @@
"DeleteRootFolder": "Delete Root Folder", "DeleteRootFolder": "Delete Root Folder",
"DeleteRootFolderMessageText": "Are you sure you want to delete the root folder '{path}'?", "DeleteRootFolderMessageText": "Are you sure you want to delete the root folder '{path}'?",
"DeleteSelected": "Delete Selected", "DeleteSelected": "Delete Selected",
"DeleteSelectedCustomFormats": "Delete Custom Format(s)",
"DeleteSelectedCustomFormatsMessageText": "Are you sure you want to delete {count} selected custom format(s)?",
"DeleteSelectedDownloadClients": "Delete Download Client(s)", "DeleteSelectedDownloadClients": "Delete Download Client(s)",
"DeleteSelectedDownloadClientsMessageText": "Are you sure you want to delete {count} selected download client(s)?", "DeleteSelectedDownloadClientsMessageText": "Are you sure you want to delete {count} selected download client(s)?",
"DeleteSelectedEpisodeFiles": "Delete Selected Episode Files", "DeleteSelectedEpisodeFiles": "Delete Selected Episode Files",
@ -590,6 +593,7 @@
"EditReleaseProfile": "Edit Release Profile", "EditReleaseProfile": "Edit Release Profile",
"EditRemotePathMapping": "Edit Remote Path Mapping", "EditRemotePathMapping": "Edit Remote Path Mapping",
"EditRestriction": "Edit Restriction", "EditRestriction": "Edit Restriction",
"EditSelectedCustomFormats": "Edit Selected Custom Formats",
"EditSelectedDownloadClients": "Edit Selected Download Clients", "EditSelectedDownloadClients": "Edit Selected Download Clients",
"EditSelectedImportLists": "Edit Selected Import Lists", "EditSelectedImportLists": "Edit Selected Import Lists",
"EditSelectedIndexers": "Edit Selected Indexers", "EditSelectedIndexers": "Edit Selected Indexers",
@ -1106,6 +1110,7 @@
"Lowercase": "Lowercase", "Lowercase": "Lowercase",
"MaintenanceRelease": "Maintenance Release: bug fixes and other improvements. See Github Commit History for more details", "MaintenanceRelease": "Maintenance Release: bug fixes and other improvements. See Github Commit History for more details",
"ManageClients": "Manage Clients", "ManageClients": "Manage Clients",
"ManageCustomFormats": "Manage Custom Formats",
"ManageDownloadClients": "Manage Download Clients", "ManageDownloadClients": "Manage Download Clients",
"ManageEpisodes": "Manage Episodes", "ManageEpisodes": "Manage Episodes",
"ManageEpisodesSeason": "Manage Episodes files in this season", "ManageEpisodesSeason": "Manage Episodes files in this season",
@ -1255,6 +1260,7 @@
"NoBlocklistItems": "No blocklist items", "NoBlocklistItems": "No blocklist items",
"NoChange": "No Change", "NoChange": "No Change",
"NoChanges": "No Changes", "NoChanges": "No Changes",
"NoCustomFormatsFound": "No custom formats found",
"NoDelay": "No Delay", "NoDelay": "No Delay",
"NoDownloadClientsFound": "No download clients found", "NoDownloadClientsFound": "No download clients found",
"NoEpisodeHistory": "No episode history", "NoEpisodeHistory": "No episode history",

View File

@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace Sonarr.Api.V3.CustomFormats
{
public class CustomFormatBulkResource
{
public HashSet<int> Ids { get; set; } = new ();
public bool? IncludeCustomFormatWhenRenaming { get; set; }
}
}

View File

@ -47,6 +47,13 @@ namespace Sonarr.Api.V3.CustomFormats
return _formatService.GetById(id).ToResource(true); return _formatService.GetById(id).ToResource(true);
} }
[HttpGet]
[Produces("application/json")]
public List<CustomFormatResource> GetAll()
{
return _formatService.All().ToResource(true);
}
[RestPostById] [RestPostById]
[Consumes("application/json")] [Consumes("application/json")]
public ActionResult<CustomFormatResource> Create([FromBody] CustomFormatResource customFormatResource) public ActionResult<CustomFormatResource> Create([FromBody] CustomFormatResource customFormatResource)
@ -71,11 +78,26 @@ namespace Sonarr.Api.V3.CustomFormats
return Accepted(model.Id); return Accepted(model.Id);
} }
[HttpGet] [HttpPut("bulk")]
[Consumes("application/json")]
[Produces("application/json")] [Produces("application/json")]
public List<CustomFormatResource> GetAll() public virtual ActionResult<CustomFormatResource> Update([FromBody] CustomFormatBulkResource resource)
{ {
return _formatService.All().ToResource(true); if (!resource.Ids.Any())
{
throw new BadRequestException("ids must be provided");
}
var customFormats = resource.Ids.Select(id => _formatService.GetById(id)).ToList();
customFormats.ForEach(existing =>
{
existing.IncludeCustomFormatWhenRenaming = resource.IncludeCustomFormatWhenRenaming ?? existing.IncludeCustomFormatWhenRenaming;
});
_formatService.Update(customFormats);
return Accepted(customFormats.ConvertAll(cf => cf.ToResource(true)));
} }
[RestDeleteById] [RestDeleteById]
@ -84,12 +106,21 @@ namespace Sonarr.Api.V3.CustomFormats
_formatService.Delete(id); _formatService.Delete(id);
} }
[HttpDelete("bulk")]
[Consumes("application/json")]
public virtual object DeleteFormats([FromBody] CustomFormatBulkResource resource)
{
_formatService.Delete(resource.Ids.ToList());
return new { };
}
[HttpGet("schema")] [HttpGet("schema")]
public object GetTemplates() public object GetTemplates()
{ {
var schema = _specifications.OrderBy(x => x.Order).Select(x => x.ToSchema()).ToList(); var schema = _specifications.OrderBy(x => x.Order).Select(x => x.ToSchema()).ToList();
var presets = GetPresets(); var presets = GetPresets().ToList();
foreach (var item in schema) foreach (var item in schema)
{ {