diff --git a/frontend/src/Author/Details/AuthorDetails.js b/frontend/src/Author/Details/AuthorDetails.js index 4eb43455d..310f18c3f 100644 --- a/frontend/src/Author/Details/AuthorDetails.js +++ b/frontend/src/Author/Details/AuthorDetails.js @@ -5,6 +5,7 @@ import DeleteAuthorModal from 'Author/Delete/DeleteAuthorModal'; import EditAuthorModalConnector from 'Author/Edit/EditAuthorModalConnector'; import AuthorHistoryTable from 'Author/History/AuthorHistoryTable'; import MonitoringOptionsModal from 'Author/MonitoringOptions/MonitoringOptionsModal'; +import BookEditorFooter from 'Book/Editor/BookEditorFooter'; import BookFileEditorTable from 'BookFile/Editor/BookFileEditorTable'; import IconButton from 'Components/Link/IconButton'; import Link from 'Components/Link/Link'; @@ -22,6 +23,7 @@ import InteractiveSearchTable from 'InteractiveSearch/InteractiveSearchTable'; import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector'; import translate from 'Utilities/String/translate'; +import getSelectedIds from 'Utilities/Table/getSelectedIds'; import selectAll from 'Utilities/Table/selectAll'; import toggleSelected from 'Utilities/Table/toggleSelected'; import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal'; @@ -53,13 +55,56 @@ class AuthorDetails extends Component { isDeleteAuthorModalOpen: false, isInteractiveImportModalOpen: false, isMonitorOptionsModalOpen: false, + isBookEditorActive: false, allExpanded: false, allCollapsed: false, expandedState: {}, + allSelected: false, + allUnselected: false, + lastToggled: null, + selectedState: {}, selectedTabIndex: 0 }; } + // + // Control + + setSelectedState = (items) => { + const { + selectedState + } = this.state; + + const newSelectedState = {}; + + items.forEach((item) => { + const isItemSelected = selectedState[item.id]; + + if (isItemSelected) { + newSelectedState[item.id] = isItemSelected; + } else { + newSelectedState[item.id] = false; + } + }); + + const selectedCount = getSelectedIds(newSelectedState).length; + const newStateCount = Object.keys(newSelectedState).length; + let isAllSelected = false; + let isAllUnselected = false; + + if (selectedCount === 0) { + isAllUnselected = true; + } else if (selectedCount === newStateCount) { + isAllSelected = true; + } + + this.setState({ selectedState: newSelectedState, allSelected: isAllSelected, allUnselected: isAllUnselected }); + } + + getSelectedIds = () => { + return getSelectedIds(this.state.selectedState); + } + // // Listeners @@ -114,6 +159,10 @@ class AuthorDetails extends Component { this.setState({ isMonitorOptionsModalOpen: false }); } + onBookEditorTogglePress = () => { + this.setState({ isBookEditorActive: !this.state.isBookEditorActive }); + } + onExpandAllPress = () => { const { allExpanded, @@ -137,6 +186,27 @@ class AuthorDetails extends Component { }); } + onSelectAllChange = ({ value }) => { + this.setState(selectAll(this.state.selectedState, value)); + } + + onSelectAllPress = () => { + this.onSelectAllChange({ value: !this.state.allSelected }); + } + + onSelectedChange = (items, id, value, shiftKey = false) => { + this.setState((state) => { + return toggleSelected(state, items, id, value, shiftKey); + }); + } + + onSaveSelected = (changes) => { + this.props.onSaveSelected({ + bookIds: this.getSelectedIds(), + ...changes + }); + } + onTabSelect = (index, lastIndex) => { this.setState({ selectedTabIndex: index }); } @@ -165,6 +235,10 @@ class AuthorDetails extends Component { nextAuthor, onRefreshPress, onSearchPress, + isSaving, + saveError, + isDeleting, + deleteError, statistics } = this.props; @@ -175,6 +249,9 @@ class AuthorDetails extends Component { isDeleteAuthorModalOpen, isInteractiveImportModalOpen, isMonitorOptionsModalOpen, + isBookEditorActive, + allSelected, + selectedState, allExpanded, allCollapsed, expandedState, @@ -189,6 +266,8 @@ class AuthorDetails extends Component { expandIcon = icons.EXPAND; } + const selectedBookIds = this.getSelectedIds(); + return ( @@ -252,6 +331,33 @@ class AuthorDetails extends Component { iconName={icons.DELETE} onPress={this.onDeleteAuthorPress} /> + + + + { + isBookEditorActive ? + : + + } + + { + isBookEditorActive ? + : + null + } + @@ -377,7 +483,11 @@ class AuthorDetails extends Component { @@ -422,7 +532,6 @@ class AuthorDetails extends Component { } -
@@ -474,6 +583,19 @@ class AuthorDetails extends Component { onModalClose={this.onMonitorOptionsClose} /> + + { + isBookEditorActive && + + } ); } @@ -493,7 +615,6 @@ AuthorDetails.propTypes = { images: PropTypes.arrayOf(PropTypes.object).isRequired, alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired, tags: PropTypes.arrayOf(PropTypes.number).isRequired, - isSaving: PropTypes.bool.isRequired, isRefreshing: PropTypes.bool.isRequired, isSearching: PropTypes.bool.isRequired, isFetching: PropTypes.bool.isRequired, @@ -510,13 +631,17 @@ AuthorDetails.propTypes = { isSmallScreen: PropTypes.bool.isRequired, onMonitorTogglePress: PropTypes.func.isRequired, onRefreshPress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired + onSearchPress: PropTypes.func.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + onSaveSelected: PropTypes.func.isRequired }; AuthorDetails.defaultProps = { statistics: {}, - tags: [], - isSaving: false + tags: [] }; export default AuthorDetails; diff --git a/frontend/src/Author/Details/AuthorDetailsConnector.js b/frontend/src/Author/Details/AuthorDetailsConnector.js index 5bc1add8c..13b34e77a 100644 --- a/frontend/src/Author/Details/AuthorDetailsConnector.js +++ b/frontend/src/Author/Details/AuthorDetailsConnector.js @@ -6,6 +6,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import * as commandNames from 'Commands/commandNames'; import { toggleAuthorMonitored } from 'Store/Actions/authorActions'; +import { saveBookEditor } from 'Store/Actions/bookEditorActions'; import { clearBookFiles, fetchBookFiles } from 'Store/Actions/bookFileActions'; import { executeCommand } from 'Store/Actions/commandActions'; import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions'; @@ -21,7 +22,8 @@ import AuthorDetails from './AuthorDetails'; const selectBooks = createSelector( (state) => state.books, - (books) => { + (state) => state.bookEditor, + (books, editor) => { const { items, isFetching, @@ -37,7 +39,8 @@ const selectBooks = createSelector( isBooksPopulated: isPopulated, booksError: error, hasBooks, - hasMonitoredBooks + hasMonitoredBooks, + ...editor }; } ); @@ -187,6 +190,7 @@ function createMapStateToProps() { const mapDispatchToProps = { fetchSeries, clearSeries, + saveBookEditor, fetchBookFiles, clearBookFiles, toggleAuthorMonitored, @@ -282,6 +286,10 @@ class AuthorDetailsConnector extends Component { }); } + onSaveSelected = (payload) => { + this.props.saveBookEditor(payload); + } + // // Render @@ -292,6 +300,7 @@ class AuthorDetailsConnector extends Component { onMonitorTogglePress={this.onMonitorTogglePress} onRefreshPress={this.onRefreshPress} onSearchPress={this.onSearchPress} + onSaveSelected={this.onSaveSelected} /> ); } @@ -307,6 +316,7 @@ AuthorDetailsConnector.propTypes = { isRenamingAuthor: PropTypes.bool.isRequired, fetchSeries: PropTypes.func.isRequired, clearSeries: PropTypes.func.isRequired, + saveBookEditor: PropTypes.func.isRequired, fetchBookFiles: PropTypes.func.isRequired, clearBookFiles: PropTypes.func.isRequired, toggleAuthorMonitored: PropTypes.func.isRequired, diff --git a/frontend/src/Author/Details/AuthorDetailsSeason.js b/frontend/src/Author/Details/AuthorDetailsSeason.js index 1abc27847..34ea09b38 100644 --- a/frontend/src/Author/Details/AuthorDetailsSeason.js +++ b/frontend/src/Author/Details/AuthorDetailsSeason.js @@ -4,6 +4,7 @@ import React, { Component } from 'react'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import { sortDirections } from 'Helpers/Props'; +import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; import getToggledRange from 'Utilities/Table/getToggledRange'; import BookRowConnector from './BookRowConnector'; import styles from './AuthorDetailsSeason.css'; @@ -21,6 +22,26 @@ class AuthorDetailsSeason extends Component { }; } + componentDidMount() { + this.props.setSelectedState(this.props.items); + } + + componentDidUpdate(prevProps) { + const { + items, + sortKey, + sortDirection, + setSelectedState + } = this.props; + + if (sortKey !== prevProps.sortKey || + sortDirection !== prevProps.sortDirection || + hasDifferentItemsOrOrder(prevProps.items, items) + ) { + setSelectedState(items); + } + } + // // Listeners @@ -42,26 +63,42 @@ class AuthorDetailsSeason extends Component { this.props.onMonitorBookPress(_.uniq(bookIds), monitored); } + onSelectedChange = ({ id, value, shiftKey = false }) => { + const { + onSelectedChange, + items + } = this.props; + + return onSelectedChange(items, id, value, shiftKey); + } + // // Render render() { const { items, + isBookEditorActive, columns, sortKey, sortDirection, onSortPress, - onTableOptionChange + onTableOptionChange, + selectedState } = this.props; + let titleColumns = columns; + if (!isBookEditorActive) { + titleColumns = columns.filter((x) => x.name !== 'select'); + } + return (
); }) @@ -92,9 +132,13 @@ AuthorDetailsSeason.propTypes = { sortKey: PropTypes.string, sortDirection: PropTypes.oneOf(sortDirections.all), items: PropTypes.arrayOf(PropTypes.object).isRequired, + isBookEditorActive: PropTypes.bool.isRequired, + selectedState: PropTypes.object.isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired, onTableOptionChange: PropTypes.func.isRequired, onExpandPress: PropTypes.func.isRequired, + setSelectedState: PropTypes.func.isRequired, + onSelectedChange: PropTypes.func.isRequired, onSortPress: PropTypes.func.isRequired, onMonitorBookPress: PropTypes.func.isRequired, uiSettings: PropTypes.object.isRequired diff --git a/frontend/src/Author/Details/BookRow.js b/frontend/src/Author/Details/BookRow.js index 292232bf2..969b38aed 100644 --- a/frontend/src/Author/Details/BookRow.js +++ b/frontend/src/Author/Details/BookRow.js @@ -6,6 +6,7 @@ import MonitorToggleButton from 'Components/MonitorToggleButton'; import StarRating from 'Components/StarRating'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; import TableRow from 'Components/Table/TableRow'; import BookStatus from './BookStatus'; import styles from './BookRow.css'; @@ -65,6 +66,9 @@ class BookRow extends Component { authorMonitored, titleSlug, bookFiles, + isBookEditorActive, + isSelected, + onSelectedChange, columns } = this.props; @@ -84,6 +88,18 @@ class BookRow extends Component { return null; } + if (isBookEditorActive && name === 'select') { + return ( + + ); + } + if (name === 'monitored') { return ( @@ -88,20 +94,33 @@ class EditBookModalContent extends Component { /> - - - {translate('Edition')} - + { + isFetching && + + } - - + { + error && +
{errorMessage}
+ } + + { + isPopulated && !isFetching && !!editions.value.length && + + + {translate('Edition')} + + + + + } @@ -131,6 +150,9 @@ EditBookModalContent.propTypes = { authorName: PropTypes.string.isRequired, statistics: PropTypes.object.isRequired, item: PropTypes.object.isRequired, + isFetching: PropTypes.bool.isRequired, + error: PropTypes.object, + isPopulated: PropTypes.bool.isRequired, isSaving: PropTypes.bool.isRequired, onInputChange: PropTypes.func.isRequired, onSavePress: PropTypes.func.isRequired, diff --git a/frontend/src/Book/Edit/EditBookModalContentConnector.js b/frontend/src/Book/Edit/EditBookModalContentConnector.js index ebf61ab2a..f4e9fbf83 100644 --- a/frontend/src/Book/Edit/EditBookModalContentConnector.js +++ b/frontend/src/Book/Edit/EditBookModalContentConnector.js @@ -4,6 +4,7 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { saveBook, setBookValue } from 'Store/Actions/bookActions'; +import { clearEditions, fetchEditions } from 'Store/Actions/editionActions'; import createAuthorSelector from 'Store/Selectors/createAuthorSelector'; import createBookSelector from 'Store/Selectors/createBookSelector'; import selectSettings from 'Store/Selectors/selectSettings'; @@ -12,15 +13,25 @@ import EditBookModalContent from './EditBookModalContent'; function createMapStateToProps() { return createSelector( (state) => state.books, + (state) => state.editions, createBookSelector(), createAuthorSelector(), - (bookState, book, author) => { + (bookState, editionState, book, author) => { const { isSaving, saveError, pendingChanges } = bookState; + const { + isFetching, + isPopulated, + error, + items + } = editionState; + + book.editions = items; + const bookSettings = _.pick(book, [ 'monitored', 'anyEditionOk', @@ -34,6 +45,9 @@ function createMapStateToProps() { authorName: author.authorName, bookType: book.bookType, statistics: book.statistics, + isFetching, + isPopulated, + error, isSaving, saveError, item: settings.settings, @@ -44,6 +58,8 @@ function createMapStateToProps() { } const mapDispatchToProps = { + dispatchFetchEditions: fetchEditions, + dispatchClearEditions: clearEditions, dispatchSetBookValue: setBookValue, dispatchSaveBook: saveBook }; @@ -53,12 +69,20 @@ class EditBookModalContentConnector extends Component { // // Lifecycle + componentDidMount() { + this.props.dispatchFetchEditions({ bookId: this.props.bookId }); + } + componentDidUpdate(prevProps, prevState) { if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { this.props.onModalClose(); } } + componentWillUnmount() { + this.props.dispatchClearEditions(); + } + // // Listeners @@ -90,6 +114,8 @@ EditBookModalContentConnector.propTypes = { bookId: PropTypes.number, isSaving: PropTypes.bool.isRequired, saveError: PropTypes.object, + dispatchFetchEditions: PropTypes.func.isRequired, + dispatchClearEditions: PropTypes.func.isRequired, dispatchSetBookValue: PropTypes.func.isRequired, dispatchSaveBook: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired diff --git a/frontend/src/Book/Editor/BookEditorFooter.css b/frontend/src/Book/Editor/BookEditorFooter.css new file mode 100644 index 000000000..0d065135c --- /dev/null +++ b/frontend/src/Book/Editor/BookEditorFooter.css @@ -0,0 +1,70 @@ +.inputContainer { + margin-right: 20px; + min-width: 150px; +} + +.buttonContainer { + display: flex; + justify-content: flex-end; + flex-grow: 1; +} + +.buttonContainerContent { + flex-grow: 0; +} + +.buttons { + display: flex; + justify-content: flex-end; + flex-grow: 1; +} + +.organizeSelectedButton, +.tagsButton { + composes: button from '~Components/Link/SpinnerButton.css'; + + margin-right: 10px; + height: 35px; +} + +.deleteSelectedButton { + composes: button from '~Components/Link/SpinnerButton.css'; + + margin-left: 50px; + height: 35px; +} + +@media only screen and (max-width: $breakpointExtraLarge) { + .deleteSelectedButton { + margin-left: 0; + } +} + +@media only screen and (max-width: $breakpointLarge) { + .buttonContainer { + justify-content: flex-start; + margin-top: 10px; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .inputContainer { + margin-right: 0; + } + + .buttonContainer { + justify-content: flex-start; + } + + .buttonContainerContent { + flex-grow: 1; + } + + .buttons { + justify-content: space-between; + } + + .selectedAuthorLabel { + text-align: left; + } +} diff --git a/frontend/src/Book/Editor/BookEditorFooter.js b/frontend/src/Book/Editor/BookEditorFooter.js new file mode 100644 index 000000000..e5a0a8c63 --- /dev/null +++ b/frontend/src/Book/Editor/BookEditorFooter.js @@ -0,0 +1,156 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import SelectInput from 'Components/Form/SelectInput'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +import PageContentFooter from 'Components/Page/PageContentFooter'; +import { kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import BookEditorFooterLabel from './BookEditorFooterLabel'; +import DeleteBookModal from './Delete/DeleteBookModal'; +import styles from './BookEditorFooter.css'; + +const NO_CHANGE = 'noChange'; + +class BookEditorFooter extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + monitored: NO_CHANGE, + rootFolderPath: NO_CHANGE, + savingTags: false, + isDeleteBookModalOpen: false, + isTagsModalOpen: false, + isConfirmMoveModalOpen: false, + destinationRootFolder: null + }; + } + + componentDidUpdate(prevProps) { + const { + isSaving, + saveError + } = this.props; + + if (prevProps.isSaving && !isSaving && !saveError) { + this.setState({ + monitored: NO_CHANGE, + rootFolderPath: NO_CHANGE, + savingTags: false + }); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.setState({ [name]: value }); + + if (value === NO_CHANGE) { + return; + } + + switch (name) { + case 'monitored': + this.props.onSaveSelected({ [name]: value === 'monitored' }); + break; + default: + this.props.onSaveSelected({ [name]: value }); + } + } + + onDeleteSelectedPress = () => { + this.setState({ isDeleteBookModalOpen: true }); + } + + onDeleteBookModalClose = () => { + this.setState({ isDeleteBookModalOpen: false }); + } + + // + // Render + + render() { + const { + bookIds, + selectedCount, + isSaving, + isDeleting + } = this.props; + + const { + monitored, + isDeleteBookModalOpen + } = this.state; + + const monitoredOptions = [ + { key: NO_CHANGE, value: 'No Change', disabled: true }, + { key: 'monitored', value: 'Monitored' }, + { key: 'unmonitored', value: 'Unmonitored' } + ]; + + return ( + +
+ + + +
+ +
+
+ + +
+ + Delete + +
+
+
+ + + +
+ ); + } +} + +BookEditorFooter.propTypes = { + bookIds: PropTypes.arrayOf(PropTypes.number).isRequired, + selectedCount: PropTypes.number.isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + onSaveSelected: PropTypes.func.isRequired +}; + +export default BookEditorFooter; diff --git a/frontend/src/Book/Editor/BookEditorFooterLabel.css b/frontend/src/Book/Editor/BookEditorFooterLabel.css new file mode 100644 index 000000000..9b4b40be6 --- /dev/null +++ b/frontend/src/Book/Editor/BookEditorFooterLabel.css @@ -0,0 +1,8 @@ +.label { + margin-bottom: 3px; + font-weight: bold; +} + +.savingIcon { + margin-left: 8px; +} diff --git a/frontend/src/Book/Editor/BookEditorFooterLabel.js b/frontend/src/Book/Editor/BookEditorFooterLabel.js new file mode 100644 index 000000000..7bda47bbf --- /dev/null +++ b/frontend/src/Book/Editor/BookEditorFooterLabel.js @@ -0,0 +1,40 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import SpinnerIcon from 'Components/SpinnerIcon'; +import { icons } from 'Helpers/Props'; +import styles from './BookEditorFooterLabel.css'; + +function BookEditorFooterLabel(props) { + const { + className, + label, + isSaving + } = props; + + return ( +
+ {label} + + { + isSaving && + + } +
+ ); +} + +BookEditorFooterLabel.propTypes = { + className: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + isSaving: PropTypes.bool.isRequired +}; + +BookEditorFooterLabel.defaultProps = { + className: styles.label +}; + +export default BookEditorFooterLabel; diff --git a/frontend/src/Book/Editor/Delete/DeleteBookModal.js b/frontend/src/Book/Editor/Delete/DeleteBookModal.js new file mode 100644 index 000000000..9d0bd46b1 --- /dev/null +++ b/frontend/src/Book/Editor/Delete/DeleteBookModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import DeleteBookModalContentConnector from './DeleteBookModalContentConnector'; + +function DeleteBookModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +DeleteBookModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default DeleteBookModal; diff --git a/frontend/src/Book/Editor/Delete/DeleteBookModalContent.css b/frontend/src/Book/Editor/Delete/DeleteBookModalContent.css new file mode 100644 index 000000000..1e4dc7711 --- /dev/null +++ b/frontend/src/Book/Editor/Delete/DeleteBookModalContent.css @@ -0,0 +1,9 @@ +.message { + margin-top: 20px; + margin-bottom: 10px; +} + +.deleteFilesMessage { + margin-top: 20px; + color: $dangerColor; +} diff --git a/frontend/src/Book/Editor/Delete/DeleteBookModalContent.js b/frontend/src/Book/Editor/Delete/DeleteBookModalContent.js new file mode 100644 index 000000000..f94039939 --- /dev/null +++ b/frontend/src/Book/Editor/Delete/DeleteBookModalContent.js @@ -0,0 +1,172 @@ +import PropTypes from 'prop-types'; +import React, { Component } 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, kinds } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './DeleteBookModalContent.css'; + +class DeleteBookModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + deleteFiles: false, + addImportListExclusion: true + }; + } + + // + // Listeners + + onDeleteFilesChange = ({ value }) => { + this.setState({ deleteFiles: value }); + } + + onAddImportListExclusionChange = ({ value }) => { + this.setState({ addImportListExclusion: value }); + } + + onDeleteBookConfirmed = () => { + const { + deleteFiles, + addImportListExclusion + } = this.state; + + this.setState({ deleteFiles: false }); + this.props.onDeleteSelectedPress(deleteFiles, addImportListExclusion); + } + + // + // Render + + render() { + const { + book, + files, + onModalClose + } = this.props; + + const { + deleteFiles, + addImportListExclusion + } = this.state; + + return ( + + + Delete Selected Book + + + +
+ + {`Delete File${book.length > 1 ? 's' : ''}`} + + + + + + {translate('AddListExclusion')} + + + + + { + !addImportListExclusion && +
+
+ {translate('IfYouDontAddAnImportListExclusionAndTheAuthorHasAMetadataProfileOtherThanNoneThenThisBookMayBeReaddedDuringTheNextAuthorRefresh')} +
+
+ } + +
+ +
+ {`Are you sure you want to delete ${book.length} selected book${book.length > 1 ? 's' : ''}${deleteFiles ? ' and their files' : ''}?`} +
+ +
    + { + book.map((s) => { + return ( +
  • + {s.title} +
  • + ); + }) + } +
+ + { + deleteFiles && +
+
+ {translate('TheFollowingFilesWillBeDeleted')} +
+
    + { + files.map((s) => { + return ( +
  • + {s.path} +
  • + ); + }) + } +
+
+ } +
+ + + + + + +
+ ); + } +} + +DeleteBookModalContent.propTypes = { + book: PropTypes.arrayOf(PropTypes.object).isRequired, + files: PropTypes.arrayOf(PropTypes.object).isRequired, + onModalClose: PropTypes.func.isRequired, + onDeleteSelectedPress: PropTypes.func.isRequired +}; + +export default DeleteBookModalContent; diff --git a/frontend/src/Book/Editor/Delete/DeleteBookModalContentConnector.js b/frontend/src/Book/Editor/Delete/DeleteBookModalContentConnector.js new file mode 100644 index 000000000..234e56a29 --- /dev/null +++ b/frontend/src/Book/Editor/Delete/DeleteBookModalContentConnector.js @@ -0,0 +1,54 @@ +import _ from 'lodash'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { bulkDeleteBook } from 'Store/Actions/bookEditorActions'; +import DeleteBookModalContent from './DeleteBookModalContent'; + +function createMapStateToProps() { + return createSelector( + (state, { bookIds }) => bookIds, + (state) => state.books.items, + (state) => state.bookFiles.items, + (bookIds, allBooks, allBookFiles) => { + const selectedBook = _.intersectionWith(allBooks, bookIds, (s, id) => { + return s.id === id; + }); + + const sortedBook = _.orderBy(selectedBook, 'title'); + + const selectedFiles = _.intersectionWith(allBookFiles, bookIds, (s, id) => { + return s.bookId === id; + }); + + const files = _.orderBy(selectedFiles, ['bookId', 'path']); + + const book = _.map(sortedBook, (s) => { + return { + title: s.title, + path: s.path + }; + }); + + return { + book, + files + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onDeleteSelectedPress(deleteFiles, addImportListExclusion) { + dispatch(bulkDeleteBook({ + bookIds: props.bookIds, + deleteFiles, + addImportListExclusion + })); + + props.onModalClose(); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteBookModalContent); diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index fd1cf2109..8b2f10ebc 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -36,6 +36,7 @@ import { faCaretDown as fasCaretDown, faCheck as fasCheck, faCheckCircle as fasCheckCircle, + faCheckSquare as fasCheckSquare, faChevronCircleDown as fasChevronCircleDown, faChevronCircleRight as fasChevronCircleRight, faChevronCircleUp as fasChevronCircleUp, @@ -127,6 +128,7 @@ export const CARET_DOWN = fasCaretDown; export const CHECK = fasCheck; export const CHECK_INDETERMINATE = fasMinus; export const CHECK_CIRCLE = fasCheckCircle; +export const CHECK_SQUARE = fasCheckSquare; export const CIRCLE = fasCircle; export const CIRCLE_OUTLINE = farCircle; export const CLEAR = fasTrashAlt; diff --git a/frontend/src/Store/Actions/bookActions.js b/frontend/src/Store/Actions/bookActions.js index 63549ef37..b20ac56aa 100644 --- a/frontend/src/Store/Actions/bookActions.js +++ b/frontend/src/Store/Actions/bookActions.js @@ -132,6 +132,14 @@ export const defaultState = { }, columns: [ + { + name: 'select', + columnLabel: 'Select', + isSortable: false, + isVisible: true, + isModifiable: false, + isHidden: true + }, { name: 'monitored', columnLabel: 'Monitored', diff --git a/frontend/src/Store/Actions/bookEditorActions.js b/frontend/src/Store/Actions/bookEditorActions.js new file mode 100644 index 000000000..83e53e414 --- /dev/null +++ b/frontend/src/Store/Actions/bookEditorActions.js @@ -0,0 +1,114 @@ +import { batchActions } from 'redux-batched-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createAjaxRequest from 'Utilities/createAjaxRequest'; +import { set, updateItem } from './baseActions'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'bookEditor'; + +// +// State + +export const defaultState = { + isSaving: false, + saveError: null, + isDeleting: false, + deleteError: null +}; + +// +// Actions Types + +export const SAVE_BOOK_EDITOR = 'bookEditor/saveBookEditor'; +export const BULK_DELETE_BOOK = 'bookEditor/bulkDeleteBook'; + +// +// Action Creators + +export const saveBookEditor = createThunk(SAVE_BOOK_EDITOR); +export const bulkDeleteBook = createThunk(BULK_DELETE_BOOK); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [SAVE_BOOK_EDITOR]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isSaving: true + })); + + const promise = createAjaxRequest({ + url: '/book/editor', + method: 'PUT', + data: JSON.stringify(payload), + dataType: 'json' + }).request; + + promise.done((data) => { + dispatch(batchActions([ + ...data.map((book) => { + return updateItem({ + id: book.id, + section: 'books', + ...book + }); + }), + + set({ + section, + isSaving: false, + saveError: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isSaving: false, + saveError: xhr + })); + }); + }, + + [BULK_DELETE_BOOK]: function(getState, payload, dispatch) { + dispatch(set({ + section, + isDeleting: true + })); + + const promise = createAjaxRequest({ + url: '/book/editor', + method: 'DELETE', + data: JSON.stringify(payload), + dataType: 'json' + }).request; + + promise.done(() => { + // SignalR will take care of removing the book from the collection + + dispatch(set({ + section, + isDeleting: false, + deleteError: null + })); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isDeleting: false, + deleteError: xhr + })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Actions/editionActions.js b/frontend/src/Store/Actions/editionActions.js new file mode 100644 index 000000000..f6e0c6f93 --- /dev/null +++ b/frontend/src/Store/Actions/editionActions.js @@ -0,0 +1,54 @@ +import { createAction } from 'redux-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createHandleActions from './Creators/createHandleActions'; +import createClearReducer from './Creators/Reducers/createClearReducer'; + +// +// Variables + +export const section = 'editions'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [], + itemMap: {} +}; + +// +// Actions Types + +export const FETCH_EDITIONS = 'editions/fetchEditions'; +export const CLEAR_EDITIONS = 'editions/clearEditions'; + +// +// Action Creators + +export const fetchEditions = createThunk(FETCH_EDITIONS); +export const clearEditions = createAction(CLEAR_EDITIONS); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_EDITIONS]: createFetchHandler(section, '/edition') +}); + +// +// Reducers +export const reducers = createHandleActions({ + + [CLEAR_EDITIONS]: createClearReducer(section, { + isFetching: false, + isPopulated: false, + error: null, + items: [], + itemMap: {} + }) + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 705ec4164..1627bff02 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -6,6 +6,7 @@ import * as authorHistory from './authorHistoryActions'; import * as authorIndex from './authorIndexActions'; import * as blocklist from './blocklistActions'; import * as books from './bookActions'; +import * as bookEditor from './bookEditorActions'; import * as bookFiles from './bookFileActions'; import * as bookHistory from './bookHistoryActions'; import * as bookIndex from './bookIndexActions'; @@ -14,6 +15,7 @@ import * as calendar from './calendarActions'; import * as captcha from './captchaActions'; import * as commands from './commandActions'; import * as customFilters from './customFilterActions'; +import * as editions from './editionActions'; import * as history from './historyActions'; import * as interactiveImportActions from './interactiveImportActions'; import * as oAuth from './oAuthActions'; @@ -42,11 +44,13 @@ export default [ bookHistory, bookIndex, books, + bookEditor, bookStudio, calendar, captcha, commands, customFilters, + editions, history, interactiveImportActions, oAuth, diff --git a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs index c9ec92ec4..2240966a7 100644 --- a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs +++ b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net; +using System.Net.Http; using System.Threading; using FluentAssertions; using Moq; @@ -15,6 +16,8 @@ using NzbDrone.Common.Http; using NzbDrone.Common.Http.Dispatchers; using NzbDrone.Common.Http.Proxy; using NzbDrone.Common.TPL; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Security; using NzbDrone.Test.Common; using NzbDrone.Test.Common.Categories; using HttpClient = NzbDrone.Common.Http.HttpClient; @@ -41,7 +44,7 @@ namespace NzbDrone.Common.Test.Http var mainHost = "httpbin.servarr.com"; // Use mirrors for tests that use two hosts - var candidates = new[] { "eu.httpbin.org", /* "httpbin.org", */ "www.httpbin.org" }; + var candidates = new[] { "httpbin1.servarr.com" }; // httpbin.org is broken right now, occassionally redirecting to https if it's unavailable. _httpBinHost = mainHost; @@ -49,7 +52,7 @@ namespace NzbDrone.Common.Test.Http TestLogger.Info($"{candidates.Length} TestSites available."); - _httpBinSleep = _httpBinHosts.Count() < 2 ? 100 : 10; + _httpBinSleep = 10; } private bool IsTestSiteAvailable(string site) @@ -84,10 +87,13 @@ namespace NzbDrone.Common.Test.Http Mocker.GetMock().Setup(c => c.Name).Returns("TestOS"); Mocker.GetMock().Setup(c => c.Version).Returns("9.0.0"); + Mocker.GetMock().SetupGet(x => x.CertificateValidation).Returns(CertificateValidationType.Enabled); + Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(new X509CertificateValidationService(Mocker.GetMock().Object, TestLogger)); Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant>(new IHttpRequestInterceptor[0]); Mocker.SetConstant(Mocker.Resolve()); @@ -127,6 +133,28 @@ namespace NzbDrone.Common.Test.Http response.Content.Should().NotBeNullOrWhiteSpace(); } + [TestCase(CertificateValidationType.Enabled)] + [TestCase(CertificateValidationType.DisabledForLocalAddresses)] + public void bad_ssl_should_fail_when_remote_validation_enabled(CertificateValidationType validationType) + { + Mocker.GetMock().SetupGet(x => x.CertificateValidation).Returns(validationType); + var request = new HttpRequest($"https://expired.badssl.com"); + + Assert.Throws(() => Subject.Execute(request)); + ExceptionVerification.ExpectedErrors(2); + } + + [Test] + public void bad_ssl_should_pass_if_remote_validation_disabled() + { + Mocker.GetMock().SetupGet(x => x.CertificateValidation).Returns(CertificateValidationType.Disabled); + + var request = new HttpRequest($"https://expired.badssl.com"); + + Subject.Execute(request); + ExceptionVerification.ExpectedErrors(0); + } + [Test] public void should_execute_typed_get() { diff --git a/src/NzbDrone.Common.Test/ServiceFactoryFixture.cs b/src/NzbDrone.Common.Test/ServiceFactoryFixture.cs index ae6785780..1a3482582 100644 --- a/src/NzbDrone.Common.Test/ServiceFactoryFixture.cs +++ b/src/NzbDrone.Common.Test/ServiceFactoryFixture.cs @@ -3,6 +3,8 @@ using DryIoc; using DryIoc.Microsoft.DependencyInjection; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Moq; using NUnit.Framework; using NzbDrone.Common.Composition.Extensions; using NzbDrone.Common.EnvironmentInfo; @@ -25,12 +27,14 @@ namespace NzbDrone.Common.Test .AddNzbDroneLogger() .AutoAddServices(Bootstrap.ASSEMBLIES) .AddDummyDatabase() - .AddStartupContext(new StartupContext("first", "second")) - .GetServiceProvider(); + .AddStartupContext(new StartupContext("first", "second")); - container.GetRequiredService().Register(); + container.RegisterInstance(new Mock().Object); - Mocker.SetConstant(container); + var serviceProvider = container.GetServiceProvider(); + serviceProvider.GetRequiredService().Register(); + + Mocker.SetConstant(serviceProvider); var handlers = Subject.BuildAll>() .Select(c => c.GetType().FullName); diff --git a/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs b/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs index 094ca7a57..874bc91ee 100644 --- a/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs +++ b/src/NzbDrone.Common/EnvironmentInfo/RuntimeInfo.cs @@ -1,9 +1,9 @@ using System; using System.Diagnostics; using System.IO; -using System.Reflection; using System.Security.Principal; -using System.ServiceProcess; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Hosting.WindowsServices; using NLog; using NzbDrone.Common.Processes; @@ -14,14 +14,11 @@ namespace NzbDrone.Common.EnvironmentInfo private readonly Logger _logger; private readonly DateTime _startTime = DateTime.UtcNow; - public RuntimeInfo(IServiceProvider serviceProvider, Logger logger) + public RuntimeInfo(IHostLifetime hostLifetime, Logger logger) { _logger = logger; - IsWindowsService = !IsUserInteractive && - OsInfo.IsWindows && - serviceProvider.ServiceExist(ServiceProvider.SERVICE_NAME) && - serviceProvider.GetStatus(ServiceProvider.SERVICE_NAME) == ServiceControllerStatus.StartPending; + IsWindowsService = hostLifetime is WindowsServiceLifetime; //Guarded to avoid issues when running in a non-managed process var entry = Process.GetCurrentProcess().MainModule; diff --git a/src/NzbDrone.Common/Http/Dispatchers/ICertificateValidationService.cs b/src/NzbDrone.Common/Http/Dispatchers/ICertificateValidationService.cs new file mode 100644 index 000000000..187c1fd43 --- /dev/null +++ b/src/NzbDrone.Common/Http/Dispatchers/ICertificateValidationService.cs @@ -0,0 +1,11 @@ +using System.Net.Http; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; + +namespace NzbDrone.Common.Http.Dispatchers +{ + public interface ICertificateValidationService + { + bool ShouldByPassValidationError(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors); + } +} diff --git a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs index 08b464b79..b7acefb3a 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs @@ -3,6 +3,7 @@ using System.IO; using System.Net; using System.Net.Http; using System.Net.Http.Headers; +using System.Net.Security; using System.Net.Sockets; using System.Text; using System.Threading; @@ -24,6 +25,7 @@ namespace NzbDrone.Common.Http.Dispatchers private readonly IHttpProxySettingsProvider _proxySettingsProvider; private readonly ICreateManagedWebProxy _createManagedWebProxy; + private readonly ICertificateValidationService _certificateValidationService; private readonly IUserAgentBuilder _userAgentBuilder; private readonly ICached _httpClientCache; private readonly ICached _credentialCache; @@ -31,12 +33,14 @@ namespace NzbDrone.Common.Http.Dispatchers public ManagedHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider, ICreateManagedWebProxy createManagedWebProxy, + ICertificateValidationService certificateValidationService, IUserAgentBuilder userAgentBuilder, ICacheManager cacheManager, Logger logger) { _proxySettingsProvider = proxySettingsProvider; _createManagedWebProxy = createManagedWebProxy; + _certificateValidationService = certificateValidationService; _userAgentBuilder = userAgentBuilder; _logger = logger; @@ -158,7 +162,12 @@ namespace NzbDrone.Common.Http.Dispatchers AllowAutoRedirect = false, Credentials = GetCredentialCache(), PreAuthenticate = true, + MaxConnectionsPerServer = 12, ConnectCallback = onConnect, + SslOptions = new SslClientAuthenticationOptions + { + RemoteCertificateValidationCallback = _certificateValidationService.ShouldByPassValidationError + } }; if (proxySettings != null) diff --git a/src/NzbDrone.Common/Readarr.Common.csproj b/src/NzbDrone.Common/Readarr.Common.csproj index 8e5ad4f93..61e70b85f 100644 --- a/src/NzbDrone.Common/Readarr.Common.csproj +++ b/src/NzbDrone.Common/Readarr.Common.csproj @@ -7,6 +7,7 @@ + diff --git a/src/NzbDrone.Console/Readarr.Console.csproj b/src/NzbDrone.Console/Readarr.Console.csproj index f738a8ef6..f88538db9 100644 --- a/src/NzbDrone.Console/Readarr.Console.csproj +++ b/src/NzbDrone.Console/Readarr.Console.csproj @@ -4,7 +4,6 @@ net6.0 ..\NzbDrone.Host\Readarr.ico - app.manifest Readarr diff --git a/src/NzbDrone.Console/app.manifest b/src/NzbDrone.Console/app.manifest deleted file mode 100644 index 8e6eb2fea..000000000 --- a/src/NzbDrone.Console/app.manifest +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - true - - - diff --git a/src/NzbDrone.Core.Test/Framework/CoreTest.cs b/src/NzbDrone.Core.Test/Framework/CoreTest.cs index ebb0611b5..4b4e990fc 100644 --- a/src/NzbDrone.Core.Test/Framework/CoreTest.cs +++ b/src/NzbDrone.Core.Test/Framework/CoreTest.cs @@ -11,6 +11,7 @@ using NzbDrone.Common.TPL; using NzbDrone.Core.Configuration; using NzbDrone.Core.Http; using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.Security; using NzbDrone.Test.Common; namespace NzbDrone.Core.Test.Framework @@ -25,7 +26,8 @@ namespace NzbDrone.Core.Test.Framework Mocker.SetConstant(new HttpProxySettingsProvider(Mocker.Resolve())); Mocker.SetConstant(new ManagedWebProxyFactory(Mocker.Resolve())); - Mocker.SetConstant(new ManagedHttpDispatcher(Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), TestLogger)); + Mocker.SetConstant(new X509CertificateValidationService(Mocker.Resolve(), TestLogger)); + Mocker.SetConstant(new ManagedHttpDispatcher(Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), TestLogger)); Mocker.SetConstant(new HttpClient(new IHttpRequestInterceptor[0], Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), TestLogger)); Mocker.SetConstant(new ReadarrCloudRequestBuilder()); Mocker.SetConstant(Mocker.Resolve()); diff --git a/src/NzbDrone.Core/Books/Repositories/EditionRepository.cs b/src/NzbDrone.Core/Books/Repositories/EditionRepository.cs index b7d767c64..3a49ead6c 100644 --- a/src/NzbDrone.Core/Books/Repositories/EditionRepository.cs +++ b/src/NzbDrone.Core/Books/Repositories/EditionRepository.cs @@ -9,6 +9,7 @@ namespace NzbDrone.Core.Books { public interface IEditionRepository : IBasicRepository { + List GetAllMonitoredEditions(); Edition FindByForeignEditionId(string foreignEditionId); List FindByBook(int id); List FindByAuthor(int id); @@ -25,6 +26,11 @@ namespace NzbDrone.Core.Books { } + public List GetAllMonitoredEditions() + { + return Query(x => x.Monitored == true); + } + public Edition FindByForeignEditionId(string foreignEditionId) { var edition = Query(x => x.ForeignEditionId == foreignEditionId).SingleOrDefault(); diff --git a/src/NzbDrone.Core/Books/Services/EditionService.cs b/src/NzbDrone.Core/Books/Services/EditionService.cs index 81fd1563b..421a537ff 100644 --- a/src/NzbDrone.Core/Books/Services/EditionService.cs +++ b/src/NzbDrone.Core/Books/Services/EditionService.cs @@ -12,7 +12,7 @@ namespace NzbDrone.Core.Books { Edition GetEdition(int id); Edition GetEditionByForeignEditionId(string foreignEditionId); - List GetAllEditions(); + List GetAllMonitoredEditions(); void InsertMany(List editions); void UpdateMany(List editions); void DeleteMany(List editions); @@ -48,9 +48,9 @@ namespace NzbDrone.Core.Books return _editionRepository.FindByForeignEditionId(foreignEditionId); } - public List GetAllEditions() + public List GetAllMonitoredEditions() { - return _editionRepository.All().ToList(); + return _editionRepository.GetAllMonitoredEditions(); } public void InsertMany(List editions) diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index aff7febff..beeaeda2f 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -66,6 +66,7 @@ "Book": "Book", "BookAvailableButMissing": "Book Available, but Missing", "BookDownloaded": "Book Downloaded", + "BookEditor": "Book Editor", "BookFileCountBookCountTotalTotalBookCountInterp": "{0} / {1} (Total: {2})", "BookFileCounttotalBookCountBooksDownloadedInterp": "{0}/{1} books downloaded", "BookFilesCountMessage": "No book files", @@ -73,6 +74,7 @@ "BookIsDownloading": "Book is downloading", "BookIsDownloadingInterp": "Book is downloading - {0}% {1}", "BookIsNotMonitored": "Book is not monitored", + "BookList": "Book List", "BookMissingFromDisk": "Book missing from disk", "BookMonitoring": "Book Monitoring", "BookNaming": "Book Naming", @@ -558,7 +560,9 @@ "SearchSelected": "Search Selected", "Season": "Season", "Security": "Security", + "SelectAll": "Select All", "SelectedCountAuthorsSelectedInterp": "{0} Author(s) Selected", + "SelectedCountBooksSelectedInterp": "{0} Book(s) Selected", "SendAnonymousUsageData": "Send Anonymous Usage Data", "SendMetadataToCalibre": "Send Metadata to Calibre", "Series": "Series", @@ -644,6 +648,7 @@ "TestAllLists": "Test All Lists", "TheAuthorFolderAndAllOfItsContentWillBeDeleted": "The author folder {0} and all of its content will be deleted.", "TheBooksFilesWillBeDeleted": "The book's files will be deleted.", + "TheFollowingFilesWillBeDeleted": "The following files will be deleted:", "ThisWillApplyToAllIndexersPleaseFollowTheRulesSetForthByThem": "This will apply to all indexers, please follow the rules set forth by them", "Time": "Time", "TimeFormat": "Time Format", @@ -706,6 +711,7 @@ "UnmappedFiles": "UnmappedFiles", "Unmonitored": "Unmonitored", "UnmonitoredHelpText": "Include unmonitored books in the iCal feed", + "UnselectAll": "Unselect All", "UpdateAll": "Update all", "UpdateAutomaticallyHelpText": "Automatically download and install updates. You will still be able to install from System: Updates", "UpdateCovers": "Update Covers", diff --git a/src/NzbDrone.Core/Security/X509CertificateValidationService.cs b/src/NzbDrone.Core/Security/X509CertificateValidationService.cs index ba58e1eff..0c03388b5 100644 --- a/src/NzbDrone.Core/Security/X509CertificateValidationService.cs +++ b/src/NzbDrone.Core/Security/X509CertificateValidationService.cs @@ -4,13 +4,12 @@ using System.Net.Security; using System.Security.Cryptography.X509Certificates; using NLog; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http.Dispatchers; using NzbDrone.Core.Configuration; -using NzbDrone.Core.Lifecycle; -using NzbDrone.Core.Messaging.Events; namespace NzbDrone.Core.Security { - public class X509CertificateValidationService : IHandle + public class X509CertificateValidationService : ICertificateValidationService { private readonly IConfigService _configService; private readonly Logger _logger; @@ -21,19 +20,16 @@ namespace NzbDrone.Core.Security _logger = logger; } - private bool ShouldByPassValidationError(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) + public bool ShouldByPassValidationError(object sender, X509Certificate certificate, X509Chain chain, SslPolicyErrors sslPolicyErrors) { - var request = sender as HttpWebRequest; - - if (request == null) + if (sender is not SslStream request) { return true; } - var cert2 = certificate as X509Certificate2; - if (cert2 != null && request != null && cert2.SignatureAlgorithm.FriendlyName == "md5RSA") + if (certificate is X509Certificate2 cert2 && cert2.SignatureAlgorithm.FriendlyName == "md5RSA") { - _logger.Error("https://{0} uses the obsolete md5 hash in it's https certificate, if that is your certificate, please (re)create certificate with better algorithm as soon as possible.", request.RequestUri.Authority); + _logger.Error("https://{0} uses the obsolete md5 hash in it's https certificate, if that is your certificate, please (re)create certificate with better algorithm as soon as possible.", request.TargetHostName); } if (sslPolicyErrors == SslPolicyErrors.None) @@ -41,12 +37,12 @@ namespace NzbDrone.Core.Security return true; } - if (request.RequestUri.Host == "localhost" || request.RequestUri.Host == "127.0.0.1") + if (request.TargetHostName == "localhost" || request.TargetHostName == "127.0.0.1") { return true; } - var ipAddresses = GetIPAddresses(request.RequestUri.Host); + var ipAddresses = GetIPAddresses(request.TargetHostName); var certificateValidation = _configService.CertificateValidation; if (certificateValidation == CertificateValidationType.Disabled) @@ -60,7 +56,7 @@ namespace NzbDrone.Core.Security return true; } - _logger.Error("Certificate validation for {0} failed. {1}", request.Address, sslPolicyErrors); + _logger.Error("Certificate validation for {0} failed. {1}", request.TargetHostName, sslPolicyErrors); return false; } @@ -74,10 +70,5 @@ namespace NzbDrone.Core.Security return Dns.GetHostEntry(host).AddressList; } - - public void Handle(ApplicationStartedEvent message) - { - ServicePointManager.ServerCertificateValidationCallback = ShouldByPassValidationError; - } } } diff --git a/src/NzbDrone.Host.Test/ContainerFixture.cs b/src/NzbDrone.Host.Test/ContainerFixture.cs index da969a093..5a658b2c1 100644 --- a/src/NzbDrone.Host.Test/ContainerFixture.cs +++ b/src/NzbDrone.Host.Test/ContainerFixture.cs @@ -4,6 +4,7 @@ using DryIoc; using DryIoc.Microsoft.DependencyInjection; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Moq; using NUnit.Framework; using NzbDrone.Common; @@ -33,16 +34,15 @@ namespace NzbDrone.App.Test { var args = new StartupContext("first", "second"); - // set up a dummy broadcaster to allow tests to resolve - var mockBroadcaster = new Mock(); - var container = new Container(rules => rules.WithNzbDroneRules()) .AutoAddServices(Bootstrap.ASSEMBLIES) .AddNzbDroneLogger() .AddDummyDatabase() .AddStartupContext(args); - container.RegisterInstance(mockBroadcaster.Object); + // set up a dummy broadcaster and lifetime to allow tests to resolve + container.RegisterInstance(new Mock().Object); + container.RegisterInstance(new Mock().Object); _container = container.GetServiceProvider(); } diff --git a/src/NzbDrone.Host/AppLifetime.cs b/src/NzbDrone.Host/AppLifetime.cs index d0d0955ce..17f5bbcdc 100644 --- a/src/NzbDrone.Host/AppLifetime.cs +++ b/src/NzbDrone.Host/AppLifetime.cs @@ -19,7 +19,6 @@ namespace NzbDrone.Host private readonly IBrowserService _browserService; private readonly IProcessProvider _processProvider; private readonly IEventAggregator _eventAggregator; - private readonly IUtilityModeRouter _utilityModeRouter; private readonly Logger _logger; public AppLifetime(IHostApplicationLifetime appLifetime, @@ -29,7 +28,6 @@ namespace NzbDrone.Host IBrowserService browserService, IProcessProvider processProvider, IEventAggregator eventAggregator, - IUtilityModeRouter utilityModeRouter, Logger logger) { _appLifetime = appLifetime; @@ -39,7 +37,6 @@ namespace NzbDrone.Host _browserService = browserService; _processProvider = processProvider; _eventAggregator = eventAggregator; - _utilityModeRouter = utilityModeRouter; _logger = logger; appLifetime.ApplicationStarted.Register(OnAppStarted); @@ -71,7 +68,7 @@ namespace NzbDrone.Host private void OnAppStopped() { - if (_runtimeInfo.RestartPending) + if (_runtimeInfo.RestartPending && !_runtimeInfo.IsWindowsService) { var restartArgs = GetRestartArgs(); diff --git a/src/NzbDrone.Host/Bootstrap.cs b/src/NzbDrone.Host/Bootstrap.cs index 3e2e3e0b1..7df0e2327 100644 --- a/src/NzbDrone.Host/Bootstrap.cs +++ b/src/NzbDrone.Host/Bootstrap.cs @@ -180,7 +180,20 @@ namespace NzbDrone.Host return ApplicationModes.UninstallService; } - if (OsInfo.IsWindows && WindowsServiceHelpers.IsWindowsService()) + Logger.Debug("Getting windows service status"); + + // IsWindowsService can throw sometimes, so wrap it + var isWindowsService = false; + try + { + isWindowsService = WindowsServiceHelpers.IsWindowsService(); + } + catch (Exception e) + { + Logger.Error(e, "Failed to get service status"); + } + + if (OsInfo.IsWindows && isWindowsService) { return ApplicationModes.Service; } diff --git a/src/NzbDrone/Readarr.csproj b/src/NzbDrone/Readarr.csproj index f01debd56..419a2af05 100644 --- a/src/NzbDrone/Readarr.csproj +++ b/src/NzbDrone/Readarr.csproj @@ -5,7 +5,6 @@ win-x64;win-x86 true ..\NzbDrone.Host\Readarr.ico - app.manifest true diff --git a/src/NzbDrone/SysTray/SysTrayApp.cs b/src/NzbDrone/SysTray/SysTrayApp.cs index 06fff943d..956cad32c 100644 --- a/src/NzbDrone/SysTray/SysTrayApp.cs +++ b/src/NzbDrone/SysTray/SysTrayApp.cs @@ -4,9 +4,8 @@ using System.Threading; using System.Threading.Tasks; using System.Windows.Forms; using Microsoft.Extensions.Hosting; -using NLog; using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Common.Processes; +using NzbDrone.Core.Lifecycle; using NzbDrone.Host; namespace NzbDrone.SysTray @@ -14,28 +13,19 @@ namespace NzbDrone.SysTray public class SystemTrayApp : Form, IHostedService { private readonly IBrowserService _browserService; - private readonly IRuntimeInfo _runtimeInfo; - private readonly IProcessProvider _processProvider; + private readonly ILifecycleService _lifecycle; private readonly NotifyIcon _trayIcon = new NotifyIcon(); private readonly ContextMenuStrip _trayMenu = new ContextMenuStrip(); - public SystemTrayApp(IBrowserService browserService, IRuntimeInfo runtimeInfo, IProcessProvider processProvider) + public SystemTrayApp(IBrowserService browserService, ILifecycleService lifecycle) { _browserService = browserService; - _runtimeInfo = runtimeInfo; - _processProvider = processProvider; + _lifecycle = lifecycle; } public void Start() { - Application.ThreadException += OnThreadException; - Application.ApplicationExit += OnApplicationExit; - - Application.SetHighDpiMode(HighDpiMode.PerMonitor); - Application.EnableVisualStyles(); - Application.SetCompatibleTextRenderingDefault(false); - _trayMenu.Items.Add(new ToolStripMenuItem("Launch Browser", null, LaunchBrowser)); _trayMenu.Items.Add(new ToolStripSeparator()); _trayMenu.Items.Add(new ToolStripMenuItem("Exit", null, OnExit)); @@ -69,12 +59,6 @@ namespace NzbDrone.SysTray DisposeTrayIcon(); } - protected override void OnClosed(EventArgs e) - { - Console.WriteLine("Closing"); - base.OnClosed(e); - } - protected override void OnLoad(EventArgs e) { Visible = false; @@ -102,8 +86,7 @@ namespace NzbDrone.SysTray private void OnExit(object sender, EventArgs e) { - LogManager.Configuration = null; - Environment.Exit(0); + _lifecycle.Shutdown(); } private void LaunchBrowser(object sender, EventArgs e) @@ -117,33 +100,17 @@ namespace NzbDrone.SysTray } } - private void OnApplicationExit(object sender, EventArgs e) - { - if (_runtimeInfo.RestartPending) - { - _processProvider.SpawnNewProcess(_runtimeInfo.ExecutingApplication, "--restart --nobrowser"); - } - - DisposeTrayIcon(); - } - - private void OnThreadException(object sender, EventArgs e) - { - DisposeTrayIcon(); - } - private void DisposeTrayIcon() { - try - { - _trayIcon.Visible = false; - _trayIcon.Icon = null; - _trayIcon.Visible = false; - _trayIcon.Dispose(); - } - catch (Exception) + if (_trayIcon == null) { + return; } + + _trayIcon.Visible = false; + _trayIcon.Icon = null; + _trayIcon.Visible = false; + _trayIcon.Dispose(); } } } diff --git a/src/NzbDrone/WindowsApp.cs b/src/NzbDrone/WindowsApp.cs index 361e26d44..963a4d4fb 100644 --- a/src/NzbDrone/WindowsApp.cs +++ b/src/NzbDrone/WindowsApp.cs @@ -16,21 +16,22 @@ namespace NzbDrone public static void Main(string[] args) { + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + Application.SetHighDpiMode(HighDpiMode.SystemAware); + try { var startupArgs = new StartupContext(args); NzbDroneLogger.Register(startupArgs, false, true); - Bootstrap.Start(args, e => - { - e.ConfigureServices((_, s) => s.AddSingleton()); - }); + Bootstrap.Start(args, e => { e.ConfigureServices((_, s) => s.AddSingleton()); }); } catch (Exception e) { Logger.Fatal(e, "EPIC FAIL"); - MessageBox.Show($"{e.GetType().Name}: {e.Message}", buttons: MessageBoxButtons.OK, icon: MessageBoxIcon.Error, caption: "Epic Fail!"); + MessageBox.Show($"{e.GetType().Name}: {e}", buttons: MessageBoxButtons.OK, icon: MessageBoxIcon.Error, caption: "Epic Fail!"); } } } diff --git a/src/NzbDrone/app.manifest b/src/NzbDrone/app.manifest deleted file mode 100644 index 8e6eb2fea..000000000 --- a/src/NzbDrone/app.manifest +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - true - - - diff --git a/src/Readarr.Api.V1/Books/BookController.cs b/src/Readarr.Api.V1/Books/BookController.cs index f8e708f60..ed4dbe9d6 100644 --- a/src/Readarr.Api.V1/Books/BookController.cs +++ b/src/Readarr.Api.V1/Books/BookController.cs @@ -73,7 +73,7 @@ namespace Readarr.Api.V1.Books var books = _bookService.GetAllBooks(); var authors = _authorService.GetAllAuthors().ToDictionary(x => x.AuthorMetadataId); - var editions = _editionService.GetAllEditions().GroupBy(x => x.BookId).ToDictionary(x => x.Key, y => y.ToList()); + var editions = _editionService.GetAllMonitoredEditions().GroupBy(x => x.BookId).ToDictionary(x => x.Key, y => y.ToList()); foreach (var book in books) { diff --git a/src/Readarr.Api.V1/Books/BookEditorController.cs b/src/Readarr.Api.V1/Books/BookEditorController.cs new file mode 100644 index 000000000..7a4e9ea1e --- /dev/null +++ b/src/Readarr.Api.V1/Books/BookEditorController.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Books; +using NzbDrone.Core.Messaging.Commands; +using Readarr.Http; + +namespace Readarr.Api.V1.Books +{ + [V1ApiController("book/editor")] + public class BookEditorController : Controller + { + private readonly IBookService _bookService; + private readonly IManageCommandQueue _commandQueueManager; + + public BookEditorController(IBookService bookService, IManageCommandQueue commandQueueManager) + { + _bookService = bookService; + _commandQueueManager = commandQueueManager; + } + + [HttpPut] + public IActionResult SaveAll([FromBody] BookEditorResource resource) + { + var booksToUpdate = _bookService.GetBooks(resource.BookIds); + + foreach (var book in booksToUpdate) + { + if (resource.Monitored.HasValue) + { + book.Monitored = resource.Monitored.Value; + } + } + + _bookService.UpdateMany(booksToUpdate); + return Accepted(booksToUpdate.ToResource()); + } + + [HttpDelete] + public object DeleteBook([FromBody] BookEditorResource resource) + { + foreach (var bookId in resource.BookIds) + { + _bookService.DeleteBook(bookId, resource.DeleteFiles ?? false, resource.AddImportListExclusion ?? false); + } + + return new object(); + } + } +} diff --git a/src/Readarr.Api.V1/Books/BookEditorResource.cs b/src/Readarr.Api.V1/Books/BookEditorResource.cs new file mode 100644 index 000000000..d2658d8c3 --- /dev/null +++ b/src/Readarr.Api.V1/Books/BookEditorResource.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace Readarr.Api.V1.Books +{ + public class BookEditorResource + { + public List BookIds { get; set; } + public bool? Monitored { get; set; } + public bool? DeleteFiles { get; set; } + public bool? AddImportListExclusion { get; set; } + } +} diff --git a/src/Readarr.Api.V1/Editions/EditionController.cs b/src/Readarr.Api.V1/Editions/EditionController.cs new file mode 100644 index 000000000..71aedc399 --- /dev/null +++ b/src/Readarr.Api.V1/Editions/EditionController.cs @@ -0,0 +1,27 @@ +using System.Collections.Generic; +using Microsoft.AspNetCore.Mvc; +using NzbDrone.Core.Books; +using Readarr.Api.V1.Books; +using Readarr.Http; + +namespace NzbDrone.Api.V1.Editions +{ + [V1ApiController] + public class EditionController : Controller + { + private readonly IEditionService _editionService; + + public EditionController(IEditionService editionService) + { + _editionService = editionService; + } + + [HttpGet] + public List GetEditions(int bookId) + { + var editions = _editionService.GetEditionsByBook(bookId); + + return editions.ToResource(); + } + } +}