diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index e6f33a849..9c9b9b096 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,4 +1,5 @@ import InteractiveImportAppState from 'App/State/InteractiveImportAppState'; +import CalendarAppState from './CalendarAppState'; import EpisodeFilesAppState from './EpisodeFilesAppState'; import EpisodesAppState from './EpisodesAppState'; import QueueAppState from './QueueAppState'; @@ -39,6 +40,7 @@ export interface CustomFilter { } interface AppState { + calendar: CalendarAppState; episodesSelection: EpisodesAppState; episodeFiles: EpisodeFilesAppState; interactiveImport: InteractiveImportAppState; diff --git a/frontend/src/App/State/CalendarAppState.ts b/frontend/src/App/State/CalendarAppState.ts new file mode 100644 index 000000000..304068528 --- /dev/null +++ b/frontend/src/App/State/CalendarAppState.ts @@ -0,0 +1,9 @@ +import AppSectionState from 'App/State/AppSectionState'; +import Episode from 'Episode/Episode'; +import { FilterBuilderProp } from './AppState'; + +interface CalendarAppState extends AppSectionState { + filterBuilderProps: FilterBuilderProp[]; +} + +export default CalendarAppState; diff --git a/frontend/src/Calendar/CalendarFilterModal.tsx b/frontend/src/Calendar/CalendarFilterModal.tsx new file mode 100644 index 000000000..c09f73743 --- /dev/null +++ b/frontend/src/Calendar/CalendarFilterModal.tsx @@ -0,0 +1,56 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setCalendarFilter } from 'Store/Actions/calendarActions'; + +function createCalendarSelector() { + return createSelector( + (state: AppState) => state.calendar.items, + (calendar) => { + return calendar; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.calendar.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +interface SeriesIndexFilterModalProps { + isOpen: boolean; +} + +export default function CalendarFilterModal( + props: SeriesIndexFilterModalProps +) { + const sectionItems = useSelector(createCalendarSelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'calendar'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload: unknown) => { + dispatch(setCalendarFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js index 7b5a987a7..d98d85683 100644 --- a/frontend/src/Calendar/CalendarPage.js +++ b/frontend/src/Calendar/CalendarPage.js @@ -11,6 +11,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import { align, icons } from 'Helpers/Props'; import NoSeries from 'Series/NoSeries'; import CalendarConnector from './CalendarConnector'; +import CalendarFilterModal from './CalendarFilterModal'; import CalendarLinkModal from './iCal/CalendarLinkModal'; import LegendConnector from './Legend/LegendConnector'; import CalendarOptionsModal from './Options/CalendarOptionsModal'; @@ -75,6 +76,7 @@ class CalendarPage extends Component { const { selectedFilterKey, filters, + customFilters, hasSeries, missingEpisodeIds, isRssSyncExecuting, @@ -132,7 +134,8 @@ class CalendarPage extends Component { isDisabled={!hasSeries} selectedFilterKey={selectedFilterKey} filters={filters} - customFilters={[]} + customFilters={customFilters} + filterModalConnectorComponent={CalendarFilterModal} onFilterSelect={onFilterSelect} /> @@ -178,6 +181,7 @@ class CalendarPage extends Component { CalendarPage.propTypes = { selectedFilterKey: PropTypes.string.isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, hasSeries: PropTypes.bool.isRequired, missingEpisodeIds: PropTypes.arrayOf(PropTypes.number).isRequired, isRssSyncExecuting: PropTypes.bool.isRequired, diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js index 350377c56..b47142b64 100644 --- a/frontend/src/Calendar/CalendarPageConnector.js +++ b/frontend/src/Calendar/CalendarPageConnector.js @@ -5,6 +5,7 @@ import * as commandNames from 'Commands/commandNames'; import withCurrentPage from 'Components/withCurrentPage'; import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions'; import { executeCommand } from 'Store/Actions/commandActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector'; @@ -59,6 +60,7 @@ function createMapStateToProps() { return createSelector( (state) => state.calendar.selectedFilterKey, (state) => state.calendar.filters, + createCustomFiltersSelector('calendar'), createSeriesCountSelector(), createUISettingsSelector(), createMissingEpisodeIdsSelector(), @@ -67,6 +69,7 @@ function createMapStateToProps() { ( selectedFilterKey, filters, + customFilters, seriesCount, uiSettings, missingEpisodeIds, @@ -76,6 +79,7 @@ function createMapStateToProps() { return { selectedFilterKey, filters, + customFilters, colorImpairedMode: uiSettings.enableColorImpairedMode, hasSeries: !!seriesCount, missingEpisodeIds, diff --git a/frontend/src/Helpers/Props/filterBuilderTypes.js b/frontend/src/Helpers/Props/filterBuilderTypes.js index f2ef6f2f8..c0806fabc 100644 --- a/frontend/src/Helpers/Props/filterBuilderTypes.js +++ b/frontend/src/Helpers/Props/filterBuilderTypes.js @@ -1,14 +1,18 @@ import * as filterTypes from './filterTypes'; export const ARRAY = 'array'; +export const CONTAINS = 'contains'; export const DATE = 'date'; +export const EQUAL = 'equal'; export const EXACT = 'exact'; export const NUMBER = 'number'; export const STRING = 'string'; export const all = [ ARRAY, + CONTAINS, DATE, + EQUAL, EXACT, NUMBER, STRING @@ -20,6 +24,10 @@ export const possibleFilterTypes = { { key: filterTypes.NOT_CONTAINS, value: 'does not contain' } ], + [CONTAINS]: [ + { key: filterTypes.CONTAINS, value: 'contains' } + ], + [DATE]: [ { key: filterTypes.LESS_THAN, value: 'is before' }, { key: filterTypes.GREATER_THAN, value: 'is after' }, @@ -29,6 +37,10 @@ export const possibleFilterTypes = { { key: filterTypes.NOT_IN_NEXT, value: 'not in the next' } ], + [EQUAL]: [ + { key: filterTypes.EQUAL, value: 'is' } + ], + [EXACT]: [ { key: filterTypes.EQUAL, value: 'is' }, { key: filterTypes.NOT_EQUAL, value: 'is not' } diff --git a/frontend/src/Store/Actions/calendarActions.js b/frontend/src/Store/Actions/calendarActions.js index 24d7d86b8..106de58bf 100644 --- a/frontend/src/Store/Actions/calendarActions.js +++ b/frontend/src/Store/Actions/calendarActions.js @@ -4,9 +4,10 @@ import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; import * as calendarViews from 'Calendar/calendarViews'; import * as commandNames from 'Commands/commandNames'; -import { filterTypes } from 'Helpers/Props'; +import { filterBuilderTypes, filterBuilderValueTypes, filterTypes } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; +import findSelectedFilters from 'Utilities/Filter/findSelectedFilters'; import { set, update } from './baseActions'; import { executeCommandHelper } from './commandActions'; import createHandleActions from './Creators/createHandleActions'; @@ -50,14 +51,16 @@ export const defaultState = { selectedFilterKey: 'monitored', + customFilters: [], + filters: [ { key: 'all', label: 'All', filters: [ { - key: 'monitored', - value: false, + key: 'unmonitored', + value: [true], type: filterTypes.EQUAL } ] @@ -67,20 +70,35 @@ export const defaultState = { label: 'Monitored Only', filters: [ { - key: 'monitored', - value: true, + key: 'unmonitored', + value: [false], type: filterTypes.EQUAL } ] } + ], + filterBuilderProps: [ + { + name: 'unmonitored', + label: 'Include Unmonitored', + type: filterBuilderTypes.EQUAL, + valueType: filterBuilderValueTypes.BOOL + }, + { + name: 'tags', + label: 'Tags', + type: filterBuilderTypes.CONTAINS, + valueType: filterBuilderValueTypes.TAG + } ] }; export const persistState = [ 'calendar.view', 'calendar.selectedFilterKey', - 'calendar.options' + 'calendar.options', + 'seriesIndex.customFilters' ]; // @@ -192,6 +210,10 @@ function isRangePopulated(start, end, state) { return false; } +function getCustomFilters(state, type) { + return state.customFilters.items.filter((customFilter) => customFilter.type === type); +} + // // Action Creators @@ -213,7 +235,8 @@ export const actionHandlers = handleThunks({ [FETCH_CALENDAR]: function(getState, payload, dispatch) { const state = getState(); const calendar = state.calendar; - const unmonitored = calendar.selectedFilterKey === 'all'; + const customFilters = getCustomFilters(state, section); + const selectedFilters = findSelectedFilters(calendar.selectedFilterKey, calendar.filters, customFilters); const { time = calendar.time, @@ -240,13 +263,26 @@ export const actionHandlers = handleThunks({ dispatch(set(attrs)); + const requestParams = { + start, + end + }; + + selectedFilters.forEach((selectedFilter) => { + if (selectedFilter.key === 'unmonitored') { + requestParams.unmonitored = selectedFilter.value.includes(true); + } + + if (selectedFilter.key === 'tags') { + requestParams.tags = selectedFilter.value.join(','); + } + }); + + requestParams.unmonitored = requestParams.unmonitored ?? false; + const promise = createAjaxRequest({ url: '/calendar', - data: { - unmonitored, - start, - end - } + data: requestParams }).request; promise.done((data) => { diff --git a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js index ae1031dca..1bac14f08 100644 --- a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js +++ b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js @@ -108,7 +108,7 @@ function sort(items, state) { return _.orderBy(items, clauses, orders); } -function createCustomFiltersSelector(type, alternateType) { +export function createCustomFiltersSelector(type, alternateType) { return createSelector( (state) => state.customFilters.items, (customFilters) => { diff --git a/frontend/src/Store/thunks.js b/frontend/src/Store/thunks.js deleted file mode 100644 index ebcf10917..000000000 --- a/frontend/src/Store/thunks.js +++ /dev/null @@ -1,28 +0,0 @@ -const thunks = {}; - -function identity(payload) { - return payload; -} - -export function createThunk(type, identityFunction = identity) { - return function(payload = {}) { - return function(dispatch, getState) { - const thunk = thunks[type]; - - if (thunk) { - return thunk(getState, identityFunction(payload), dispatch); - } - - throw Error(`Thunk handler has not been registered for ${type}`); - }; - }; -} - -export function handleThunks(handlers) { - const types = Object.keys(handlers); - - types.forEach((type) => { - thunks[type] = handlers[type]; - }); -} - diff --git a/frontend/src/Store/thunks.ts b/frontend/src/Store/thunks.ts new file mode 100644 index 000000000..7244e920e --- /dev/null +++ b/frontend/src/Store/thunks.ts @@ -0,0 +1,37 @@ +import { Dispatch } from 'redux'; +import AppState from 'App/State/AppState'; + +type GetState = () => AppState; +type Thunk = ( + getState: GetState, + identity: unknown, + dispatch: Dispatch +) => unknown; + +const thunks: Record = {}; + +function identity(payload: unknown) { + return payload; +} + +export function createThunk(type: string, identityFunction = identity) { + return function (payload: unknown = {}) { + return function (dispatch: Dispatch, getState: GetState) { + const thunk = thunks[type]; + + if (thunk) { + return thunk(getState, identityFunction(payload), dispatch); + } + + throw Error(`Thunk handler has not been registered for ${type}`); + }; + }; +} + +export function handleThunks(handlers: Record) { + const types = Object.keys(handlers); + + types.forEach((type) => { + thunks[type] = handlers[type]; + }); +} diff --git a/src/Sonarr.Api.V3/Calendar/CalendarController.cs b/src/Sonarr.Api.V3/Calendar/CalendarController.cs index f59bd1c8c..41a175435 100644 --- a/src/Sonarr.Api.V3/Calendar/CalendarController.cs +++ b/src/Sonarr.Api.V3/Calendar/CalendarController.cs @@ -2,8 +2,10 @@ using System; using System.Collections.Generic; using System.Linq; using Microsoft.AspNetCore.Mvc; +using NzbDrone.Common.Extensions; using NzbDrone.Core.CustomFormats; using NzbDrone.Core.DecisionEngine.Specifications; +using NzbDrone.Core.Tags; using NzbDrone.Core.Tv; using NzbDrone.SignalR; using Sonarr.Api.V3.Episodes; @@ -14,23 +16,53 @@ namespace Sonarr.Api.V3.Calendar [V3ApiController] public class CalendarController : EpisodeControllerWithSignalR { + private readonly ITagService _tagService; + public CalendarController(IBroadcastSignalRMessage signalR, IEpisodeService episodeService, ISeriesService seriesService, IUpgradableSpecification qualityUpgradableSpecification, + ITagService tagService, ICustomFormatCalculationService formatCalculator) : base(episodeService, seriesService, qualityUpgradableSpecification, formatCalculator, signalR) { + _tagService = tagService; } [HttpGet] [Produces("application/json")] - public List GetCalendar(DateTime? start, DateTime? end, bool unmonitored = false, bool includeSeries = false, bool includeEpisodeFile = false, bool includeEpisodeImages = false) + public List GetCalendar(DateTime? start, DateTime? end, bool unmonitored = false, bool includeSeries = false, bool includeEpisodeFile = false, bool includeEpisodeImages = false, string tags = "") { var startUse = start ?? DateTime.Today; var endUse = end ?? DateTime.Today.AddDays(2); + var episodes = _episodeService.EpisodesBetweenDates(startUse, endUse, unmonitored); + var allSeries = _seriesService.GetAllSeries(); + var parsedTags = new List(); + var result = new List(); + + if (tags.IsNotNullOrWhiteSpace()) + { + parsedTags.AddRange(tags.Split(',').Select(_tagService.GetTag).Select(t => t.Id)); + } + + foreach (var episode in episodes) + { + var series = allSeries.SingleOrDefault(s => s.Id == episode.SeriesId); + + if (series == null) + { + continue; + } + + if (parsedTags.Any() && parsedTags.None(series.Tags.Contains)) + { + continue; + } + + result.Add(episode); + } - var resources = MapToResource(_episodeService.EpisodesBetweenDates(startUse, endUse, unmonitored), includeSeries, includeEpisodeFile, includeEpisodeImages); + var resources = MapToResource(result, includeSeries, includeEpisodeFile, includeEpisodeImages); return resources.OrderBy(e => e.AirDateUtc).ToList(); }