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 EditAuthorModalConnector from 'Author/Edit/EditAuthorModalConnector';
|
||||||
import AuthorHistoryTable from 'Author/History/AuthorHistoryTable';
|
import AuthorHistoryTable from 'Author/History/AuthorHistoryTable';
|
||||||
import MonitoringOptionsModal from 'Author/MonitoringOptions/MonitoringOptionsModal';
|
import MonitoringOptionsModal from 'Author/MonitoringOptions/MonitoringOptionsModal';
|
||||||
|
import BookEditorFooter from 'Book/Editor/BookEditorFooter';
|
||||||
import BookFileEditorTable from 'BookFile/Editor/BookFileEditorTable';
|
import BookFileEditorTable from 'BookFile/Editor/BookFileEditorTable';
|
||||||
import IconButton from 'Components/Link/IconButton';
|
import IconButton from 'Components/Link/IconButton';
|
||||||
import Link from 'Components/Link/Link';
|
import Link from 'Components/Link/Link';
|
||||||
@@ -22,6 +23,7 @@ import InteractiveSearchTable from 'InteractiveSearch/InteractiveSearchTable';
|
|||||||
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
|
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
|
||||||
import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
|
import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
|
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||||
import selectAll from 'Utilities/Table/selectAll';
|
import selectAll from 'Utilities/Table/selectAll';
|
||||||
import toggleSelected from 'Utilities/Table/toggleSelected';
|
import toggleSelected from 'Utilities/Table/toggleSelected';
|
||||||
import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal';
|
import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal';
|
||||||
@@ -53,13 +55,56 @@ class AuthorDetails extends Component {
|
|||||||
isDeleteAuthorModalOpen: false,
|
isDeleteAuthorModalOpen: false,
|
||||||
isInteractiveImportModalOpen: false,
|
isInteractiveImportModalOpen: false,
|
||||||
isMonitorOptionsModalOpen: false,
|
isMonitorOptionsModalOpen: false,
|
||||||
|
isBookEditorActive: false,
|
||||||
allExpanded: false,
|
allExpanded: false,
|
||||||
allCollapsed: false,
|
allCollapsed: false,
|
||||||
expandedState: {},
|
expandedState: {},
|
||||||
|
allSelected: false,
|
||||||
|
allUnselected: false,
|
||||||
|
lastToggled: null,
|
||||||
|
selectedState: {},
|
||||||
selectedTabIndex: 0
|
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
|
// Listeners
|
||||||
|
|
||||||
@@ -114,6 +159,10 @@ class AuthorDetails extends Component {
|
|||||||
this.setState({ isMonitorOptionsModalOpen: false });
|
this.setState({ isMonitorOptionsModalOpen: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBookEditorTogglePress = () => {
|
||||||
|
this.setState({ isBookEditorActive: !this.state.isBookEditorActive });
|
||||||
|
}
|
||||||
|
|
||||||
onExpandAllPress = () => {
|
onExpandAllPress = () => {
|
||||||
const {
|
const {
|
||||||
allExpanded,
|
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) => {
|
onTabSelect = (index, lastIndex) => {
|
||||||
this.setState({ selectedTabIndex: index });
|
this.setState({ selectedTabIndex: index });
|
||||||
}
|
}
|
||||||
@@ -165,6 +235,10 @@ class AuthorDetails extends Component {
|
|||||||
nextAuthor,
|
nextAuthor,
|
||||||
onRefreshPress,
|
onRefreshPress,
|
||||||
onSearchPress,
|
onSearchPress,
|
||||||
|
isSaving,
|
||||||
|
saveError,
|
||||||
|
isDeleting,
|
||||||
|
deleteError,
|
||||||
statistics
|
statistics
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
@@ -175,6 +249,9 @@ class AuthorDetails extends Component {
|
|||||||
isDeleteAuthorModalOpen,
|
isDeleteAuthorModalOpen,
|
||||||
isInteractiveImportModalOpen,
|
isInteractiveImportModalOpen,
|
||||||
isMonitorOptionsModalOpen,
|
isMonitorOptionsModalOpen,
|
||||||
|
isBookEditorActive,
|
||||||
|
allSelected,
|
||||||
|
selectedState,
|
||||||
allExpanded,
|
allExpanded,
|
||||||
allCollapsed,
|
allCollapsed,
|
||||||
expandedState,
|
expandedState,
|
||||||
@@ -189,6 +266,8 @@ class AuthorDetails extends Component {
|
|||||||
expandIcon = icons.EXPAND;
|
expandIcon = icons.EXPAND;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const selectedBookIds = this.getSelectedIds();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContent title={authorName}>
|
<PageContent title={authorName}>
|
||||||
<PageToolbar>
|
<PageToolbar>
|
||||||
@@ -252,6 +331,33 @@ class AuthorDetails extends Component {
|
|||||||
iconName={icons.DELETE}
|
iconName={icons.DELETE}
|
||||||
onPress={this.onDeleteAuthorPress}
|
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>
|
||||||
|
|
||||||
<PageToolbarSection alignContent={align.RIGHT}>
|
<PageToolbarSection alignContent={align.RIGHT}>
|
||||||
@@ -377,7 +483,11 @@ class AuthorDetails extends Component {
|
|||||||
<AuthorDetailsSeasonConnector
|
<AuthorDetailsSeasonConnector
|
||||||
authorId={id}
|
authorId={id}
|
||||||
isExpanded={true}
|
isExpanded={true}
|
||||||
|
selectedState={selectedState}
|
||||||
onExpandPress={this.onExpandPress}
|
onExpandPress={this.onExpandPress}
|
||||||
|
setSelectedState={this.setSelectedState}
|
||||||
|
onSelectedChange={this.onSelectedChange}
|
||||||
|
isBookEditorActive={isBookEditorActive}
|
||||||
/>
|
/>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
@@ -422,7 +532,6 @@ class AuthorDetails extends Component {
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
}
|
}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.metadataMessage}>
|
<div className={styles.metadataMessage}>
|
||||||
@@ -474,6 +583,19 @@ class AuthorDetails extends Component {
|
|||||||
onModalClose={this.onMonitorOptionsClose}
|
onModalClose={this.onMonitorOptionsClose}
|
||||||
/>
|
/>
|
||||||
</PageContentBody>
|
</PageContentBody>
|
||||||
|
|
||||||
|
{
|
||||||
|
isBookEditorActive &&
|
||||||
|
<BookEditorFooter
|
||||||
|
bookIds={selectedBookIds}
|
||||||
|
selectedCount={selectedBookIds.length}
|
||||||
|
isSaving={isSaving}
|
||||||
|
saveError={saveError}
|
||||||
|
isDeleting={isDeleting}
|
||||||
|
deleteError={deleteError}
|
||||||
|
onSaveSelected={this.onSaveSelected}
|
||||||
|
/>
|
||||||
|
}
|
||||||
</PageContent>
|
</PageContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -493,7 +615,6 @@ AuthorDetails.propTypes = {
|
|||||||
images: PropTypes.arrayOf(PropTypes.object).isRequired,
|
images: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
|
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||||
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
|
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||||
isSaving: PropTypes.bool.isRequired,
|
|
||||||
isRefreshing: PropTypes.bool.isRequired,
|
isRefreshing: PropTypes.bool.isRequired,
|
||||||
isSearching: PropTypes.bool.isRequired,
|
isSearching: PropTypes.bool.isRequired,
|
||||||
isFetching: PropTypes.bool.isRequired,
|
isFetching: PropTypes.bool.isRequired,
|
||||||
@@ -510,13 +631,17 @@ AuthorDetails.propTypes = {
|
|||||||
isSmallScreen: PropTypes.bool.isRequired,
|
isSmallScreen: PropTypes.bool.isRequired,
|
||||||
onMonitorTogglePress: PropTypes.func.isRequired,
|
onMonitorTogglePress: PropTypes.func.isRequired,
|
||||||
onRefreshPress: 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 = {
|
AuthorDetails.defaultProps = {
|
||||||
statistics: {},
|
statistics: {},
|
||||||
tags: [],
|
tags: []
|
||||||
isSaving: false
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AuthorDetails;
|
export default AuthorDetails;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { connect } from 'react-redux';
|
|||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import * as commandNames from 'Commands/commandNames';
|
import * as commandNames from 'Commands/commandNames';
|
||||||
import { toggleAuthorMonitored } from 'Store/Actions/authorActions';
|
import { toggleAuthorMonitored } from 'Store/Actions/authorActions';
|
||||||
|
import { saveBookEditor } from 'Store/Actions/bookEditorActions';
|
||||||
import { clearBookFiles, fetchBookFiles } from 'Store/Actions/bookFileActions';
|
import { clearBookFiles, fetchBookFiles } from 'Store/Actions/bookFileActions';
|
||||||
import { executeCommand } from 'Store/Actions/commandActions';
|
import { executeCommand } from 'Store/Actions/commandActions';
|
||||||
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
|
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
|
||||||
@@ -21,7 +22,8 @@ import AuthorDetails from './AuthorDetails';
|
|||||||
|
|
||||||
const selectBooks = createSelector(
|
const selectBooks = createSelector(
|
||||||
(state) => state.books,
|
(state) => state.books,
|
||||||
(books) => {
|
(state) => state.bookEditor,
|
||||||
|
(books, editor) => {
|
||||||
const {
|
const {
|
||||||
items,
|
items,
|
||||||
isFetching,
|
isFetching,
|
||||||
@@ -37,7 +39,8 @@ const selectBooks = createSelector(
|
|||||||
isBooksPopulated: isPopulated,
|
isBooksPopulated: isPopulated,
|
||||||
booksError: error,
|
booksError: error,
|
||||||
hasBooks,
|
hasBooks,
|
||||||
hasMonitoredBooks
|
hasMonitoredBooks,
|
||||||
|
...editor
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -187,6 +190,7 @@ function createMapStateToProps() {
|
|||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
fetchSeries,
|
fetchSeries,
|
||||||
clearSeries,
|
clearSeries,
|
||||||
|
saveBookEditor,
|
||||||
fetchBookFiles,
|
fetchBookFiles,
|
||||||
clearBookFiles,
|
clearBookFiles,
|
||||||
toggleAuthorMonitored,
|
toggleAuthorMonitored,
|
||||||
@@ -282,6 +286,10 @@ class AuthorDetailsConnector extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSaveSelected = (payload) => {
|
||||||
|
this.props.saveBookEditor(payload);
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Render
|
// Render
|
||||||
|
|
||||||
@@ -292,6 +300,7 @@ class AuthorDetailsConnector extends Component {
|
|||||||
onMonitorTogglePress={this.onMonitorTogglePress}
|
onMonitorTogglePress={this.onMonitorTogglePress}
|
||||||
onRefreshPress={this.onRefreshPress}
|
onRefreshPress={this.onRefreshPress}
|
||||||
onSearchPress={this.onSearchPress}
|
onSearchPress={this.onSearchPress}
|
||||||
|
onSaveSelected={this.onSaveSelected}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -307,6 +316,7 @@ AuthorDetailsConnector.propTypes = {
|
|||||||
isRenamingAuthor: PropTypes.bool.isRequired,
|
isRenamingAuthor: PropTypes.bool.isRequired,
|
||||||
fetchSeries: PropTypes.func.isRequired,
|
fetchSeries: PropTypes.func.isRequired,
|
||||||
clearSeries: PropTypes.func.isRequired,
|
clearSeries: PropTypes.func.isRequired,
|
||||||
|
saveBookEditor: PropTypes.func.isRequired,
|
||||||
fetchBookFiles: PropTypes.func.isRequired,
|
fetchBookFiles: PropTypes.func.isRequired,
|
||||||
clearBookFiles: PropTypes.func.isRequired,
|
clearBookFiles: PropTypes.func.isRequired,
|
||||||
toggleAuthorMonitored: PropTypes.func.isRequired,
|
toggleAuthorMonitored: PropTypes.func.isRequired,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { Component } from 'react';
|
|||||||
import Table from 'Components/Table/Table';
|
import Table from 'Components/Table/Table';
|
||||||
import TableBody from 'Components/Table/TableBody';
|
import TableBody from 'Components/Table/TableBody';
|
||||||
import { sortDirections } from 'Helpers/Props';
|
import { sortDirections } from 'Helpers/Props';
|
||||||
|
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
|
||||||
import getToggledRange from 'Utilities/Table/getToggledRange';
|
import getToggledRange from 'Utilities/Table/getToggledRange';
|
||||||
import BookRowConnector from './BookRowConnector';
|
import BookRowConnector from './BookRowConnector';
|
||||||
import styles from './AuthorDetailsSeason.css';
|
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
|
// Listeners
|
||||||
|
|
||||||
@@ -42,26 +63,42 @@ class AuthorDetailsSeason extends Component {
|
|||||||
this.props.onMonitorBookPress(_.uniq(bookIds), monitored);
|
this.props.onMonitorBookPress(_.uniq(bookIds), monitored);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onSelectedChange = ({ id, value, shiftKey = false }) => {
|
||||||
|
const {
|
||||||
|
onSelectedChange,
|
||||||
|
items
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
return onSelectedChange(items, id, value, shiftKey);
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Render
|
// Render
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
items,
|
items,
|
||||||
|
isBookEditorActive,
|
||||||
columns,
|
columns,
|
||||||
sortKey,
|
sortKey,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
onSortPress,
|
onSortPress,
|
||||||
onTableOptionChange
|
onTableOptionChange,
|
||||||
|
selectedState
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
|
let titleColumns = columns;
|
||||||
|
if (!isBookEditorActive) {
|
||||||
|
titleColumns = columns.filter((x) => x.name !== 'select');
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={styles.bookType}
|
className={styles.bookType}
|
||||||
>
|
>
|
||||||
<div className={styles.books}>
|
<div className={styles.books}>
|
||||||
<Table
|
<Table
|
||||||
columns={columns}
|
columns={titleColumns}
|
||||||
sortKey={sortKey}
|
sortKey={sortKey}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
onSortPress={onSortPress}
|
onSortPress={onSortPress}
|
||||||
@@ -76,6 +113,9 @@ class AuthorDetailsSeason extends Component {
|
|||||||
columns={columns}
|
columns={columns}
|
||||||
{...item}
|
{...item}
|
||||||
onMonitorBookPress={this.onMonitorBookPress}
|
onMonitorBookPress={this.onMonitorBookPress}
|
||||||
|
isBookEditorActive={isBookEditorActive}
|
||||||
|
isSelected={selectedState[item.id]}
|
||||||
|
onSelectedChange={this.onSelectedChange}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -92,9 +132,13 @@ AuthorDetailsSeason.propTypes = {
|
|||||||
sortKey: PropTypes.string,
|
sortKey: PropTypes.string,
|
||||||
sortDirection: PropTypes.oneOf(sortDirections.all),
|
sortDirection: PropTypes.oneOf(sortDirections.all),
|
||||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
isBookEditorActive: PropTypes.bool.isRequired,
|
||||||
|
selectedState: PropTypes.object.isRequired,
|
||||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
onTableOptionChange: PropTypes.func.isRequired,
|
onTableOptionChange: PropTypes.func.isRequired,
|
||||||
onExpandPress: PropTypes.func.isRequired,
|
onExpandPress: PropTypes.func.isRequired,
|
||||||
|
setSelectedState: PropTypes.func.isRequired,
|
||||||
|
onSelectedChange: PropTypes.func.isRequired,
|
||||||
onSortPress: PropTypes.func.isRequired,
|
onSortPress: PropTypes.func.isRequired,
|
||||||
onMonitorBookPress: PropTypes.func.isRequired,
|
onMonitorBookPress: PropTypes.func.isRequired,
|
||||||
uiSettings: PropTypes.object.isRequired
|
uiSettings: PropTypes.object.isRequired
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import MonitorToggleButton from 'Components/MonitorToggleButton';
|
|||||||
import StarRating from 'Components/StarRating';
|
import StarRating from 'Components/StarRating';
|
||||||
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||||
|
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
|
||||||
import TableRow from 'Components/Table/TableRow';
|
import TableRow from 'Components/Table/TableRow';
|
||||||
import BookStatus from './BookStatus';
|
import BookStatus from './BookStatus';
|
||||||
import styles from './BookRow.css';
|
import styles from './BookRow.css';
|
||||||
@@ -65,6 +66,9 @@ class BookRow extends Component {
|
|||||||
authorMonitored,
|
authorMonitored,
|
||||||
titleSlug,
|
titleSlug,
|
||||||
bookFiles,
|
bookFiles,
|
||||||
|
isBookEditorActive,
|
||||||
|
isSelected,
|
||||||
|
onSelectedChange,
|
||||||
columns
|
columns
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
@@ -84,6 +88,18 @@ class BookRow extends Component {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isBookEditorActive && name === 'select') {
|
||||||
|
return (
|
||||||
|
<TableSelectCell
|
||||||
|
key={name}
|
||||||
|
id={id}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isDisabled={false}
|
||||||
|
onSelectedChange={onSelectedChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (name === 'monitored') {
|
if (name === 'monitored') {
|
||||||
return (
|
return (
|
||||||
<TableRowCell
|
<TableRowCell
|
||||||
@@ -220,6 +236,9 @@ BookRow.propTypes = {
|
|||||||
isSaving: PropTypes.bool,
|
isSaving: PropTypes.bool,
|
||||||
authorMonitored: PropTypes.bool.isRequired,
|
authorMonitored: PropTypes.bool.isRequired,
|
||||||
bookFiles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
bookFiles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
isBookEditorActive: PropTypes.bool.isRequired,
|
||||||
|
isSelected: PropTypes.bool,
|
||||||
|
onSelectedChange: PropTypes.func.isRequired,
|
||||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
onMonitorBookPress: PropTypes.func.isRequired
|
onMonitorBookPress: PropTypes.func.isRequired
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,11 +6,13 @@ import FormInputGroup from 'Components/Form/FormInputGroup';
|
|||||||
import FormLabel from 'Components/Form/FormLabel';
|
import FormLabel from 'Components/Form/FormLabel';
|
||||||
import Button from 'Components/Link/Button';
|
import Button from 'Components/Link/Button';
|
||||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
import ModalBody from 'Components/Modal/ModalBody';
|
import ModalBody from 'Components/Modal/ModalBody';
|
||||||
import ModalContent from 'Components/Modal/ModalContent';
|
import ModalContent from 'Components/Modal/ModalContent';
|
||||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
import { inputTypes } from 'Helpers/Props';
|
import { inputTypes } from 'Helpers/Props';
|
||||||
|
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
||||||
class EditBookModalContent extends Component {
|
class EditBookModalContent extends Component {
|
||||||
@@ -36,6 +38,9 @@ class EditBookModalContent extends Component {
|
|||||||
authorName,
|
authorName,
|
||||||
statistics,
|
statistics,
|
||||||
item,
|
item,
|
||||||
|
isFetching,
|
||||||
|
isPopulated,
|
||||||
|
error,
|
||||||
isSaving,
|
isSaving,
|
||||||
onInputChange,
|
onInputChange,
|
||||||
onModalClose,
|
onModalClose,
|
||||||
@@ -49,6 +54,7 @@ class EditBookModalContent extends Component {
|
|||||||
} = item;
|
} = item;
|
||||||
|
|
||||||
const hasFile = statistics ? statistics.bookFileCount : 0;
|
const hasFile = statistics ? statistics.bookFileCount : 0;
|
||||||
|
const errorMessage = getErrorMessage(error, 'Unable to load editions');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ModalContent onModalClose={onModalClose}>
|
<ModalContent onModalClose={onModalClose}>
|
||||||
@@ -88,20 +94,33 @@ class EditBookModalContent extends Component {
|
|||||||
/>
|
/>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
||||||
<FormGroup>
|
{
|
||||||
<FormLabel>
|
isFetching &&
|
||||||
{translate('Edition')}
|
<LoadingIndicator />
|
||||||
</FormLabel>
|
}
|
||||||
|
|
||||||
<FormInputGroup
|
{
|
||||||
type={inputTypes.BOOK_EDITION_SELECT}
|
error &&
|
||||||
name="editions"
|
<div>{errorMessage}</div>
|
||||||
helpText={translate('EditionsHelpText')}
|
}
|
||||||
isDisabled={anyEditionOk.value && hasFile}
|
|
||||||
bookEditions={editions}
|
{
|
||||||
onChange={onInputChange}
|
isPopulated && !isFetching && !!editions.value.length &&
|
||||||
/>
|
<FormGroup>
|
||||||
</FormGroup>
|
<FormLabel>
|
||||||
|
{translate('Edition')}
|
||||||
|
</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.BOOK_EDITION_SELECT}
|
||||||
|
name="editions"
|
||||||
|
helpText={translate('EditionsHelpText')}
|
||||||
|
isDisabled={anyEditionOk.value && hasFile}
|
||||||
|
bookEditions={editions}
|
||||||
|
onChange={onInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
}
|
||||||
|
|
||||||
</Form>
|
</Form>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
@@ -131,6 +150,9 @@ EditBookModalContent.propTypes = {
|
|||||||
authorName: PropTypes.string.isRequired,
|
authorName: PropTypes.string.isRequired,
|
||||||
statistics: PropTypes.object.isRequired,
|
statistics: PropTypes.object.isRequired,
|
||||||
item: PropTypes.object.isRequired,
|
item: PropTypes.object.isRequired,
|
||||||
|
isFetching: PropTypes.bool.isRequired,
|
||||||
|
error: PropTypes.object,
|
||||||
|
isPopulated: PropTypes.bool.isRequired,
|
||||||
isSaving: PropTypes.bool.isRequired,
|
isSaving: PropTypes.bool.isRequired,
|
||||||
onInputChange: PropTypes.func.isRequired,
|
onInputChange: PropTypes.func.isRequired,
|
||||||
onSavePress: PropTypes.func.isRequired,
|
onSavePress: PropTypes.func.isRequired,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import React, { Component } from 'react';
|
|||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import { saveBook, setBookValue } from 'Store/Actions/bookActions';
|
import { saveBook, setBookValue } from 'Store/Actions/bookActions';
|
||||||
|
import { clearEditions, fetchEditions } from 'Store/Actions/editionActions';
|
||||||
import createAuthorSelector from 'Store/Selectors/createAuthorSelector';
|
import createAuthorSelector from 'Store/Selectors/createAuthorSelector';
|
||||||
import createBookSelector from 'Store/Selectors/createBookSelector';
|
import createBookSelector from 'Store/Selectors/createBookSelector';
|
||||||
import selectSettings from 'Store/Selectors/selectSettings';
|
import selectSettings from 'Store/Selectors/selectSettings';
|
||||||
@@ -12,15 +13,25 @@ import EditBookModalContent from './EditBookModalContent';
|
|||||||
function createMapStateToProps() {
|
function createMapStateToProps() {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
(state) => state.books,
|
(state) => state.books,
|
||||||
|
(state) => state.editions,
|
||||||
createBookSelector(),
|
createBookSelector(),
|
||||||
createAuthorSelector(),
|
createAuthorSelector(),
|
||||||
(bookState, book, author) => {
|
(bookState, editionState, book, author) => {
|
||||||
const {
|
const {
|
||||||
isSaving,
|
isSaving,
|
||||||
saveError,
|
saveError,
|
||||||
pendingChanges
|
pendingChanges
|
||||||
} = bookState;
|
} = bookState;
|
||||||
|
|
||||||
|
const {
|
||||||
|
isFetching,
|
||||||
|
isPopulated,
|
||||||
|
error,
|
||||||
|
items
|
||||||
|
} = editionState;
|
||||||
|
|
||||||
|
book.editions = items;
|
||||||
|
|
||||||
const bookSettings = _.pick(book, [
|
const bookSettings = _.pick(book, [
|
||||||
'monitored',
|
'monitored',
|
||||||
'anyEditionOk',
|
'anyEditionOk',
|
||||||
@@ -34,6 +45,9 @@ function createMapStateToProps() {
|
|||||||
authorName: author.authorName,
|
authorName: author.authorName,
|
||||||
bookType: book.bookType,
|
bookType: book.bookType,
|
||||||
statistics: book.statistics,
|
statistics: book.statistics,
|
||||||
|
isFetching,
|
||||||
|
isPopulated,
|
||||||
|
error,
|
||||||
isSaving,
|
isSaving,
|
||||||
saveError,
|
saveError,
|
||||||
item: settings.settings,
|
item: settings.settings,
|
||||||
@@ -44,6 +58,8 @@ function createMapStateToProps() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
const mapDispatchToProps = {
|
||||||
|
dispatchFetchEditions: fetchEditions,
|
||||||
|
dispatchClearEditions: clearEditions,
|
||||||
dispatchSetBookValue: setBookValue,
|
dispatchSetBookValue: setBookValue,
|
||||||
dispatchSaveBook: saveBook
|
dispatchSaveBook: saveBook
|
||||||
};
|
};
|
||||||
@@ -53,12 +69,20 @@ class EditBookModalContentConnector extends Component {
|
|||||||
//
|
//
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.dispatchFetchEditions({ bookId: this.props.bookId });
|
||||||
|
}
|
||||||
|
|
||||||
componentDidUpdate(prevProps, prevState) {
|
componentDidUpdate(prevProps, prevState) {
|
||||||
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
|
if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) {
|
||||||
this.props.onModalClose();
|
this.props.onModalClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
this.props.dispatchClearEditions();
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Listeners
|
// Listeners
|
||||||
|
|
||||||
@@ -90,6 +114,8 @@ EditBookModalContentConnector.propTypes = {
|
|||||||
bookId: PropTypes.number,
|
bookId: PropTypes.number,
|
||||||
isSaving: PropTypes.bool.isRequired,
|
isSaving: PropTypes.bool.isRequired,
|
||||||
saveError: PropTypes.object,
|
saveError: PropTypes.object,
|
||||||
|
dispatchFetchEditions: PropTypes.func.isRequired,
|
||||||
|
dispatchClearEditions: PropTypes.func.isRequired,
|
||||||
dispatchSetBookValue: PropTypes.func.isRequired,
|
dispatchSetBookValue: PropTypes.func.isRequired,
|
||||||
dispatchSaveBook: PropTypes.func.isRequired,
|
dispatchSaveBook: PropTypes.func.isRequired,
|
||||||
onModalClose: 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,
|
faCaretDown as fasCaretDown,
|
||||||
faCheck as fasCheck,
|
faCheck as fasCheck,
|
||||||
faCheckCircle as fasCheckCircle,
|
faCheckCircle as fasCheckCircle,
|
||||||
|
faCheckSquare as fasCheckSquare,
|
||||||
faChevronCircleDown as fasChevronCircleDown,
|
faChevronCircleDown as fasChevronCircleDown,
|
||||||
faChevronCircleRight as fasChevronCircleRight,
|
faChevronCircleRight as fasChevronCircleRight,
|
||||||
faChevronCircleUp as fasChevronCircleUp,
|
faChevronCircleUp as fasChevronCircleUp,
|
||||||
@@ -127,6 +128,7 @@ export const CARET_DOWN = fasCaretDown;
|
|||||||
export const CHECK = fasCheck;
|
export const CHECK = fasCheck;
|
||||||
export const CHECK_INDETERMINATE = fasMinus;
|
export const CHECK_INDETERMINATE = fasMinus;
|
||||||
export const CHECK_CIRCLE = fasCheckCircle;
|
export const CHECK_CIRCLE = fasCheckCircle;
|
||||||
|
export const CHECK_SQUARE = fasCheckSquare;
|
||||||
export const CIRCLE = fasCircle;
|
export const CIRCLE = fasCircle;
|
||||||
export const CIRCLE_OUTLINE = farCircle;
|
export const CIRCLE_OUTLINE = farCircle;
|
||||||
export const CLEAR = fasTrashAlt;
|
export const CLEAR = fasTrashAlt;
|
||||||
|
|||||||
@@ -132,6 +132,14 @@ export const defaultState = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
columns: [
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'select',
|
||||||
|
columnLabel: 'Select',
|
||||||
|
isSortable: false,
|
||||||
|
isVisible: true,
|
||||||
|
isModifiable: false,
|
||||||
|
isHidden: true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'monitored',
|
name: 'monitored',
|
||||||
columnLabel: '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 authorIndex from './authorIndexActions';
|
||||||
import * as blocklist from './blocklistActions';
|
import * as blocklist from './blocklistActions';
|
||||||
import * as books from './bookActions';
|
import * as books from './bookActions';
|
||||||
|
import * as bookEditor from './bookEditorActions';
|
||||||
import * as bookFiles from './bookFileActions';
|
import * as bookFiles from './bookFileActions';
|
||||||
import * as bookHistory from './bookHistoryActions';
|
import * as bookHistory from './bookHistoryActions';
|
||||||
import * as bookIndex from './bookIndexActions';
|
import * as bookIndex from './bookIndexActions';
|
||||||
@@ -14,6 +15,7 @@ import * as calendar from './calendarActions';
|
|||||||
import * as captcha from './captchaActions';
|
import * as captcha from './captchaActions';
|
||||||
import * as commands from './commandActions';
|
import * as commands from './commandActions';
|
||||||
import * as customFilters from './customFilterActions';
|
import * as customFilters from './customFilterActions';
|
||||||
|
import * as editions from './editionActions';
|
||||||
import * as history from './historyActions';
|
import * as history from './historyActions';
|
||||||
import * as interactiveImportActions from './interactiveImportActions';
|
import * as interactiveImportActions from './interactiveImportActions';
|
||||||
import * as oAuth from './oAuthActions';
|
import * as oAuth from './oAuthActions';
|
||||||
@@ -42,11 +44,13 @@ export default [
|
|||||||
bookHistory,
|
bookHistory,
|
||||||
bookIndex,
|
bookIndex,
|
||||||
books,
|
books,
|
||||||
|
bookEditor,
|
||||||
bookStudio,
|
bookStudio,
|
||||||
calendar,
|
calendar,
|
||||||
captcha,
|
captcha,
|
||||||
commands,
|
commands,
|
||||||
customFilters,
|
customFilters,
|
||||||
|
editions,
|
||||||
history,
|
history,
|
||||||
interactiveImportActions,
|
interactiveImportActions,
|
||||||
oAuth,
|
oAuth,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using System.Globalization;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using System.Net.Http;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Moq;
|
using Moq;
|
||||||
@@ -15,6 +16,8 @@ using NzbDrone.Common.Http;
|
|||||||
using NzbDrone.Common.Http.Dispatchers;
|
using NzbDrone.Common.Http.Dispatchers;
|
||||||
using NzbDrone.Common.Http.Proxy;
|
using NzbDrone.Common.Http.Proxy;
|
||||||
using NzbDrone.Common.TPL;
|
using NzbDrone.Common.TPL;
|
||||||
|
using NzbDrone.Core.Configuration;
|
||||||
|
using NzbDrone.Core.Security;
|
||||||
using NzbDrone.Test.Common;
|
using NzbDrone.Test.Common;
|
||||||
using NzbDrone.Test.Common.Categories;
|
using NzbDrone.Test.Common.Categories;
|
||||||
using HttpClient = NzbDrone.Common.Http.HttpClient;
|
using HttpClient = NzbDrone.Common.Http.HttpClient;
|
||||||
@@ -41,7 +44,7 @@ namespace NzbDrone.Common.Test.Http
|
|||||||
var mainHost = "httpbin.servarr.com";
|
var mainHost = "httpbin.servarr.com";
|
||||||
|
|
||||||
// Use mirrors for tests that use two hosts
|
// 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.
|
// httpbin.org is broken right now, occassionally redirecting to https if it's unavailable.
|
||||||
_httpBinHost = mainHost;
|
_httpBinHost = mainHost;
|
||||||
@@ -49,7 +52,7 @@ namespace NzbDrone.Common.Test.Http
|
|||||||
|
|
||||||
TestLogger.Info($"{candidates.Length} TestSites available.");
|
TestLogger.Info($"{candidates.Length} TestSites available.");
|
||||||
|
|
||||||
_httpBinSleep = _httpBinHosts.Count() < 2 ? 100 : 10;
|
_httpBinSleep = 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool IsTestSiteAvailable(string site)
|
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.Name).Returns("TestOS");
|
||||||
Mocker.GetMock<IOsInfo>().Setup(c => c.Version).Returns("9.0.0");
|
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<IUserAgentBuilder>(Mocker.Resolve<UserAgentBuilder>());
|
||||||
|
|
||||||
Mocker.SetConstant<ICacheManager>(Mocker.Resolve<CacheManager>());
|
Mocker.SetConstant<ICacheManager>(Mocker.Resolve<CacheManager>());
|
||||||
Mocker.SetConstant<ICreateManagedWebProxy>(Mocker.Resolve<ManagedWebProxyFactory>());
|
Mocker.SetConstant<ICreateManagedWebProxy>(Mocker.Resolve<ManagedWebProxyFactory>());
|
||||||
|
Mocker.SetConstant<ICertificateValidationService>(new X509CertificateValidationService(Mocker.GetMock<IConfigService>().Object, TestLogger));
|
||||||
Mocker.SetConstant<IRateLimitService>(Mocker.Resolve<RateLimitService>());
|
Mocker.SetConstant<IRateLimitService>(Mocker.Resolve<RateLimitService>());
|
||||||
Mocker.SetConstant<IEnumerable<IHttpRequestInterceptor>>(new IHttpRequestInterceptor[0]);
|
Mocker.SetConstant<IEnumerable<IHttpRequestInterceptor>>(new IHttpRequestInterceptor[0]);
|
||||||
Mocker.SetConstant<IHttpDispatcher>(Mocker.Resolve<TDispatcher>());
|
Mocker.SetConstant<IHttpDispatcher>(Mocker.Resolve<TDispatcher>());
|
||||||
@@ -127,6 +133,28 @@ namespace NzbDrone.Common.Test.Http
|
|||||||
response.Content.Should().NotBeNullOrWhiteSpace();
|
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]
|
[Test]
|
||||||
public void should_execute_typed_get()
|
public void should_execute_typed_get()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ using DryIoc;
|
|||||||
using DryIoc.Microsoft.DependencyInjection;
|
using DryIoc.Microsoft.DependencyInjection;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Moq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using NzbDrone.Common.Composition.Extensions;
|
using NzbDrone.Common.Composition.Extensions;
|
||||||
using NzbDrone.Common.EnvironmentInfo;
|
using NzbDrone.Common.EnvironmentInfo;
|
||||||
@@ -25,12 +27,14 @@ namespace NzbDrone.Common.Test
|
|||||||
.AddNzbDroneLogger()
|
.AddNzbDroneLogger()
|
||||||
.AutoAddServices(Bootstrap.ASSEMBLIES)
|
.AutoAddServices(Bootstrap.ASSEMBLIES)
|
||||||
.AddDummyDatabase()
|
.AddDummyDatabase()
|
||||||
.AddStartupContext(new StartupContext("first", "second"))
|
.AddStartupContext(new StartupContext("first", "second"));
|
||||||
.GetServiceProvider();
|
|
||||||
|
|
||||||
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>>()
|
var handlers = Subject.BuildAll<IHandle<ApplicationStartedEvent>>()
|
||||||
.Select(c => c.GetType().FullName);
|
.Select(c => c.GetType().FullName);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Reflection;
|
|
||||||
using System.Security.Principal;
|
using System.Security.Principal;
|
||||||
using System.ServiceProcess;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Hosting.WindowsServices;
|
||||||
using NLog;
|
using NLog;
|
||||||
using NzbDrone.Common.Processes;
|
using NzbDrone.Common.Processes;
|
||||||
|
|
||||||
@@ -14,14 +14,11 @@ namespace NzbDrone.Common.EnvironmentInfo
|
|||||||
private readonly Logger _logger;
|
private readonly Logger _logger;
|
||||||
private readonly DateTime _startTime = DateTime.UtcNow;
|
private readonly DateTime _startTime = DateTime.UtcNow;
|
||||||
|
|
||||||
public RuntimeInfo(IServiceProvider serviceProvider, Logger logger)
|
public RuntimeInfo(IHostLifetime hostLifetime, Logger logger)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
IsWindowsService = !IsUserInteractive &&
|
IsWindowsService = hostLifetime is WindowsServiceLifetime;
|
||||||
OsInfo.IsWindows &&
|
|
||||||
serviceProvider.ServiceExist(ServiceProvider.SERVICE_NAME) &&
|
|
||||||
serviceProvider.GetStatus(ServiceProvider.SERVICE_NAME) == ServiceControllerStatus.StartPending;
|
|
||||||
|
|
||||||
//Guarded to avoid issues when running in a non-managed process
|
//Guarded to avoid issues when running in a non-managed process
|
||||||
var entry = Process.GetCurrentProcess().MainModule;
|
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;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Security;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -24,6 +25,7 @@ namespace NzbDrone.Common.Http.Dispatchers
|
|||||||
|
|
||||||
private readonly IHttpProxySettingsProvider _proxySettingsProvider;
|
private readonly IHttpProxySettingsProvider _proxySettingsProvider;
|
||||||
private readonly ICreateManagedWebProxy _createManagedWebProxy;
|
private readonly ICreateManagedWebProxy _createManagedWebProxy;
|
||||||
|
private readonly ICertificateValidationService _certificateValidationService;
|
||||||
private readonly IUserAgentBuilder _userAgentBuilder;
|
private readonly IUserAgentBuilder _userAgentBuilder;
|
||||||
private readonly ICached<System.Net.Http.HttpClient> _httpClientCache;
|
private readonly ICached<System.Net.Http.HttpClient> _httpClientCache;
|
||||||
private readonly ICached<CredentialCache> _credentialCache;
|
private readonly ICached<CredentialCache> _credentialCache;
|
||||||
@@ -31,12 +33,14 @@ namespace NzbDrone.Common.Http.Dispatchers
|
|||||||
|
|
||||||
public ManagedHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider,
|
public ManagedHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider,
|
||||||
ICreateManagedWebProxy createManagedWebProxy,
|
ICreateManagedWebProxy createManagedWebProxy,
|
||||||
|
ICertificateValidationService certificateValidationService,
|
||||||
IUserAgentBuilder userAgentBuilder,
|
IUserAgentBuilder userAgentBuilder,
|
||||||
ICacheManager cacheManager,
|
ICacheManager cacheManager,
|
||||||
Logger logger)
|
Logger logger)
|
||||||
{
|
{
|
||||||
_proxySettingsProvider = proxySettingsProvider;
|
_proxySettingsProvider = proxySettingsProvider;
|
||||||
_createManagedWebProxy = createManagedWebProxy;
|
_createManagedWebProxy = createManagedWebProxy;
|
||||||
|
_certificateValidationService = certificateValidationService;
|
||||||
_userAgentBuilder = userAgentBuilder;
|
_userAgentBuilder = userAgentBuilder;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
@@ -158,7 +162,12 @@ namespace NzbDrone.Common.Http.Dispatchers
|
|||||||
AllowAutoRedirect = false,
|
AllowAutoRedirect = false,
|
||||||
Credentials = GetCredentialCache(),
|
Credentials = GetCredentialCache(),
|
||||||
PreAuthenticate = true,
|
PreAuthenticate = true,
|
||||||
|
MaxConnectionsPerServer = 12,
|
||||||
ConnectCallback = onConnect,
|
ConnectCallback = onConnect,
|
||||||
|
SslOptions = new SslClientAuthenticationOptions
|
||||||
|
{
|
||||||
|
RemoteCertificateValidationCallback = _certificateValidationService.ShouldByPassValidationError
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (proxySettings != null)
|
if (proxySettings != null)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="DryIoc.dll" />
|
<PackageReference Include="DryIoc.dll" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
|
||||||
<PackageReference Include="NLog.Extensions.Logging" />
|
<PackageReference Include="NLog.Extensions.Logging" />
|
||||||
<PackageReference Include="Newtonsoft.Json" />
|
<PackageReference Include="Newtonsoft.Json" />
|
||||||
<PackageReference Include="NLog" />
|
<PackageReference Include="NLog" />
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
<TargetFrameworks>net6.0</TargetFrameworks>
|
<TargetFrameworks>net6.0</TargetFrameworks>
|
||||||
|
|
||||||
<ApplicationIcon>..\NzbDrone.Host\Readarr.ico</ApplicationIcon>
|
<ApplicationIcon>..\NzbDrone.Host\Readarr.ico</ApplicationIcon>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<PropertyGroup Condition="!$(RuntimeIdentifier.StartsWith('win'))">
|
<PropertyGroup Condition="!$(RuntimeIdentifier.StartsWith('win'))">
|
||||||
<AssemblyName>Readarr</AssemblyName>
|
<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.Configuration;
|
||||||
using NzbDrone.Core.Http;
|
using NzbDrone.Core.Http;
|
||||||
using NzbDrone.Core.MetadataSource;
|
using NzbDrone.Core.MetadataSource;
|
||||||
|
using NzbDrone.Core.Security;
|
||||||
using NzbDrone.Test.Common;
|
using NzbDrone.Test.Common;
|
||||||
|
|
||||||
namespace NzbDrone.Core.Test.Framework
|
namespace NzbDrone.Core.Test.Framework
|
||||||
@@ -25,7 +26,8 @@ namespace NzbDrone.Core.Test.Framework
|
|||||||
|
|
||||||
Mocker.SetConstant<IHttpProxySettingsProvider>(new HttpProxySettingsProvider(Mocker.Resolve<ConfigService>()));
|
Mocker.SetConstant<IHttpProxySettingsProvider>(new HttpProxySettingsProvider(Mocker.Resolve<ConfigService>()));
|
||||||
Mocker.SetConstant<ICreateManagedWebProxy>(new ManagedWebProxyFactory(Mocker.Resolve<CacheManager>()));
|
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<IHttpClient>(new HttpClient(new IHttpRequestInterceptor[0], Mocker.Resolve<CacheManager>(), Mocker.Resolve<RateLimitService>(), Mocker.Resolve<IHttpDispatcher>(), TestLogger));
|
||||||
Mocker.SetConstant<IReadarrCloudRequestBuilder>(new ReadarrCloudRequestBuilder());
|
Mocker.SetConstant<IReadarrCloudRequestBuilder>(new ReadarrCloudRequestBuilder());
|
||||||
Mocker.SetConstant<IMetadataRequestBuilder>(Mocker.Resolve<MetadataRequestBuilder>());
|
Mocker.SetConstant<IMetadataRequestBuilder>(Mocker.Resolve<MetadataRequestBuilder>());
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ namespace NzbDrone.Core.Books
|
|||||||
{
|
{
|
||||||
public interface IEditionRepository : IBasicRepository<Edition>
|
public interface IEditionRepository : IBasicRepository<Edition>
|
||||||
{
|
{
|
||||||
|
List<Edition> GetAllMonitoredEditions();
|
||||||
Edition FindByForeignEditionId(string foreignEditionId);
|
Edition FindByForeignEditionId(string foreignEditionId);
|
||||||
List<Edition> FindByBook(int id);
|
List<Edition> FindByBook(int id);
|
||||||
List<Edition> FindByAuthor(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)
|
public Edition FindByForeignEditionId(string foreignEditionId)
|
||||||
{
|
{
|
||||||
var edition = Query(x => x.ForeignEditionId == foreignEditionId).SingleOrDefault();
|
var edition = Query(x => x.ForeignEditionId == foreignEditionId).SingleOrDefault();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace NzbDrone.Core.Books
|
|||||||
{
|
{
|
||||||
Edition GetEdition(int id);
|
Edition GetEdition(int id);
|
||||||
Edition GetEditionByForeignEditionId(string foreignEditionId);
|
Edition GetEditionByForeignEditionId(string foreignEditionId);
|
||||||
List<Edition> GetAllEditions();
|
List<Edition> GetAllMonitoredEditions();
|
||||||
void InsertMany(List<Edition> editions);
|
void InsertMany(List<Edition> editions);
|
||||||
void UpdateMany(List<Edition> editions);
|
void UpdateMany(List<Edition> editions);
|
||||||
void DeleteMany(List<Edition> editions);
|
void DeleteMany(List<Edition> editions);
|
||||||
@@ -48,9 +48,9 @@ namespace NzbDrone.Core.Books
|
|||||||
return _editionRepository.FindByForeignEditionId(foreignEditionId);
|
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)
|
public void InsertMany(List<Edition> editions)
|
||||||
|
|||||||
@@ -66,6 +66,7 @@
|
|||||||
"Book": "Book",
|
"Book": "Book",
|
||||||
"BookAvailableButMissing": "Book Available, but Missing",
|
"BookAvailableButMissing": "Book Available, but Missing",
|
||||||
"BookDownloaded": "Book Downloaded",
|
"BookDownloaded": "Book Downloaded",
|
||||||
|
"BookEditor": "Book Editor",
|
||||||
"BookFileCountBookCountTotalTotalBookCountInterp": "{0} / {1} (Total: {2})",
|
"BookFileCountBookCountTotalTotalBookCountInterp": "{0} / {1} (Total: {2})",
|
||||||
"BookFileCounttotalBookCountBooksDownloadedInterp": "{0}/{1} books downloaded",
|
"BookFileCounttotalBookCountBooksDownloadedInterp": "{0}/{1} books downloaded",
|
||||||
"BookFilesCountMessage": "No book files",
|
"BookFilesCountMessage": "No book files",
|
||||||
@@ -73,6 +74,7 @@
|
|||||||
"BookIsDownloading": "Book is downloading",
|
"BookIsDownloading": "Book is downloading",
|
||||||
"BookIsDownloadingInterp": "Book is downloading - {0}% {1}",
|
"BookIsDownloadingInterp": "Book is downloading - {0}% {1}",
|
||||||
"BookIsNotMonitored": "Book is not monitored",
|
"BookIsNotMonitored": "Book is not monitored",
|
||||||
|
"BookList": "Book List",
|
||||||
"BookMissingFromDisk": "Book missing from disk",
|
"BookMissingFromDisk": "Book missing from disk",
|
||||||
"BookMonitoring": "Book Monitoring",
|
"BookMonitoring": "Book Monitoring",
|
||||||
"BookNaming": "Book Naming",
|
"BookNaming": "Book Naming",
|
||||||
@@ -558,7 +560,9 @@
|
|||||||
"SearchSelected": "Search Selected",
|
"SearchSelected": "Search Selected",
|
||||||
"Season": "Season",
|
"Season": "Season",
|
||||||
"Security": "Security",
|
"Security": "Security",
|
||||||
|
"SelectAll": "Select All",
|
||||||
"SelectedCountAuthorsSelectedInterp": "{0} Author(s) Selected",
|
"SelectedCountAuthorsSelectedInterp": "{0} Author(s) Selected",
|
||||||
|
"SelectedCountBooksSelectedInterp": "{0} Book(s) Selected",
|
||||||
"SendAnonymousUsageData": "Send Anonymous Usage Data",
|
"SendAnonymousUsageData": "Send Anonymous Usage Data",
|
||||||
"SendMetadataToCalibre": "Send Metadata to Calibre",
|
"SendMetadataToCalibre": "Send Metadata to Calibre",
|
||||||
"Series": "Series",
|
"Series": "Series",
|
||||||
@@ -644,6 +648,7 @@
|
|||||||
"TestAllLists": "Test All Lists",
|
"TestAllLists": "Test All Lists",
|
||||||
"TheAuthorFolderAndAllOfItsContentWillBeDeleted": "The author folder {0} and all of its content will be deleted.",
|
"TheAuthorFolderAndAllOfItsContentWillBeDeleted": "The author folder {0} and all of its content will be deleted.",
|
||||||
"TheBooksFilesWillBeDeleted": "The book's files 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",
|
"ThisWillApplyToAllIndexersPleaseFollowTheRulesSetForthByThem": "This will apply to all indexers, please follow the rules set forth by them",
|
||||||
"Time": "Time",
|
"Time": "Time",
|
||||||
"TimeFormat": "Time Format",
|
"TimeFormat": "Time Format",
|
||||||
@@ -706,6 +711,7 @@
|
|||||||
"UnmappedFiles": "UnmappedFiles",
|
"UnmappedFiles": "UnmappedFiles",
|
||||||
"Unmonitored": "Unmonitored",
|
"Unmonitored": "Unmonitored",
|
||||||
"UnmonitoredHelpText": "Include unmonitored books in the iCal feed",
|
"UnmonitoredHelpText": "Include unmonitored books in the iCal feed",
|
||||||
|
"UnselectAll": "Unselect All",
|
||||||
"UpdateAll": "Update all",
|
"UpdateAll": "Update all",
|
||||||
"UpdateAutomaticallyHelpText": "Automatically download and install updates. You will still be able to install from System: Updates",
|
"UpdateAutomaticallyHelpText": "Automatically download and install updates. You will still be able to install from System: Updates",
|
||||||
"UpdateCovers": "Update Covers",
|
"UpdateCovers": "Update Covers",
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ using System.Net.Security;
|
|||||||
using System.Security.Cryptography.X509Certificates;
|
using System.Security.Cryptography.X509Certificates;
|
||||||
using NLog;
|
using NLog;
|
||||||
using NzbDrone.Common.Extensions;
|
using NzbDrone.Common.Extensions;
|
||||||
|
using NzbDrone.Common.Http.Dispatchers;
|
||||||
using NzbDrone.Core.Configuration;
|
using NzbDrone.Core.Configuration;
|
||||||
using NzbDrone.Core.Lifecycle;
|
|
||||||
using NzbDrone.Core.Messaging.Events;
|
|
||||||
|
|
||||||
namespace NzbDrone.Core.Security
|
namespace NzbDrone.Core.Security
|
||||||
{
|
{
|
||||||
public class X509CertificateValidationService : IHandle<ApplicationStartedEvent>
|
public class X509CertificateValidationService : ICertificateValidationService
|
||||||
{
|
{
|
||||||
private readonly IConfigService _configService;
|
private readonly IConfigService _configService;
|
||||||
private readonly Logger _logger;
|
private readonly Logger _logger;
|
||||||
@@ -21,19 +20,16 @@ namespace NzbDrone.Core.Security
|
|||||||
_logger = logger;
|
_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 (sender is not SslStream request)
|
||||||
|
|
||||||
if (request == null)
|
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var cert2 = certificate as X509Certificate2;
|
if (certificate is X509Certificate2 cert2 && cert2.SignatureAlgorithm.FriendlyName == "md5RSA")
|
||||||
if (cert2 != null && request != null && 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)
|
if (sslPolicyErrors == SslPolicyErrors.None)
|
||||||
@@ -41,12 +37,12 @@ namespace NzbDrone.Core.Security
|
|||||||
return true;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
var ipAddresses = GetIPAddresses(request.RequestUri.Host);
|
var ipAddresses = GetIPAddresses(request.TargetHostName);
|
||||||
var certificateValidation = _configService.CertificateValidation;
|
var certificateValidation = _configService.CertificateValidation;
|
||||||
|
|
||||||
if (certificateValidation == CertificateValidationType.Disabled)
|
if (certificateValidation == CertificateValidationType.Disabled)
|
||||||
@@ -60,7 +56,7 @@ namespace NzbDrone.Core.Security
|
|||||||
return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -74,10 +70,5 @@ namespace NzbDrone.Core.Security
|
|||||||
|
|
||||||
return Dns.GetHostEntry(host).AddressList;
|
return Dns.GetHostEntry(host).AddressList;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Handle(ApplicationStartedEvent message)
|
|
||||||
{
|
|
||||||
ServicePointManager.ServerCertificateValidationCallback = ShouldByPassValidationError;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using DryIoc;
|
|||||||
using DryIoc.Microsoft.DependencyInjection;
|
using DryIoc.Microsoft.DependencyInjection;
|
||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
using Moq;
|
using Moq;
|
||||||
using NUnit.Framework;
|
using NUnit.Framework;
|
||||||
using NzbDrone.Common;
|
using NzbDrone.Common;
|
||||||
@@ -33,16 +34,15 @@ namespace NzbDrone.App.Test
|
|||||||
{
|
{
|
||||||
var args = new StartupContext("first", "second");
|
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())
|
var container = new Container(rules => rules.WithNzbDroneRules())
|
||||||
.AutoAddServices(Bootstrap.ASSEMBLIES)
|
.AutoAddServices(Bootstrap.ASSEMBLIES)
|
||||||
.AddNzbDroneLogger()
|
.AddNzbDroneLogger()
|
||||||
.AddDummyDatabase()
|
.AddDummyDatabase()
|
||||||
.AddStartupContext(args);
|
.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();
|
_container = container.GetServiceProvider();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ namespace NzbDrone.Host
|
|||||||
private readonly IBrowserService _browserService;
|
private readonly IBrowserService _browserService;
|
||||||
private readonly IProcessProvider _processProvider;
|
private readonly IProcessProvider _processProvider;
|
||||||
private readonly IEventAggregator _eventAggregator;
|
private readonly IEventAggregator _eventAggregator;
|
||||||
private readonly IUtilityModeRouter _utilityModeRouter;
|
|
||||||
private readonly Logger _logger;
|
private readonly Logger _logger;
|
||||||
|
|
||||||
public AppLifetime(IHostApplicationLifetime appLifetime,
|
public AppLifetime(IHostApplicationLifetime appLifetime,
|
||||||
@@ -29,7 +28,6 @@ namespace NzbDrone.Host
|
|||||||
IBrowserService browserService,
|
IBrowserService browserService,
|
||||||
IProcessProvider processProvider,
|
IProcessProvider processProvider,
|
||||||
IEventAggregator eventAggregator,
|
IEventAggregator eventAggregator,
|
||||||
IUtilityModeRouter utilityModeRouter,
|
|
||||||
Logger logger)
|
Logger logger)
|
||||||
{
|
{
|
||||||
_appLifetime = appLifetime;
|
_appLifetime = appLifetime;
|
||||||
@@ -39,7 +37,6 @@ namespace NzbDrone.Host
|
|||||||
_browserService = browserService;
|
_browserService = browserService;
|
||||||
_processProvider = processProvider;
|
_processProvider = processProvider;
|
||||||
_eventAggregator = eventAggregator;
|
_eventAggregator = eventAggregator;
|
||||||
_utilityModeRouter = utilityModeRouter;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
appLifetime.ApplicationStarted.Register(OnAppStarted);
|
appLifetime.ApplicationStarted.Register(OnAppStarted);
|
||||||
@@ -71,7 +68,7 @@ namespace NzbDrone.Host
|
|||||||
|
|
||||||
private void OnAppStopped()
|
private void OnAppStopped()
|
||||||
{
|
{
|
||||||
if (_runtimeInfo.RestartPending)
|
if (_runtimeInfo.RestartPending && !_runtimeInfo.IsWindowsService)
|
||||||
{
|
{
|
||||||
var restartArgs = GetRestartArgs();
|
var restartArgs = GetRestartArgs();
|
||||||
|
|
||||||
|
|||||||
@@ -180,7 +180,20 @@ namespace NzbDrone.Host
|
|||||||
return ApplicationModes.UninstallService;
|
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;
|
return ApplicationModes.Service;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
<RuntimeIdentifiers>win-x64;win-x86</RuntimeIdentifiers>
|
<RuntimeIdentifiers>win-x64;win-x86</RuntimeIdentifiers>
|
||||||
<UseWindowsForms>true</UseWindowsForms>
|
<UseWindowsForms>true</UseWindowsForms>
|
||||||
<ApplicationIcon>..\NzbDrone.Host\Readarr.ico</ApplicationIcon>
|
<ApplicationIcon>..\NzbDrone.Host\Readarr.ico</ApplicationIcon>
|
||||||
<ApplicationManifest>app.manifest</ApplicationManifest>
|
|
||||||
<GenerateResourceUsePreserializedResources>true</GenerateResourceUsePreserializedResources>
|
<GenerateResourceUsePreserializedResources>true</GenerateResourceUsePreserializedResources>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using System.Windows.Forms;
|
using System.Windows.Forms;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using NLog;
|
|
||||||
using NzbDrone.Common.EnvironmentInfo;
|
using NzbDrone.Common.EnvironmentInfo;
|
||||||
using NzbDrone.Common.Processes;
|
using NzbDrone.Core.Lifecycle;
|
||||||
using NzbDrone.Host;
|
using NzbDrone.Host;
|
||||||
|
|
||||||
namespace NzbDrone.SysTray
|
namespace NzbDrone.SysTray
|
||||||
@@ -14,28 +13,19 @@ namespace NzbDrone.SysTray
|
|||||||
public class SystemTrayApp : Form, IHostedService
|
public class SystemTrayApp : Form, IHostedService
|
||||||
{
|
{
|
||||||
private readonly IBrowserService _browserService;
|
private readonly IBrowserService _browserService;
|
||||||
private readonly IRuntimeInfo _runtimeInfo;
|
private readonly ILifecycleService _lifecycle;
|
||||||
private readonly IProcessProvider _processProvider;
|
|
||||||
|
|
||||||
private readonly NotifyIcon _trayIcon = new NotifyIcon();
|
private readonly NotifyIcon _trayIcon = new NotifyIcon();
|
||||||
private readonly ContextMenuStrip _trayMenu = new ContextMenuStrip();
|
private readonly ContextMenuStrip _trayMenu = new ContextMenuStrip();
|
||||||
|
|
||||||
public SystemTrayApp(IBrowserService browserService, IRuntimeInfo runtimeInfo, IProcessProvider processProvider)
|
public SystemTrayApp(IBrowserService browserService, ILifecycleService lifecycle)
|
||||||
{
|
{
|
||||||
_browserService = browserService;
|
_browserService = browserService;
|
||||||
_runtimeInfo = runtimeInfo;
|
_lifecycle = lifecycle;
|
||||||
_processProvider = processProvider;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Start()
|
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 ToolStripMenuItem("Launch Browser", null, LaunchBrowser));
|
||||||
_trayMenu.Items.Add(new ToolStripSeparator());
|
_trayMenu.Items.Add(new ToolStripSeparator());
|
||||||
_trayMenu.Items.Add(new ToolStripMenuItem("Exit", null, OnExit));
|
_trayMenu.Items.Add(new ToolStripMenuItem("Exit", null, OnExit));
|
||||||
@@ -69,12 +59,6 @@ namespace NzbDrone.SysTray
|
|||||||
DisposeTrayIcon();
|
DisposeTrayIcon();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnClosed(EventArgs e)
|
|
||||||
{
|
|
||||||
Console.WriteLine("Closing");
|
|
||||||
base.OnClosed(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnLoad(EventArgs e)
|
protected override void OnLoad(EventArgs e)
|
||||||
{
|
{
|
||||||
Visible = false;
|
Visible = false;
|
||||||
@@ -102,8 +86,7 @@ namespace NzbDrone.SysTray
|
|||||||
|
|
||||||
private void OnExit(object sender, EventArgs e)
|
private void OnExit(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
LogManager.Configuration = null;
|
_lifecycle.Shutdown();
|
||||||
Environment.Exit(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LaunchBrowser(object sender, EventArgs e)
|
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()
|
private void DisposeTrayIcon()
|
||||||
{
|
{
|
||||||
try
|
if (_trayIcon == null)
|
||||||
{
|
|
||||||
_trayIcon.Visible = false;
|
|
||||||
_trayIcon.Icon = null;
|
|
||||||
_trayIcon.Visible = false;
|
|
||||||
_trayIcon.Dispose();
|
|
||||||
}
|
|
||||||
catch (Exception)
|
|
||||||
{
|
{
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_trayIcon.Visible = false;
|
||||||
|
_trayIcon.Icon = null;
|
||||||
|
_trayIcon.Visible = false;
|
||||||
|
_trayIcon.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,21 +16,22 @@ namespace NzbDrone
|
|||||||
|
|
||||||
public static void Main(string[] args)
|
public static void Main(string[] args)
|
||||||
{
|
{
|
||||||
|
Application.EnableVisualStyles();
|
||||||
|
Application.SetCompatibleTextRenderingDefault(false);
|
||||||
|
Application.SetHighDpiMode(HighDpiMode.SystemAware);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var startupArgs = new StartupContext(args);
|
var startupArgs = new StartupContext(args);
|
||||||
|
|
||||||
NzbDroneLogger.Register(startupArgs, false, true);
|
NzbDroneLogger.Register(startupArgs, false, true);
|
||||||
|
|
||||||
Bootstrap.Start(args, e =>
|
Bootstrap.Start(args, e => { e.ConfigureServices((_, s) => s.AddSingleton<IHostedService, SystemTrayApp>()); });
|
||||||
{
|
|
||||||
e.ConfigureServices((_, s) => s.AddSingleton<IHostedService, SystemTrayApp>());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
Logger.Fatal(e, "EPIC FAIL");
|
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 books = _bookService.GetAllBooks();
|
||||||
|
|
||||||
var authors = _authorService.GetAllAuthors().ToDictionary(x => x.AuthorMetadataId);
|
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)
|
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