mirror of
https://github.com/fergalmoran/Readarr.git
synced 2025-12-22 09:29:59 +00:00
merge
This commit is contained in:
@@ -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 (
|
||||
<PageContent title={authorName}>
|
||||
<PageToolbar>
|
||||
@@ -252,6 +331,33 @@ class AuthorDetails extends Component {
|
||||
iconName={icons.DELETE}
|
||||
onPress={this.onDeleteAuthorPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
{
|
||||
isBookEditorActive ?
|
||||
<PageToolbarButton
|
||||
label={translate('BookList')}
|
||||
iconName={icons.AUTHOR_CONTINUING}
|
||||
onPress={this.onBookEditorTogglePress}
|
||||
/> :
|
||||
<PageToolbarButton
|
||||
label={translate('BookEditor')}
|
||||
iconName={icons.EDIT}
|
||||
onPress={this.onBookEditorTogglePress}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
isBookEditorActive ?
|
||||
<PageToolbarButton
|
||||
label={allSelected ? translate('UnselectAll') : translate('SelectAll')}
|
||||
iconName={icons.CHECK_SQUARE}
|
||||
onPress={this.onSelectAllPress}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
@@ -377,7 +483,11 @@ class AuthorDetails extends Component {
|
||||
<AuthorDetailsSeasonConnector
|
||||
authorId={id}
|
||||
isExpanded={true}
|
||||
selectedState={selectedState}
|
||||
onExpandPress={this.onExpandPress}
|
||||
setSelectedState={this.setSelectedState}
|
||||
onSelectedChange={this.onSelectedChange}
|
||||
isBookEditorActive={isBookEditorActive}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
@@ -422,7 +532,6 @@ class AuthorDetails extends Component {
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<div className={styles.metadataMessage}>
|
||||
@@ -474,6 +583,19 @@ class AuthorDetails extends Component {
|
||||
onModalClose={this.onMonitorOptionsClose}
|
||||
/>
|
||||
</PageContentBody>
|
||||
|
||||
{
|
||||
isBookEditorActive &&
|
||||
<BookEditorFooter
|
||||
bookIds={selectedBookIds}
|
||||
selectedCount={selectedBookIds.length}
|
||||
isSaving={isSaving}
|
||||
saveError={saveError}
|
||||
isDeleting={isDeleting}
|
||||
deleteError={deleteError}
|
||||
onSaveSelected={this.onSaveSelected}
|
||||
/>
|
||||
}
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={styles.bookType}
|
||||
>
|
||||
<div className={styles.books}>
|
||||
<Table
|
||||
columns={columns}
|
||||
columns={titleColumns}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onSortPress={onSortPress}
|
||||
@@ -76,6 +113,9 @@ class AuthorDetailsSeason extends Component {
|
||||
columns={columns}
|
||||
{...item}
|
||||
onMonitorBookPress={this.onMonitorBookPress}
|
||||
isBookEditorActive={isBookEditorActive}
|
||||
isSelected={selectedState[item.id]}
|
||||
onSelectedChange={this.onSelectedChange}
|
||||
/>
|
||||
);
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<TableSelectCell
|
||||
key={name}
|
||||
id={id}
|
||||
isSelected={isSelected}
|
||||
isDisabled={false}
|
||||
onSelectedChange={onSelectedChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'monitored') {
|
||||
return (
|
||||
<TableRowCell
|
||||
@@ -220,6 +236,9 @@ BookRow.propTypes = {
|
||||
isSaving: PropTypes.bool,
|
||||
authorMonitored: PropTypes.bool.isRequired,
|
||||
bookFiles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isBookEditorActive: PropTypes.bool.isRequired,
|
||||
isSelected: PropTypes.bool,
|
||||
onSelectedChange: PropTypes.func.isRequired,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onMonitorBookPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
@@ -6,11 +6,13 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
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 getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
class EditBookModalContent extends Component {
|
||||
@@ -36,6 +38,9 @@ class EditBookModalContent extends Component {
|
||||
authorName,
|
||||
statistics,
|
||||
item,
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
isSaving,
|
||||
onInputChange,
|
||||
onModalClose,
|
||||
@@ -49,6 +54,7 @@ class EditBookModalContent extends Component {
|
||||
} = item;
|
||||
|
||||
const hasFile = statistics ? statistics.bookFileCount : 0;
|
||||
const errorMessage = getErrorMessage(error, 'Unable to load editions');
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
@@ -88,6 +94,18 @@ class EditBookModalContent extends Component {
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
error &&
|
||||
<div>{errorMessage}</div>
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && !isFetching && !!editions.value.length &&
|
||||
<FormGroup>
|
||||
<FormLabel>
|
||||
{translate('Edition')}
|
||||
@@ -102,6 +120,7 @@ class EditBookModalContent extends Component {
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
}
|
||||
|
||||
</Form>
|
||||
</ModalBody>
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
70
frontend/src/Book/Editor/BookEditorFooter.css
Normal file
70
frontend/src/Book/Editor/BookEditorFooter.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
156
frontend/src/Book/Editor/BookEditorFooter.js
Normal file
156
frontend/src/Book/Editor/BookEditorFooter.js
Normal file
@@ -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 (
|
||||
<PageContentFooter>
|
||||
<div className={styles.inputContainer}>
|
||||
<BookEditorFooterLabel
|
||||
label={translate('MonitorBook')}
|
||||
isSaving={isSaving && monitored !== NO_CHANGE}
|
||||
/>
|
||||
|
||||
<SelectInput
|
||||
name="monitored"
|
||||
value={monitored}
|
||||
values={monitoredOptions}
|
||||
isDisabled={!selectedCount}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonContainer}>
|
||||
<div className={styles.buttonContainerContent}>
|
||||
<BookEditorFooterLabel
|
||||
label={translate('SelectedCountBooksSelectedInterp', [selectedCount])}
|
||||
isSaving={false}
|
||||
/>
|
||||
|
||||
<div className={styles.buttons}>
|
||||
<SpinnerButton
|
||||
className={styles.deleteSelectedButton}
|
||||
kind={kinds.DANGER}
|
||||
isSpinning={isDeleting}
|
||||
isDisabled={!selectedCount || isDeleting}
|
||||
onPress={this.onDeleteSelectedPress}
|
||||
>
|
||||
Delete
|
||||
</SpinnerButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteBookModal
|
||||
isOpen={isDeleteBookModalOpen}
|
||||
bookIds={bookIds}
|
||||
onModalClose={this.onDeleteBookModalClose}
|
||||
/>
|
||||
|
||||
</PageContentFooter>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
8
frontend/src/Book/Editor/BookEditorFooterLabel.css
Normal file
8
frontend/src/Book/Editor/BookEditorFooterLabel.css
Normal file
@@ -0,0 +1,8 @@
|
||||
.label {
|
||||
margin-bottom: 3px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.savingIcon {
|
||||
margin-left: 8px;
|
||||
}
|
||||
40
frontend/src/Book/Editor/BookEditorFooterLabel.js
Normal file
40
frontend/src/Book/Editor/BookEditorFooterLabel.js
Normal file
@@ -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 (
|
||||
<div className={className}>
|
||||
{label}
|
||||
|
||||
{
|
||||
isSaving &&
|
||||
<SpinnerIcon
|
||||
className={styles.savingIcon}
|
||||
name={icons.SPINNER}
|
||||
isSpinning={true}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
BookEditorFooterLabel.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
label: PropTypes.string.isRequired,
|
||||
isSaving: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
BookEditorFooterLabel.defaultProps = {
|
||||
className: styles.label
|
||||
};
|
||||
|
||||
export default BookEditorFooterLabel;
|
||||
31
frontend/src/Book/Editor/Delete/DeleteBookModal.js
Normal file
31
frontend/src/Book/Editor/Delete/DeleteBookModal.js
Normal file
@@ -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 (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<DeleteBookModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
DeleteBookModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default DeleteBookModal;
|
||||
@@ -0,0 +1,9 @@
|
||||
.message {
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.deleteFilesMessage {
|
||||
margin-top: 20px;
|
||||
color: $dangerColor;
|
||||
}
|
||||
172
frontend/src/Book/Editor/Delete/DeleteBookModalContent.js
Normal file
172
frontend/src/Book/Editor/Delete/DeleteBookModalContent.js
Normal file
@@ -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 (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
Delete Selected Book
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div>
|
||||
<FormGroup>
|
||||
<FormLabel>{`Delete File${book.length > 1 ? 's' : ''}`}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="deleteFiles"
|
||||
value={deleteFiles}
|
||||
helpText={'Delete book files'}
|
||||
kind={kinds.DANGER}
|
||||
isDisabled={files.length === 0}
|
||||
onChange={this.onDeleteFilesChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('AddListExclusion')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="addImportListExclusion"
|
||||
value={addImportListExclusion}
|
||||
helpText={translate('AddImportListExclusionHelpText')}
|
||||
kind={kinds.DANGER}
|
||||
onChange={this.onAddImportListExclusionChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
{
|
||||
!addImportListExclusion &&
|
||||
<div className={styles.deleteFilesMessage}>
|
||||
<div>
|
||||
{translate('IfYouDontAddAnImportListExclusionAndTheAuthorHasAMetadataProfileOtherThanNoneThenThisBookMayBeReaddedDuringTheNextAuthorRefresh')}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
|
||||
<div className={styles.message}>
|
||||
{`Are you sure you want to delete ${book.length} selected book${book.length > 1 ? 's' : ''}${deleteFiles ? ' and their files' : ''}?`}
|
||||
</div>
|
||||
|
||||
<ul>
|
||||
{
|
||||
book.map((s) => {
|
||||
return (
|
||||
<li key={s.title}>
|
||||
<span>{s.title}</span>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
|
||||
{
|
||||
deleteFiles &&
|
||||
<div>
|
||||
<div className={styles.deleteFilesMessage}>
|
||||
{translate('TheFollowingFilesWillBeDeleted')}
|
||||
</div>
|
||||
<ul>
|
||||
{
|
||||
files.map((s) => {
|
||||
return (
|
||||
<li key={s.path}>
|
||||
<span>{s.path}</span>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
kind={kinds.DANGER}
|
||||
onPress={this.onDeleteBookConfirmed}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -132,6 +132,14 @@ export const defaultState = {
|
||||
},
|
||||
|
||||
columns: [
|
||||
{
|
||||
name: 'select',
|
||||
columnLabel: 'Select',
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
isModifiable: false,
|
||||
isHidden: true
|
||||
},
|
||||
{
|
||||
name: 'monitored',
|
||||
columnLabel: 'Monitored',
|
||||
|
||||
114
frontend/src/Store/Actions/bookEditorActions.js
Normal file
114
frontend/src/Store/Actions/bookEditorActions.js
Normal file
@@ -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);
|
||||
54
frontend/src/Store/Actions/editionActions.js
Normal file
54
frontend/src/Store/Actions/editionActions.js
Normal file
@@ -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);
|
||||
@@ -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,
|
||||
|
||||
@@ -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<IOsInfo>().Setup(c => c.Name).Returns("TestOS");
|
||||
Mocker.GetMock<IOsInfo>().Setup(c => c.Version).Returns("9.0.0");
|
||||
|
||||
Mocker.GetMock<IConfigService>().SetupGet(x => x.CertificateValidation).Returns(CertificateValidationType.Enabled);
|
||||
|
||||
Mocker.SetConstant<IUserAgentBuilder>(Mocker.Resolve<UserAgentBuilder>());
|
||||
|
||||
Mocker.SetConstant<ICacheManager>(Mocker.Resolve<CacheManager>());
|
||||
Mocker.SetConstant<ICreateManagedWebProxy>(Mocker.Resolve<ManagedWebProxyFactory>());
|
||||
Mocker.SetConstant<ICertificateValidationService>(new X509CertificateValidationService(Mocker.GetMock<IConfigService>().Object, TestLogger));
|
||||
Mocker.SetConstant<IRateLimitService>(Mocker.Resolve<RateLimitService>());
|
||||
Mocker.SetConstant<IEnumerable<IHttpRequestInterceptor>>(new IHttpRequestInterceptor[0]);
|
||||
Mocker.SetConstant<IHttpDispatcher>(Mocker.Resolve<TDispatcher>());
|
||||
@@ -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<IConfigService>().SetupGet(x => x.CertificateValidation).Returns(validationType);
|
||||
var request = new HttpRequest($"https://expired.badssl.com");
|
||||
|
||||
Assert.Throws<HttpRequestException>(() => Subject.Execute(request));
|
||||
ExceptionVerification.ExpectedErrors(2);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void bad_ssl_should_pass_if_remote_validation_disabled()
|
||||
{
|
||||
Mocker.GetMock<IConfigService>().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()
|
||||
{
|
||||
|
||||
@@ -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<IAppFolderFactory>().Register();
|
||||
container.RegisterInstance<IHostLifetime>(new Mock<IHostLifetime>().Object);
|
||||
|
||||
Mocker.SetConstant<System.IServiceProvider>(container);
|
||||
var serviceProvider = container.GetServiceProvider();
|
||||
serviceProvider.GetRequiredService<IAppFolderFactory>().Register();
|
||||
|
||||
Mocker.SetConstant<System.IServiceProvider>(serviceProvider);
|
||||
|
||||
var handlers = Subject.BuildAll<IHandle<ApplicationStartedEvent>>()
|
||||
.Select(c => c.GetType().FullName);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<System.Net.Http.HttpClient> _httpClientCache;
|
||||
private readonly ICached<CredentialCache> _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)
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DryIoc.dll" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
|
||||
<PackageReference Include="NLog.Extensions.Logging" />
|
||||
<PackageReference Include="Newtonsoft.Json" />
|
||||
<PackageReference Include="NLog" />
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<TargetFrameworks>net6.0</TargetFrameworks>
|
||||
|
||||
<ApplicationIcon>..\NzbDrone.Host\Readarr.ico</ApplicationIcon>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="!$(RuntimeIdentifier.StartsWith('win'))">
|
||||
<AssemblyName>Readarr</AssemblyName>
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<!-- UAC Manifest Options
|
||||
If you want to change the Windows User Account Control level replace the
|
||||
requestedExecutionLevel node with one of the following.
|
||||
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
|
||||
|
||||
Specifying requestedExecutionLevel element will disable file and registry virtualization.
|
||||
Remove this element if your application requires this virtualization for backwards
|
||||
compatibility.
|
||||
-->
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on and is
|
||||
is designed to work with. Uncomment the appropriate elements and Windows will
|
||||
automatically selected the most compatible environment. -->
|
||||
|
||||
<!-- Windows Vista -->
|
||||
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />
|
||||
|
||||
<!-- Windows 7 -->
|
||||
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
|
||||
|
||||
<!-- Windows 8 -->
|
||||
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
|
||||
|
||||
<!-- Windows 8.1 -->
|
||||
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
@@ -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<IHttpProxySettingsProvider>(new HttpProxySettingsProvider(Mocker.Resolve<ConfigService>()));
|
||||
Mocker.SetConstant<ICreateManagedWebProxy>(new ManagedWebProxyFactory(Mocker.Resolve<CacheManager>()));
|
||||
Mocker.SetConstant<IHttpDispatcher>(new ManagedHttpDispatcher(Mocker.Resolve<IHttpProxySettingsProvider>(), Mocker.Resolve<ICreateManagedWebProxy>(), Mocker.Resolve<UserAgentBuilder>(), Mocker.Resolve<CacheManager>(), TestLogger));
|
||||
Mocker.SetConstant<ICertificateValidationService>(new X509CertificateValidationService(Mocker.Resolve<ConfigService>(), TestLogger));
|
||||
Mocker.SetConstant<IHttpDispatcher>(new ManagedHttpDispatcher(Mocker.Resolve<IHttpProxySettingsProvider>(), Mocker.Resolve<ICreateManagedWebProxy>(), Mocker.Resolve<ICertificateValidationService>(), Mocker.Resolve<UserAgentBuilder>(), Mocker.Resolve<CacheManager>(), TestLogger));
|
||||
Mocker.SetConstant<IHttpClient>(new HttpClient(new IHttpRequestInterceptor[0], Mocker.Resolve<CacheManager>(), Mocker.Resolve<RateLimitService>(), Mocker.Resolve<IHttpDispatcher>(), TestLogger));
|
||||
Mocker.SetConstant<IReadarrCloudRequestBuilder>(new ReadarrCloudRequestBuilder());
|
||||
Mocker.SetConstant<IMetadataRequestBuilder>(Mocker.Resolve<MetadataRequestBuilder>());
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace NzbDrone.Core.Books
|
||||
{
|
||||
public interface IEditionRepository : IBasicRepository<Edition>
|
||||
{
|
||||
List<Edition> GetAllMonitoredEditions();
|
||||
Edition FindByForeignEditionId(string foreignEditionId);
|
||||
List<Edition> FindByBook(int id);
|
||||
List<Edition> FindByAuthor(int id);
|
||||
@@ -25,6 +26,11 @@ namespace NzbDrone.Core.Books
|
||||
{
|
||||
}
|
||||
|
||||
public List<Edition> GetAllMonitoredEditions()
|
||||
{
|
||||
return Query(x => x.Monitored == true);
|
||||
}
|
||||
|
||||
public Edition FindByForeignEditionId(string foreignEditionId)
|
||||
{
|
||||
var edition = Query(x => x.ForeignEditionId == foreignEditionId).SingleOrDefault();
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace NzbDrone.Core.Books
|
||||
{
|
||||
Edition GetEdition(int id);
|
||||
Edition GetEditionByForeignEditionId(string foreignEditionId);
|
||||
List<Edition> GetAllEditions();
|
||||
List<Edition> GetAllMonitoredEditions();
|
||||
void InsertMany(List<Edition> editions);
|
||||
void UpdateMany(List<Edition> editions);
|
||||
void DeleteMany(List<Edition> editions);
|
||||
@@ -48,9 +48,9 @@ namespace NzbDrone.Core.Books
|
||||
return _editionRepository.FindByForeignEditionId(foreignEditionId);
|
||||
}
|
||||
|
||||
public List<Edition> GetAllEditions()
|
||||
public List<Edition> GetAllMonitoredEditions()
|
||||
{
|
||||
return _editionRepository.All().ToList();
|
||||
return _editionRepository.GetAllMonitoredEditions();
|
||||
}
|
||||
|
||||
public void InsertMany(List<Edition> editions)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ApplicationStartedEvent>
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IBroadcastSignalRMessage>();
|
||||
|
||||
var container = new Container(rules => rules.WithNzbDroneRules())
|
||||
.AutoAddServices(Bootstrap.ASSEMBLIES)
|
||||
.AddNzbDroneLogger()
|
||||
.AddDummyDatabase()
|
||||
.AddStartupContext(args);
|
||||
|
||||
container.RegisterInstance<IBroadcastSignalRMessage>(mockBroadcaster.Object);
|
||||
// set up a dummy broadcaster and lifetime to allow tests to resolve
|
||||
container.RegisterInstance<IHostLifetime>(new Mock<IHostLifetime>().Object);
|
||||
container.RegisterInstance<IBroadcastSignalRMessage>(new Mock<IBroadcastSignalRMessage>().Object);
|
||||
|
||||
_container = container.GetServiceProvider();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
<RuntimeIdentifiers>win-x64;win-x86</RuntimeIdentifiers>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ApplicationIcon>..\NzbDrone.Host\Readarr.ico</ApplicationIcon>
|
||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||
<GenerateResourceUsePreserializedResources>true</GenerateResourceUsePreserializedResources>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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
|
||||
if (_trayIcon == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_trayIcon.Visible = false;
|
||||
_trayIcon.Icon = null;
|
||||
_trayIcon.Visible = false;
|
||||
_trayIcon.Dispose();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IHostedService, SystemTrayApp>());
|
||||
});
|
||||
Bootstrap.Start(args, e => { e.ConfigureServices((_, s) => s.AddSingleton<IHostedService, SystemTrayApp>()); });
|
||||
}
|
||||
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!");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
|
||||
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
|
||||
<security>
|
||||
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<!-- UAC Manifest Options
|
||||
If you want to change the Windows User Account Control level replace the
|
||||
requestedExecutionLevel node with one of the following.
|
||||
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
|
||||
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
|
||||
|
||||
Specifying requestedExecutionLevel element will disable file and registry virtualization.
|
||||
Remove this element if your application requires this virtualization for backwards
|
||||
compatibility.
|
||||
-->
|
||||
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||
</requestedPrivileges>
|
||||
</security>
|
||||
</trustInfo>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<!-- A list of the Windows versions that this application has been tested on and is
|
||||
is designed to work with. Uncomment the appropriate elements and Windows will
|
||||
automatically selected the most compatible environment. -->
|
||||
|
||||
<!-- Windows Vista -->
|
||||
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />
|
||||
|
||||
<!-- Windows 7 -->
|
||||
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />
|
||||
|
||||
<!-- Windows 8 -->
|
||||
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />
|
||||
|
||||
<!-- Windows 8.1 -->
|
||||
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />
|
||||
|
||||
<!-- Windows 10 -->
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
|
||||
</application>
|
||||
</compatibility>
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
|
||||
</windowsSettings>
|
||||
</application>
|
||||
</assembly>
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
48
src/Readarr.Api.V1/Books/BookEditorController.cs
Normal file
48
src/Readarr.Api.V1/Books/BookEditorController.cs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/Readarr.Api.V1/Books/BookEditorResource.cs
Normal file
12
src/Readarr.Api.V1/Books/BookEditorResource.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Readarr.Api.V1.Books
|
||||
{
|
||||
public class BookEditorResource
|
||||
{
|
||||
public List<int> BookIds { get; set; }
|
||||
public bool? Monitored { get; set; }
|
||||
public bool? DeleteFiles { get; set; }
|
||||
public bool? AddImportListExclusion { get; set; }
|
||||
}
|
||||
}
|
||||
27
src/Readarr.Api.V1/Editions/EditionController.cs
Normal file
27
src/Readarr.Api.V1/Editions/EditionController.cs
Normal file
@@ -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<EditionResource> GetEditions(int bookId)
|
||||
{
|
||||
var editions = _editionService.GetEditionsByBook(bookId);
|
||||
|
||||
return editions.ToResource();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user