New: Loads of Backend Updates to Clients and Indexers

pull/2/head
Qstick 5 years ago
parent c48838e5b6
commit 8a9e2dc90d

@ -19,7 +19,8 @@ const cssVarsFiles = [
'../src/Styles/Variables/colors', '../src/Styles/Variables/colors',
'../src/Styles/Variables/dimensions', '../src/Styles/Variables/dimensions',
'../src/Styles/Variables/fonts', '../src/Styles/Variables/fonts',
'../src/Styles/Variables/animations' '../src/Styles/Variables/animations',
'../src/Styles/Variables/zIndexes'
].map(require.resolve); ].map(require.resolve);
const plugins = [ const plugins = [

@ -42,13 +42,13 @@ class Queue extends Component {
shouldComponentUpdate(nextProps) { shouldComponentUpdate(nextProps) {
// Don't update when fetching has completed if items have changed, // Don't update when fetching has completed if items have changed,
// before episodes start fetching or when episodes start fetching. // before movies start fetching or when movies start fetching.
if ( if (
this.props.isFetching && this.props.isFetching &&
nextProps.isPopulated && nextProps.isPopulated &&
hasDifferentItems(this.props.items, nextProps.items) && hasDifferentItems(this.props.items, nextProps.items) &&
nextProps.items.some((e) => e.episodeId) nextProps.items.some((e) => e.movieId)
) { ) {
return false; return false;
} }
@ -139,7 +139,6 @@ class Queue extends Component {
} = this.state; } = this.state;
const isRefreshing = isFetching || isCheckForFinishedDownloadExecuting; const isRefreshing = isFetching || isCheckForFinishedDownloadExecuting;
const isAllPopulated = isPopulated && !items.length;
const hasError = error; const hasError = error;
const selectedCount = this.getSelectedIds().length; const selectedCount = this.getSelectedIds().length;
const disableSelectedActions = selectedCount === 0; const disableSelectedActions = selectedCount === 0;
@ -192,7 +191,7 @@ class Queue extends Component {
<PageContentBodyConnector> <PageContentBodyConnector>
{ {
isRefreshing && !isAllPopulated && isRefreshing && !isPopulated &&
<LoadingIndicator /> <LoadingIndicator />
} }
@ -211,7 +210,7 @@ class Queue extends Component {
} }
{ {
isAllPopulated && !hasError && !!items.length && isPopulated && !hasError && !!items.length &&
<div> <div>
<Table <Table
columns={columns} columns={columns}
@ -228,7 +227,7 @@ class Queue extends Component {
return ( return (
<QueueRowConnector <QueueRowConnector
key={item.id} key={item.id}
episodeId={item.episodeId} movieId={item.movieId}
isSelected={selectedState[item.id]} isSelected={selectedState[item.id]}
columns={columns} columns={columns}
{...item} {...item}

@ -76,7 +76,7 @@ function QueueDetails(props) {
return ( return (
<Icon <Icon
name={icons.DOWNLOADING} name={icons.DOWNLOADING}
title={`Episode is downloading - ${progress.toFixed(1)}% ${title}`} title={`Movie is downloading - ${progress.toFixed(1)}% ${title}`}
/> />
); );
} }

@ -14,18 +14,18 @@ class QueueOptions extends Component {
super(props, context); super(props, context);
this.state = { this.state = {
includeUnknownSeriesItems: props.includeUnknownSeriesItems includeUnknownMovieItems: props.includeUnknownMovieItems
}; };
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
const { const {
includeUnknownSeriesItems includeUnknownMovieItems
} = this.props; } = this.props;
if (includeUnknownSeriesItems !== prevProps.includeUnknownSeriesItems) { if (includeUnknownMovieItems !== prevProps.includeUnknownMovieItems) {
this.setState({ this.setState({
includeUnknownSeriesItems includeUnknownMovieItems
}); });
} }
} }
@ -48,19 +48,19 @@ class QueueOptions extends Component {
render() { render() {
const { const {
includeUnknownSeriesItems includeUnknownMovieItems
} = this.state; } = this.state;
return ( return (
<Fragment> <Fragment>
<FormGroup> <FormGroup>
<FormLabel>Show Unknown Series Items</FormLabel> <FormLabel>Show Unknown Movie Items</FormLabel>
<FormInputGroup <FormInputGroup
type={inputTypes.CHECK} type={inputTypes.CHECK}
name="includeUnknownSeriesItems" name="includeUnknownMovieItems"
value={includeUnknownSeriesItems} value={includeUnknownMovieItems}
helpText="Show items without a series in the queue, this could include removed series, movies or anything else in Sonarr's category" helpText="Show items without a movie in the queue, this could include removed movie, movies or anything else in Radarr's category"
onChange={this.onOptionChange} onChange={this.onOptionChange}
/> />
</FormGroup> </FormGroup>
@ -70,7 +70,7 @@ class QueueOptions extends Component {
} }
QueueOptions.propTypes = { QueueOptions.propTypes = {
includeUnknownSeriesItems: PropTypes.bool.isRequired, includeUnknownMovieItems: PropTypes.bool.isRequired,
onOptionChange: PropTypes.func.isRequired onOptionChange: PropTypes.func.isRequired
}; };

@ -5,7 +5,7 @@ import IconButton from 'Components/Link/IconButton';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import ProgressBar from 'Components/ProgressBar'; import ProgressBar from 'Components/ProgressBar';
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
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 TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import ProtocolLabel from 'Activity/Queue/ProtocolLabel';
@ -67,8 +67,7 @@ class QueueRow extends Component {
trackedDownloadStatus, trackedDownloadStatus,
statusMessages, statusMessages,
errorMessage, errorMessage,
series, movie,
episode,
quality, quality,
protocol, protocol,
indexer, indexer,
@ -130,37 +129,28 @@ class QueueRow extends Component {
); );
} }
if (name === 'series.sortTitle') { if (name === 'movie.sortTitle') {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
<MovieTitleLink <MovieTitleLink
titleSlug={series.titleSlug} titleSlug={movie.titleSlug}
title={series.title} title={movie.title}
/> />
</TableRowCell> </TableRowCell>
); );
} }
if (name === 'series') { if (name === 'movie') {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
<MovieTitleLink <MovieTitleLink
titleSlug={series.titleSlug} titleSlug={movie.titleSlug}
title={series.title} title={movie.title}
/> />
</TableRowCell> </TableRowCell>
); );
} }
if (name === 'episode.airDateUtc') {
return (
<RelativeDateCellConnector
key={name}
date={episode.airDateUtc}
/>
);
}
if (name === 'quality') { if (name === 'quality') {
return ( return (
<TableRowCell key={name}> <TableRowCell key={name}>
@ -303,8 +293,7 @@ QueueRow.propTypes = {
trackedDownloadStatus: PropTypes.string, trackedDownloadStatus: PropTypes.string,
statusMessages: PropTypes.arrayOf(PropTypes.object), statusMessages: PropTypes.arrayOf(PropTypes.object),
errorMessage: PropTypes.string, errorMessage: PropTypes.string,
series: PropTypes.object.isRequired, movie: PropTypes.object.isRequired,
episode: PropTypes.object.isRequired,
quality: PropTypes.object.isRequired, quality: PropTypes.object.isRequired,
protocol: PropTypes.string.isRequired, protocol: PropTypes.string.isRequired,
indexer: PropTypes.string, indexer: PropTypes.string,

@ -12,14 +12,14 @@ function createMapStateToProps() {
return createSelector( return createSelector(
createMovieSelector(), createMovieSelector(),
createUISettingsSelector(), createUISettingsSelector(),
(series, uiSettings) => { (movie, uiSettings) => {
const result = _.pick(uiSettings, [ const result = _.pick(uiSettings, [
'showRelativeDates', 'showRelativeDates',
'shortDateFormat', 'shortDateFormat',
'timeFormat' 'timeFormat'
]); ]);
result.series = series; result.movie = movie;
return result; return result;
} }
@ -60,7 +60,7 @@ class QueueRowConnector extends Component {
QueueRowConnector.propTypes = { QueueRowConnector.propTypes = {
id: PropTypes.number.isRequired, id: PropTypes.number.isRequired,
episode: PropTypes.object, movie: PropTypes.object,
grabQueueItem: PropTypes.func.isRequired, grabQueueItem: PropTypes.func.isRequired,
removeQueueItem: PropTypes.func.isRequired removeQueueItem: PropTypes.func.isRequired
}; };

@ -116,6 +116,7 @@ function QueueStatusCell(props) {
title={title} title={title}
body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle} body={hasWarning || hasError ? getDetailedPopoverBody(statusMessages) : sourceTitle}
position={tooltipPositions.RIGHT} position={tooltipPositions.RIGHT}
canFlip={false}
/> />
</TableRowCell> </TableRowCell>
); );

@ -1,11 +1,6 @@
.tether {
z-index: 2000;
}
.button { .button {
composes: link from '~Components/Link/Link.css'; composes: link from '~Components/Link/Link.css';
position: relative;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 6px 16px; padding: 6px 16px;
@ -35,9 +30,10 @@
} }
.contentContainer { .contentContainer {
z-index: $popperZIndex;
margin-top: 4px; margin-top: 4px;
padding: 0 8px; /* 400px container witdh with 8px padding on each side */
width: 400px; width: 384px;
} }
.content { .content {

@ -1,9 +1,10 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import ReactDOM from 'react-dom'; import { Manager, Popper, Reference } from 'react-popper';
import TetherComponent from 'react-tether'; import getUniqueElememtId from 'Utilities/getUniqueElementId';
import { icons, kinds } from 'Helpers/Props'; import { icons, kinds } from 'Helpers/Props';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Portal from 'Components/Portal';
import FormInputButton from 'Components/Form/FormInputButton'; import FormInputButton from 'Components/Form/FormInputButton';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import LoadingIndicator from 'Components/Loading/LoadingIndicator';
@ -12,19 +13,6 @@ import ImportMovieSearchResultConnector from './ImportMovieSearchResultConnector
import ImportMovieTitle from './ImportMovieTitle'; import ImportMovieTitle from './ImportMovieTitle';
import styles from './ImportMovieSelectMovie.css'; import styles from './ImportMovieSelectMovie.css';
const tetherOptions = {
skipMoveElement: true,
constraints: [
{
to: 'window',
attachment: 'together',
pin: true
}
],
attachment: 'top center',
targetAttachment: 'bottom center'
};
class ImportMovieSelectMovie extends Component { class ImportMovieSelectMovie extends Component {
// //
@ -34,8 +22,9 @@ class ImportMovieSelectMovie extends Component {
super(props, context); super(props, context);
this._movieLookupTimeout = null; this._movieLookupTimeout = null;
this._buttonRef = {}; this._scheduleUpdate = null;
this._contentRef = {}; this._buttonId = getUniqueElememtId();
this._contentId = getUniqueElememtId();
this.state = { this.state = {
term: props.id, term: props.id,
@ -43,6 +32,12 @@ class ImportMovieSelectMovie extends Component {
}; };
} }
componentDidUpdate() {
if (this._scheduleUpdate) {
this._scheduleUpdate();
}
}
// //
// Control // Control
@ -58,8 +53,8 @@ class ImportMovieSelectMovie extends Component {
// Listeners // Listeners
onWindowClick = (event) => { onWindowClick = (event) => {
const button = ReactDOM.findDOMNode(this._buttonRef.current); const button = document.getElementById(this._buttonId);
const content = ReactDOM.findDOMNode(this._contentRef.current); const content = document.getElementById(this._contentId);
if (!button || !content) { if (!button || !content) {
return; return;
@ -127,150 +122,158 @@ class ImportMovieSelectMovie extends Component {
error.responseJSON.message; error.responseJSON.message;
return ( return (
<TetherComponent <Manager>
classes={{ <Reference>
element: styles.tether {({ ref }) => (
}} <div
{...tetherOptions} ref={ref}
renderTarget={ id={this._buttonId}
(ref) => { >
this._buttonRef = ref; <Link
ref={ref}
return ( className={styles.button}
<div ref={ref}> component="div"
<Link onPress={this.onPress}
className={styles.button} >
component="div" {
onPress={this.onPress} isLookingUpMovie && isQueued && !isPopulated ?
> <LoadingIndicator
{ className={styles.loading}
isLookingUpMovie && isQueued && !isPopulated ? size={20}
<LoadingIndicator /> :
className={styles.loading} null
size={20} }
/> :
null {
} isPopulated && selectedMovie && isExistingMovie ?
<Icon
{ className={styles.warningIcon}
isPopulated && selectedMovie && isExistingMovie ? name={icons.WARNING}
kind={kinds.WARNING}
/> :
null
}
{
isPopulated && selectedMovie ?
<ImportMovieTitle
title={selectedMovie.title}
year={selectedMovie.year}
studio={selectedMovie.studio}
isExistingMovie={isExistingMovie}
/> :
null
}
{
isPopulated && !selectedMovie ?
<div className={styles.noMatches}>
<Icon <Icon
className={styles.warningIcon} className={styles.warningIcon}
name={icons.WARNING} name={icons.WARNING}
kind={kinds.WARNING} kind={kinds.WARNING}
/> : />
null
}
{
isPopulated && selectedMovie ?
<ImportMovieTitle
title={selectedMovie.title}
year={selectedMovie.year}
network={selectedMovie.network}
isExistingMovie={isExistingMovie}
/> :
null
}
{
isPopulated && !selectedMovie ?
<div>
<Icon
className={styles.warningIcon}
name={icons.WARNING}
kind={kinds.WARNING}
/>
No match found! No match found!
</div> : </div> :
null null
} }
{ {
!isFetching && !!error ? !isFetching && !!error ?
<div> <div>
<Icon <Icon
className={styles.warningIcon} className={styles.warningIcon}
title={errorMessage} title={errorMessage}
name={icons.WARNING} name={icons.WARNING}
kind={kinds.WARNING} kind={kinds.WARNING}
/> />
Search failed, please try again later. Search failed, please try again later.
</div> :
null
}
<div className={styles.dropdownArrowContainer}>
<Icon
name={icons.CARET_DOWN}
/>
</div>
</Link>
</div>
)}
</Reference>
<Portal>
<Popper
placement="bottom"
modifiers={{
preventOverflow: {
boundariesElement: 'viewport'
}
}}
>
{({ ref, style, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
return (
<div
ref={ref}
id={this._contentId}
className={styles.contentContainer}
style={style}
>
{
this.state.isOpen ?
<div className={styles.content}>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon name={icons.SEARCH} />
</div>
<TextInput
className={styles.searchInput}
name={`${name}_textInput`}
value={this.state.term}
onChange={this.onSearchInputChange}
/>
<FormInputButton
kind={kinds.DEFAULT}
spinnerIcon={icons.REFRESH}
canSpin={true}
isSpinning={isFetching}
onPress={this.onRefreshPress}
>
<Icon name={icons.REFRESH} />
</FormInputButton>
</div>
<div className={styles.results}>
{
items.map((item) => {
return (
<ImportMovieSearchResultConnector
key={item.tvdbId}
tmdbId={item.tmdbId}
title={item.title}
year={item.year}
studio={item.studio}
onPress={this.onSeriesSelect}
/>
);
})
}
</div>
</div> : </div> :
null null
} }
<div className={styles.dropdownArrowContainer}>
<Icon
name={icons.CARET_DOWN}
/>
</div>
</Link>
</div>
);
}
}
renderElement={
(ref) => {
this._contentRef = ref;
if (!this.state.isOpen) {
return;
}
return (
<div
ref={ref}
className={styles.contentContainer}
>
<div className={styles.content}>
<div className={styles.searchContainer}>
<div className={styles.searchIconContainer}>
<Icon name={icons.SEARCH} />
</div>
<TextInput
className={styles.searchInput}
name={`${name}_textInput`}
value={this.state.term}
onChange={this.onSearchInputChange}
/>
<FormInputButton
kind={kinds.DEFAULT}
spinnerIcon={icons.REFRESH}
canSpin={true}
isSpinning={isFetching}
onPress={this.onRefreshPress}
>
<Icon name={icons.REFRESH} />
</FormInputButton>
</div>
<div className={styles.results}>
{
items.map((item) => {
return (
<ImportMovieSearchResultConnector
key={item.tmdbId}
tmdbId={item.tmdbId}
title={item.title}
year={item.year}
studio={item.studio}
onPress={this.onMovieSelect}
/>
);
})
}
</div>
</div> </div>
</div> );
); }}
} </Popper>
} </Portal>
/> </Manager>
); );
} }
} }

@ -1,9 +1,7 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import classNames from 'classnames';
import jdu from 'jdu'; import jdu from 'jdu';
import styles from './AutoCompleteInput.css'; import AutoSuggestInput from './AutoSuggestInput';
class AutoCompleteInput extends Component { class AutoCompleteInput extends Component {
@ -39,31 +37,6 @@ class AutoCompleteInput extends Component {
}); });
} }
onInputKeyDown = (event) => {
const {
name,
value,
onChange
} = this.props;
const { suggestions } = this.state;
if (
event.key === 'Tab' &&
suggestions.length &&
suggestions[0] !== this.props.value
) {
event.preventDefault();
if (value) {
onChange({
name,
value: suggestions[0]
});
}
}
}
onInputBlur = () => { onInputBlur = () => {
this.setState({ suggestions: [] }); this.setState({ suggestions: [] });
} }
@ -88,74 +61,37 @@ class AutoCompleteInput extends Component {
render() { render() {
const { const {
className,
inputClassName,
name, name,
value, value,
placeholder, ...otherProps
hasError,
hasWarning
} = this.props; } = this.props;
const { suggestions } = this.state; const { suggestions } = this.state;
const inputProps = {
className: classNames(
inputClassName,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
),
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: this.onInputChange,
onKeyDown: this.onInputKeyDown,
onBlur: this.onInputBlur
};
const theme = {
container: styles.inputContainer,
containerOpen: styles.inputContainerOpen,
suggestionsContainer: styles.container,
suggestionsList: styles.list,
suggestion: styles.listItem,
suggestionHighlighted: styles.highlighted
};
return ( return (
<div className={className}> <AutoSuggestInput
<Autosuggest {...otherProps}
id={name} name={name}
inputProps={inputProps} value={value}
theme={theme} suggestions={suggestions}
suggestions={suggestions} getSuggestionValue={this.getSuggestionValue}
getSuggestionValue={this.getSuggestionValue} renderSuggestion={this.renderSuggestion}
renderSuggestion={this.renderSuggestion} onInputBlur={this.onInputBlur}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested} onSuggestionsClearRequested={this.onSuggestionsClearRequested}
/> />
</div>
); );
} }
} }
AutoCompleteInput.propTypes = { AutoCompleteInput.propTypes = {
className: PropTypes.string.isRequired,
inputClassName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
value: PropTypes.string, value: PropTypes.string,
values: PropTypes.arrayOf(PropTypes.string).isRequired, values: PropTypes.arrayOf(PropTypes.string).isRequired,
placeholder: PropTypes.string,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
onChange: PropTypes.func.isRequired onChange: PropTypes.func.isRequired
}; };
AutoCompleteInput.defaultProps = { AutoCompleteInput.defaultProps = {
className: styles.inputWrapper,
inputClassName: styles.input,
value: '' value: ''
}; };

@ -10,25 +10,20 @@
composes: hasWarning from '~Components/Form/Input.css'; composes: hasWarning from '~Components/Form/Input.css';
} }
.inputWrapper {
display: flex;
}
.inputContainer { .inputContainer {
position: relative;
flex-grow: 1; flex-grow: 1;
} }
.container { .suggestionsContainer {
@add-mixin scrollbar; @add-mixin scrollbar;
@add-mixin scrollbarTrack; @add-mixin scrollbarTrack;
@add-mixin scrollbarThumb; @add-mixin scrollbarThumb;
} }
.inputContainerOpen { .suggestionsContainerOpen {
.container { z-index: $popperZIndex;
position: absolute;
z-index: 1; .suggestionsContainer {
overflow-y: auto; overflow-y: auto;
max-height: 200px; max-height: 200px;
width: 100%; width: 100%;
@ -39,20 +34,16 @@
} }
} }
.list { .suggestionsList {
margin: 5px 0; margin: 5px 0;
padding-left: 0; padding-left: 0;
list-style-type: none; list-style-type: none;
} }
.listItem { .suggestion {
padding: 0 16px; padding: 0 16px;
} }
.match { .suggestionHighlighted {
font-weight: bold;
}
.highlighted {
background-color: $menuItemHoverBackgroundColor; background-color: $menuItemHoverBackgroundColor;
} }

@ -0,0 +1,257 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import { Manager, Popper, Reference } from 'react-popper';
import classNames from 'classnames';
import Portal from 'Components/Portal';
import styles from './AutoSuggestInput.css';
class AutoSuggestInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._scheduleUpdate = null;
}
componentDidUpdate(prevProps) {
if (
this._scheduleUpdate &&
prevProps.suggestions !== this.props.suggestions
) {
this._scheduleUpdate();
}
}
//
// Control
renderInputComponent = (inputProps) => {
const { renderInputComponent } = this.props;
return (
<Reference>
{({ ref }) => {
if (renderInputComponent) {
return renderInputComponent(inputProps, ref);
}
return (
<div ref={ref}>
<input
{...inputProps}
/>
</div>
);
}}
</Reference>
);
}
renderSuggestionsContainer = ({ containerProps, children }) => {
return (
<Portal>
<Popper
placement='bottom-start'
modifiers={{
computeMaxHeight: {
order: 851,
enabled: true,
fn: this.onComputeMaxHeight
},
flip: {
padding: this.props.minHeight
}
}}
>
{({ ref: popperRef, style, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
return (
<div
ref={popperRef}
style={style}
className={children ? styles.suggestionsContainerOpen : undefined}
>
<div
{...containerProps}
style={{
maxHeight: style.maxHeight
}}
>
{children}
</div>
</div>
);
}}
</Popper>
</Portal>
);
}
//
// Listeners
onComputeMaxHeight = (data) => {
const {
top,
bottom,
width
} = data.offsets.reference;
const windowHeight = window.innerHeight;
if ((/^botton/).test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
data.styles.width = width;
return data;
}
onInputChange = (event, { newValue }) => {
this.props.onChange({
name: this.props.name,
value: newValue
});
}
onInputKeyDown = (event) => {
const {
name,
value,
suggestions,
onChange
} = this.props;
if (
event.key === 'Tab' &&
suggestions.length &&
suggestions[0] !== this.props.value
) {
event.preventDefault();
if (value) {
onChange({
name,
value: suggestions[0]
});
}
}
}
//
// Render
render() {
const {
forwardedRef,
className,
inputContainerClassName,
name,
value,
placeholder,
suggestions,
hasError,
hasWarning,
getSuggestionValue,
renderSuggestion,
onInputChange,
onInputKeyDown,
onInputFocus,
onInputBlur,
onSuggestionsFetchRequested,
onSuggestionsClearRequested,
onSuggestionSelected,
...otherProps
} = this.props;
const inputProps = {
className: classNames(
className,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
),
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: onInputChange || this.onInputChange,
onKeyDown: onInputKeyDown || this.onInputKeyDown,
onFocus: onInputFocus,
onBlur: onInputBlur
};
const theme = {
container: inputContainerClassName,
containerOpen: styles.suggestionsContainerOpen,
suggestionsContainer: styles.suggestionsContainer,
suggestionsList: styles.suggestionsList,
suggestion: styles.suggestion,
suggestionHighlighted: styles.suggestionHighlighted
};
return (
<Manager>
<Autosuggest
{...otherProps}
ref={forwardedRef}
id={name}
inputProps={inputProps}
theme={theme}
suggestions={suggestions}
getSuggestionValue={getSuggestionValue}
renderInputComponent={this.renderInputComponent}
renderSuggestionsContainer={this.renderSuggestionsContainer}
renderSuggestion={renderSuggestion}
onSuggestionSelected={onSuggestionSelected}
onSuggestionsFetchRequested={onSuggestionsFetchRequested}
onSuggestionsClearRequested={onSuggestionsClearRequested}
/>
</Manager>
);
}
}
AutoSuggestInput.propTypes = {
forwardedRef: PropTypes.func,
className: PropTypes.string.isRequired,
inputContainerClassName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.array]),
placeholder: PropTypes.string,
suggestions: PropTypes.array.isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
enforceMaxHeight: PropTypes.bool.isRequired,
minHeight: PropTypes.number.isRequired,
maxHeight: PropTypes.number.isRequired,
getSuggestionValue: PropTypes.func.isRequired,
renderInputComponent: PropTypes.func,
renderSuggestion: PropTypes.func.isRequired,
onInputChange: PropTypes.func,
onInputKeyDown: PropTypes.func,
onInputFocus: PropTypes.func,
onInputBlur: PropTypes.func.isRequired,
onSuggestionsFetchRequested: PropTypes.func.isRequired,
onSuggestionsClearRequested: PropTypes.func.isRequired,
onSuggestionSelected: PropTypes.func,
onChange: PropTypes.func.isRequired
};
AutoSuggestInput.defaultProps = {
className: styles.input,
inputContainerClassName: styles.inputContainer,
enforceMaxHeight: true,
minHeight: 50,
maxHeight: 200
};
export default AutoSuggestInput;

@ -2,7 +2,7 @@
display: flex; display: flex;
} }
.inputContainer { .input {
composes: inputContainer from '~./TagInput.css'; composes: input from '~./TagInput.css';
composes: hasButton from '~Components/Form/Input.css'; composes: hasButton from '~Components/Form/Input.css';
} }

@ -47,6 +47,7 @@ class DeviceInput extends Component {
render() { render() {
const { const {
className, className,
name,
items, items,
selectedDevices, selectedDevices,
hasError, hasError,
@ -58,7 +59,8 @@ class DeviceInput extends Component {
return ( return (
<div className={className}> <div className={className}>
<TagInput <TagInput
className={styles.inputContainer} inputContainerClassName={styles.input}
name={name}
tags={selectedDevices} tags={selectedDevices}
tagList={items} tagList={items}
allowNew={true} allowNew={true}

@ -1,7 +1,3 @@
.tether {
z-index: 2000;
}
.enhancedSelect { .enhancedSelect {
composes: input from '~Components/Form/Input.css'; composes: input from '~Components/Form/Input.css';
composes: link from '~Components/Link/Link.css'; composes: link from '~Components/Link/Link.css';
@ -44,10 +40,13 @@
} }
.optionsContainer { .optionsContainer {
z-index: $popperZIndex;
width: auto; width: auto;
} }
.options { .options {
composes: scroller from '~Components/Scroller/Scroller.css';
border: 1px solid $inputBorderColor; border: 1px solid $inputBorderColor;
border-radius: 4px; border-radius: 4px;
background-color: $white; background-color: $white;

@ -1,13 +1,14 @@
import _ from 'lodash'; import _ from 'lodash';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import ReactDOM from 'react-dom'; import { Manager, Popper, Reference } from 'react-popper';
import TetherComponent from 'react-tether';
import classNames from 'classnames'; import classNames from 'classnames';
import getUniqueElememtId from 'Utilities/getUniqueElementId';
import isMobileUtil from 'Utilities/isMobile'; import isMobileUtil from 'Utilities/isMobile';
import * as keyCodes from 'Utilities/Constants/keyCodes'; import * as keyCodes from 'Utilities/Constants/keyCodes';
import { icons, scrollDirections } from 'Helpers/Props'; import { icons, scrollDirections } from 'Helpers/Props';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import Portal from 'Components/Portal';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
import Measure from 'Components/Measure'; import Measure from 'Components/Measure';
import Modal from 'Components/Modal/Modal'; import Modal from 'Components/Modal/Modal';
@ -17,19 +18,6 @@ import EnhancedSelectInputSelectedValue from './EnhancedSelectInputSelectedValue
import EnhancedSelectInputOption from './EnhancedSelectInputOption'; import EnhancedSelectInputOption from './EnhancedSelectInputOption';
import styles from './EnhancedSelectInput.css'; import styles from './EnhancedSelectInput.css';
const tetherOptions = {
skipMoveElement: true,
constraints: [
{
to: 'window',
attachment: 'together',
pin: true
}
],
attachment: 'top left',
targetAttachment: 'bottom left'
};
function isArrowKey(keyCode) { function isArrowKey(keyCode) {
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW; return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
} }
@ -87,8 +75,9 @@ class EnhancedSelectInput extends Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this._buttonRef = {}; this._scheduleUpdate = null;
this._optionsRef = {}; this._buttonId = getUniqueElememtId();
this._optionsId = getUniqueElememtId();
this.state = { this.state = {
isOpen: false, isOpen: false,
@ -99,6 +88,10 @@ class EnhancedSelectInput extends Component {
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this._scheduleUpdate) {
this._scheduleUpdate();
}
if (prevProps.value !== this.props.value) { if (prevProps.value !== this.props.value) {
this.setState({ this.setState({
selectedIndex: getSelectedIndex(this.props) selectedIndex: getSelectedIndex(this.props)
@ -120,9 +113,26 @@ class EnhancedSelectInput extends Component {
// //
// Listeners // Listeners
onComputeMaxHeight = (data) => {
const {
top,
bottom
} = data.offsets.reference;
const windowHeight = window.innerHeight;
if ((/^botton/).test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom;
} else {
data.styles.maxHeight = top;
}
return data;
}
onWindowClick = (event) => { onWindowClick = (event) => {
const button = ReactDOM.findDOMNode(this._buttonRef.current); const button = document.getElementById(this._buttonId);
const options = ReactDOM.findDOMNode(this._optionsRef.current); const options = document.getElementById(this._optionsId);
if (!button || this.state.isMobile) { if (!button || this.state.isMobile) {
return; return;
@ -266,96 +276,110 @@ class EnhancedSelectInput extends Component {
return ( return (
<div> <div>
<TetherComponent <Manager>
classes={{ <Reference>
element: styles.tether {({ ref }) => (
}} <div
{...tetherOptions} ref={ref}
renderTarget={ id={this._buttonId}
(ref) => { >
this._buttonRef = ref;
return (
<Measure <Measure
whitelist={['width']} whitelist={['width']}
onMeasure={this.onMeasure} onMeasure={this.onMeasure}
> >
<div ref={ref}> <Link
<Link className={classNames(
className={classNames( className,
className, hasError && styles.hasError,
hasError && styles.hasError, hasWarning && styles.hasWarning,
hasWarning && styles.hasWarning, isDisabled && disabledClassName
isDisabled && disabledClassName )}
)} isDisabled={isDisabled}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
onPress={this.onPress}
>
<SelectedValueComponent
{...selectedValueOptions}
{...selectedOption}
isDisabled={isDisabled} isDisabled={isDisabled}
onBlur={this.onBlur}
onKeyDown={this.onKeyDown}
onPress={this.onPress}
> >
<SelectedValueComponent {selectedOption ? selectedOption.value : null}
{...selectedValueOptions} </SelectedValueComponent>
{...selectedOption}
isDisabled={isDisabled} <div
> className={isDisabled ?
{selectedOption ? selectedOption.value : null} styles.dropdownArrowContainerDisabled :
</SelectedValueComponent> styles.dropdownArrowContainer
}
<div >
className={isDisabled ? <Icon
styles.dropdownArrowContainerDisabled : name={icons.CARET_DOWN}
styles.dropdownArrowContainer />
} </div>
> </Link>
<Icon
name={icons.CARET_DOWN}
/>
</div>
</Link>
</div>
</Measure> </Measure>
); </div>
} )}
} </Reference>
renderElement={ <Portal>
(ref) => { <Popper
this._optionsRef = ref; placement="bottom-start"
modifiers={{
if (!isOpen || isMobile) { computeMaxHeight: {
return; order: 851,
} enabled: true,
fn: this.onComputeMaxHeight
return ( }
<div }}
ref={ref} >
className={styles.optionsContainer} {({ ref, style, scheduleUpdate }) => {
style={{ this._scheduleUpdate = scheduleUpdate;
minWidth: width
}} return (
> <div
<div className={styles.options}> ref={ref}
id={this._optionsId}
className={styles.optionsContainer}
style={{
...style,
minWidth: width
}}
>
{ {
values.map((v, index) => { isOpen && !isMobile ?
return ( <Scroller
<OptionComponent className={styles.options}
key={v.key} style={{
id={v.key} maxHeight: style.maxHeight
isSelected={index === selectedIndex} }}
{...v} >
isMobile={false} {
onSelect={this.onSelect} values.map((v, index) => {
> return (
{v.value} <OptionComponent
</OptionComponent> key={v.key}
); id={v.key}
}) isSelected={index === selectedIndex}
{...v}
isMobile={false}
onSelect={this.onSelect}
>
{v.value}
</OptionComponent>
);
})
}
</Scroller> :
null
} }
</div> </div>
</div> );
); }
} }
} </Popper>
/> </Portal>
</Manager>
{ {
isMobile && isMobile &&

@ -1,66 +1,16 @@
.path {
composes: input from '~Components/Form/Input.css';
}
.hasError {
composes: hasError from '~Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from '~Components/Form/Input.css';
}
.hasFileBrowser { .hasFileBrowser {
composes: input from '~./AutoSuggestInput.css';
composes: hasButton from '~Components/Form/Input.css'; composes: hasButton from '~Components/Form/Input.css';
} }
.pathInputWrapper { .inputWrapper {
display: flex; display: flex;
} }
.pathInputContainer {
position: relative;
flex-grow: 1;
}
.pathContainer {
@add-mixin scrollbar;
@add-mixin scrollbarTrack;
@add-mixin scrollbarThumb;
}
.pathInputContainerOpen {
.pathContainer {
position: absolute;
z-index: 1;
overflow-y: auto;
max-height: 200px;
width: 100%;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
}
}
.pathList {
margin: 5px 0;
padding-left: 0;
list-style-type: none;
}
.pathListItem {
padding: 0 16px;
}
.pathMatch { .pathMatch {
font-weight: bold; font-weight: bold;
} }
.pathHighlighted {
background-color: $menuItemHoverBackgroundColor;
}
.fileBrowserButton { .fileBrowserButton {
composes: button from '~./FormInputButton.css'; composes: button from '~./FormInputButton.css';

@ -1,10 +1,9 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import classNames from 'classnames';
import { icons } from 'Helpers/Props'; import { icons } from 'Helpers/Props';
import Icon from 'Components/Icon'; import Icon from 'Components/Icon';
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal'; import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
import AutoSuggestInput from './AutoSuggestInput';
import FormInputButton from './FormInputButton'; import FormInputButton from './FormInputButton';
import styles from './PathInput.css'; import styles from './PathInput.css';
@ -16,6 +15,8 @@ class PathInput extends Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this._node = document.getElementById('portal-root');
this.state = { this.state = {
isFileBrowserModalOpen: false isFileBrowserModalOpen: false
}; };
@ -106,56 +107,30 @@ class PathInput extends Component {
render() { render() {
const { const {
className, className,
inputClassName,
name, name,
value, value,
placeholder,
paths, paths,
includeFiles, includeFiles,
hasError,
hasWarning,
hasFileBrowser, hasFileBrowser,
onChange onChange,
...otherProps
} = this.props; } = this.props;
const inputProps = {
className: classNames(
inputClassName,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
hasFileBrowser && styles.hasFileBrowser
),
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: this.onInputChange,
onKeyDown: this.onInputKeyDown,
onBlur: this.onInputBlur
};
const theme = {
container: styles.pathInputContainer,
containerOpen: styles.pathInputContainerOpen,
suggestionsContainer: styles.pathContainer,
suggestionsList: styles.pathList,
suggestion: styles.pathListItem,
suggestionHighlighted: styles.pathHighlighted
};
return ( return (
<div className={className}> <div className={className}>
<Autosuggest <AutoSuggestInput
id={name} {...otherProps}
inputProps={inputProps} className={hasFileBrowser ? styles.hasFileBrowser : undefined}
theme={theme} name={name}
value={value}
suggestions={paths} suggestions={paths}
getSuggestionValue={this.getSuggestionValue} getSuggestionValue={this.getSuggestionValue}
renderSuggestion={this.renderSuggestion} renderSuggestion={this.renderSuggestion}
onInputKeyDown={this.onInputKeyDown}
onInputBlur={this.onInputBlur}
onSuggestionSelected={this.onSuggestionSelected} onSuggestionSelected={this.onSuggestionSelected}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested} onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onChange={onChange}
/> />
{ {
@ -185,14 +160,10 @@ class PathInput extends Component {
PathInput.propTypes = { PathInput.propTypes = {
className: PropTypes.string.isRequired, className: PropTypes.string.isRequired,
inputClassName: PropTypes.string.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
value: PropTypes.string, value: PropTypes.string,
placeholder: PropTypes.string,
paths: PropTypes.array.isRequired, paths: PropTypes.array.isRequired,
includeFiles: PropTypes.bool.isRequired, includeFiles: PropTypes.bool.isRequired,
hasError: PropTypes.bool,
hasWarning: PropTypes.bool,
hasFileBrowser: PropTypes.bool, hasFileBrowser: PropTypes.bool,
onChange: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired,
onFetchPaths: PropTypes.func.isRequired, onFetchPaths: PropTypes.func.isRequired,
@ -200,8 +171,7 @@ PathInput.propTypes = {
}; };
PathInput.defaultProps = { PathInput.defaultProps = {
className: styles.pathInputWrapper, className: styles.inputWrapper,
inputClassName: styles.path,
value: '', value: '',
hasFileBrowser: true hasFileBrowser: true
}; };

@ -1,5 +1,5 @@
.inputContainer { .input {
composes: input from '~Components/Form/Input.css'; composes: input from '~./AutoSuggestInput.css';
position: relative; position: relative;
padding: 0; padding: 0;
@ -13,20 +13,7 @@
} }
} }
.hasError { .internalInput {
composes: hasError from '~Components/Form/Input.css';
}
.hasWarning {
composes: hasWarning from '~Components/Form/Input.css';
}
.tags {
flex: 0 0 auto;
max-width: 100%;
}
.input {
flex: 1 1 0%; flex: 1 1 0%;
margin-left: 3px; margin-left: 3px;
min-width: 20%; min-width: 20%;
@ -35,44 +22,3 @@
height: 21px; height: 21px;
border: none; border: none;
} }
.suggestionsContainer {
@add-mixin scrollbar;
@add-mixin scrollbarTrack;
@add-mixin scrollbarThumb;
}
.containerOpen {
.suggestionsContainer {
position: absolute;
right: -1px;
left: -1px;
z-index: 1;
overflow-y: auto;
margin-top: 1px;
max-height: 110px;
border: 1px solid $inputBorderColor;
border-radius: 4px;
background-color: $white;
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
}
}
.suggestionsList {
margin: 5px 0;
padding-left: 0;
list-style-type: none;
}
.suggestion {
padding: 0 16px;
cursor: default;
&:hover {
background-color: $menuItemHoverBackgroundColor;
}
}
.suggestionHighlighted {
background-color: $menuItemHoverBackgroundColor;
}

@ -1,17 +1,17 @@
import _ from 'lodash'; import _ from 'lodash';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import classNames from 'classnames'; import classNames from 'classnames';
import { kinds } from 'Helpers/Props'; import { kinds } from 'Helpers/Props';
import tagShape from 'Helpers/Props/Shapes/tagShape'; import tagShape from 'Helpers/Props/Shapes/tagShape';
import AutoSuggestInput from './AutoSuggestInput';
import TagInputInput from './TagInputInput'; import TagInputInput from './TagInputInput';
import TagInputTag from './TagInputTag'; import TagInputTag from './TagInputTag';
import styles from './TagInput.css'; import styles from './TagInput.css';
function getTag(value, selectedIndex, suggestions, allowNew) { function getTag(value, selectedIndex, suggestions, allowNew) {
if (selectedIndex == null && value) { if (selectedIndex == null && value) {
const existingTag = _.find(suggestions, { name: value }); const existingTag = suggestions.find((suggestion) => suggestion.name === value);
if (existingTag) { if (existingTag) {
return existingTag; return existingTag;
@ -184,7 +184,7 @@ class TagInput extends Component {
// //
// Render // Render
renderInputComponent = (inputProps) => { renderInputComponent = (inputProps, forwardedRef) => {
const { const {
tags, tags,
kind, kind,
@ -194,6 +194,7 @@ class TagInput extends Component {
return ( return (
<TagInputInput <TagInputInput
forwardedRef={forwardedRef}
tags={tags} tags={tags}
kind={kind} kind={kind}
inputProps={inputProps} inputProps={inputProps}
@ -208,10 +209,8 @@ class TagInput extends Component {
render() { render() {
const { const {
className, className,
inputClassName, inputContainerClassName,
placeholder, ...otherProps
hasError,
hasWarning
} = this.props; } = this.props;
const { const {
@ -220,48 +219,30 @@ class TagInput extends Component {
isFocused isFocused
} = this.state; } = this.state;
const inputProps = {
className: inputClassName,
name,
value,
placeholder,
autoComplete: 'off',
spellCheck: false,
onChange: this.onInputChange,
onKeyDown: this.onInputKeyDown,
onFocus: this.onInputFocus,
onBlur: this.onInputBlur
};
const theme = {
container: classNames(
className,
isFocused && styles.isFocused,
hasError && styles.hasError,
hasWarning && styles.hasWarning,
),
containerOpen: styles.containerOpen,
suggestionsContainer: styles.suggestionsContainer,
suggestionsList: styles.suggestionsList,
suggestion: styles.suggestion,
suggestionHighlighted: styles.suggestionHighlighted
};
return ( return (
<Autosuggest <AutoSuggestInput
ref={this._setAutosuggestRef} {...otherProps}
id={name} forwardedRef={this._setAutosuggestRef}
inputProps={inputProps} className={styles.internalInput}
theme={theme} inputContainerClassName={classNames(
inputContainerClassName,
isFocused && styles.isFocused,
)}
value={value}
suggestions={suggestions} suggestions={suggestions}
getSuggestionValue={this.getSuggestionValue} getSuggestionValue={this.getSuggestionValue}
shouldRenderSuggestions={this.shouldRenderSuggestions} shouldRenderSuggestions={this.shouldRenderSuggestions}
focusInputOnSuggestionClick={false} focusInputOnSuggestionClick={false}
renderSuggestion={this.renderSuggestion} renderSuggestion={this.renderSuggestion}
renderInputComponent={this.renderInputComponent} renderInputComponent={this.renderInputComponent}
onInputChange={this.onInputChange}
onInputKeyDown={this.onInputKeyDown}
onInputFocus={this.onInputFocus}
onInputBlur={this.onInputBlur}
onSuggestionSelected={this.onSuggestionSelected} onSuggestionSelected={this.onSuggestionSelected}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested} onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested} onSuggestionsClearRequested={this.onSuggestionsClearRequested}
onChange={this.onInputChange}
/> />
); );
} }
@ -269,7 +250,7 @@ class TagInput extends Component {
TagInput.propTypes = { TagInput.propTypes = {
className: PropTypes.string.isRequired, className: PropTypes.string.isRequired,
inputClassName: PropTypes.string.isRequired, inputContainerClassName: PropTypes.string.isRequired,
tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, tagList: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
allowNew: PropTypes.bool.isRequired, allowNew: PropTypes.bool.isRequired,
@ -285,8 +266,8 @@ TagInput.propTypes = {
}; };
TagInput.defaultProps = { TagInput.defaultProps = {
className: styles.inputContainer, className: styles.internalInput,
inputClassName: styles.input, inputContainerClassName: styles.input,
allowNew: true, allowNew: true,
kind: kinds.INFO, kind: kinds.INFO,
placeholder: '', placeholder: '',

@ -1,4 +1,9 @@
.inputContainer { .inputContainer {
position: absolute;
top: -1px;
right: -1px;
bottom: -1px;
left: -1px;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
padding: 6px 16px; padding: 6px 16px;

@ -23,6 +23,7 @@ class TagInputInput extends Component {
render() { render() {
const { const {
forwardedRef,
className, className,
tags, tags,
inputProps, inputProps,
@ -33,6 +34,7 @@ class TagInputInput extends Component {
return ( return (
<div <div
ref={forwardedRef}
className={className} className={className}
component="div" component="div"
onMouseDown={this.onMouseDown} onMouseDown={this.onMouseDown}
@ -59,6 +61,7 @@ class TagInputInput extends Component {
} }
TagInputInput.propTypes = { TagInputInput.propTypes = {
forwardedRef: PropTypes.func,
className: PropTypes.string.isRequired, className: PropTypes.string.isRequired,
tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired, tags: PropTypes.arrayOf(PropTypes.shape(tagShape)).isRequired,
inputProps: PropTypes.object.isRequired, inputProps: PropTypes.object.isRequired,

@ -71,6 +71,7 @@
.success { .success {
border-color: $successColor; border-color: $successColor;
background-color: $successColor; background-color: $successColor;
color: #eee;
&.outline { &.outline {
color: $successColor; color: $successColor;
@ -101,7 +102,7 @@
.large { .large {
padding: 3px 7px; padding: 3px 7px;
font-weight: bold; font-weight: bold;
font-size: 14px; font-size: $defaultFontSize;
} }
/** Outline **/ /** Outline **/

@ -1,7 +1,3 @@
.tether {
z-index: 2000;
}
.menu { .menu {
position: relative; position: relative;
} }

@ -1,32 +1,31 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import ReactDOM from 'react-dom'; import { Manager, Popper, Reference } from 'react-popper';
import TetherComponent from 'react-tether'; import getUniqueElememtId from 'Utilities/getUniqueElementId';
import { align } from 'Helpers/Props'; import { align } from 'Helpers/Props';
import Portal from 'Components/Portal';
import styles from './Menu.css'; import styles from './Menu.css';
const baseTetherOptions = { const sharedPopperOptions = {
skipMoveElement: true, modifiers: {
constraints: [ preventOverflow: {
{ padding: 0
to: 'window', },
attachment: 'together', flip: {
pin: true padding: 0
} }
] }
}; };
const tetherOptions = { const popperOptions = {
[align.RIGHT]: { [align.RIGHT]: {
...baseTetherOptions, ...sharedPopperOptions,
attachment: 'top right', placement: 'bottom-end'
targetAttachment: 'bottom right'
}, },
[align.LEFT]: { [align.LEFT]: {
...baseTetherOptions, ...sharedPopperOptions,
attachment: 'top left', placement: 'bottom-start'
targetAttachment: 'bottom left'
} }
}; };
@ -38,8 +37,8 @@ class Menu extends Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this._menuRef = {}; this._scheduleUpdate = null;
this._menuContentRef = {}; this._menuButtonId = getUniqueElememtId();
this.state = { this.state = {
isMenuOpen: false, isMenuOpen: false,
@ -51,6 +50,12 @@ class Menu extends Component {
this.setMaxHeight(); this.setMaxHeight();
} }
componentDidUpdate() {
if (this._scheduleUpdate) {
this._scheduleUpdate();
}
}
componentWillUnmount() { componentWillUnmount() {
this._removeListener(); this._removeListener();
} }
@ -63,13 +68,13 @@ class Menu extends Component {
return; return;
} }
const menu = ReactDOM.findDOMNode(this._menuRef.current); const menuButton = document.getElementById(this._menuButtonId);
if (!menu) { if (!menuButton) {
return; return;
} }
const { bottom } = menu.getBoundingClientRect(); const { bottom } = menuButton.getBoundingClientRect();
const maxHeight = window.innerHeight - bottom; const maxHeight = window.innerHeight - bottom;
return maxHeight; return maxHeight;
@ -106,14 +111,13 @@ class Menu extends Component {
// Listeners // Listeners
onWindowClick = (event) => { onWindowClick = (event) => {
const menu = ReactDOM.findDOMNode(this._menuRef.current); const menuButton = document.getElementById(this._menuButtonId);
const menuContent = ReactDOM.findDOMNode(this._menuContentRef.current);
if (!menu || !menuContent) { if (!menuButton) {
return; return;
} }
if ((!menu.contains(event.target) || menuContent.contains(event.target)) && this.state.isMenuOpen) { if (!menuButton.contains(event.target) && this.state.isMenuOpen) {
this.setState({ isMenuOpen: false }); this.setState({ isMenuOpen: false });
this._removeListener(); this._removeListener();
} }
@ -124,17 +128,9 @@ class Menu extends Component {
} }
onWindowScroll = (event) => { onWindowScroll = (event) => {
if (!this._menuContentRef.current) { if (this.state.isMenuOpen) {
return; this.setMaxHeight();
}
const menuContent = ReactDOM.findDOMNode(this._menuContentRef.current);
if (menuContent && menuContent.contains(event.target)) {
return;
} }
this.setMaxHeight();
} }
onMenuButtonPress = () => { onMenuButtonPress = () => {
@ -176,45 +172,39 @@ class Menu extends Component {
); );
return ( return (
<TetherComponent <Manager>
classes={{ <Reference>
element: styles.tether {({ ref }) => (
}} <div
{...tetherOptions[alignMenu]} ref={ref}
renderTarget={ id={this._menuButtonId}
(ref) => { className={className}
this._menuRef = ref; >
{button}
return ( </div>
<div )}
ref={ref} </Reference>
className={className}
> <Portal>
{button} <Popper {...popperOptions[alignMenu]}>
</div> {({ ref, style, scheduleUpdate }) => {
); this._scheduleUpdate = scheduleUpdate;
}
} return React.cloneElement(
renderElement={ childrenArray[1],
(ref) => { {
this._menuContentRef = ref; forwardedRef: ref,
style: {
if (!isMenuOpen) { ...style,
return null; maxHeight
} },
isOpen: isMenuOpen
return React.cloneElement( }
childrenArray[1], );
{ }}
ref, </Popper>
alignMenu, </Portal>
maxHeight, </Manager>
isOpen: isMenuOpen
}
);
}
}
/>
); );
} }
} }

@ -1,4 +1,5 @@
.menuContent { .menuContent {
z-index: $popperZIndex;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: $toolbarMenuItemBackgroundColor; background-color: $toolbarMenuItemBackgroundColor;

@ -10,30 +10,37 @@ class MenuContent extends Component {
render() { render() {
const { const {
forwardedRef,
className, className,
children, children,
maxHeight style,
isOpen
} = this.props; } = this.props;
return ( return (
<div <div
ref={forwardedRef}
className={className} className={className}
style={{ style={style}
maxHeight: maxHeight ? `${maxHeight}px` : undefined
}}
> >
<Scroller className={styles.scroller}> {
{children} isOpen ?
</Scroller> <Scroller className={styles.scroller}>
{children}
</Scroller> :
null
}
</div> </div>
); );
} }
} }
MenuContent.propTypes = { MenuContent.propTypes = {
forwardedRef: PropTypes.func,
className: PropTypes.string, className: PropTypes.string,
children: PropTypes.node.isRequired, children: PropTypes.node.isRequired,
maxHeight: PropTypes.number style: PropTypes.object,
isOpen: PropTypes.bool
}; };
MenuContent.defaultProps = { MenuContent.defaultProps = {

@ -1,5 +1,6 @@
.separator { .separator {
overflow: hidden; overflow: hidden;
min-height: 1px;
height: 1px; height: 1px;
background-color: $themeDarkColor; background-color: $themeDarkColor;
} }

@ -1,7 +1,7 @@
.modalContainer { .modalContainer {
position: absolute; position: absolute;
top: 0; top: 0;
z-index: 1000; z-index: $modalZIndex;
width: 100%; width: 100%;
height: 100%; height: 100%;
} }

@ -28,7 +28,7 @@ class Modal extends Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this._node = document.getElementById('modal-root'); this._node = document.getElementById('portal-root');
this._backgroundRef = null; this._backgroundRef = null;
this._modalId = getUniqueElememtId(); this._modalId = getUniqueElememtId();
} }

@ -10,13 +10,13 @@
.logoContainer { .logoContainer {
display: flex; display: flex;
justify-content: center; align-items: center;
flex: 0 0 $sidebarWidth; flex: 0 0 $sidebarWidth;
padding-left: 20px;
} }
.logoFull { .logoLink {
width: 144px; line-height: 0;
height: 48px;
} }
.logo { .logo {

@ -45,17 +45,19 @@ class PageHeader extends Component {
render() { render() {
const { const {
onSidebarToggle, onSidebarToggle
isSmallScreen
} = this.props; } = this.props;
return ( return (
<div className={styles.header}> <div className={styles.header}>
<div className={styles.logoContainer}> <div className={styles.logoContainer}>
<Link to={`${window.Radarr.urlBase}/`}> <Link
className={styles.logoLink}
to={`${window.Radarr.urlBase}/`}
>
<img <img
className={isSmallScreen ? styles.logo : styles.logoFull} className={styles.logo}
src={isSmallScreen ? `${window.Radarr.urlBase}/Content/Images/logo.png` : `${window.Radarr.urlBase}/Content/Images/logo-full.png`} src={`${window.Radarr.urlBase}/Content/Images/logo.svg`}
/> />
</Link> </Link>
</div> </div>
@ -93,7 +95,6 @@ class PageHeader extends Component {
PageHeader.propTypes = { PageHeader.propTypes = {
onSidebarToggle: PropTypes.func.isRequired, onSidebarToggle: PropTypes.func.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
bindShortcut: PropTypes.func.isRequired bindShortcut: PropTypes.func.isRequired
}; };

@ -18,4 +18,3 @@
margin-right: 5px; margin-right: 5px;
} }
} }

@ -86,7 +86,6 @@ class Page extends Component {
<PageHeader <PageHeader
onSidebarToggle={onSidebarToggle} onSidebarToggle={onSidebarToggle}
isSmallScreen={isSmallScreen}
/> />
<div className={styles.main}> <div className={styles.main}>

@ -31,4 +31,3 @@
height: 100%; height: 100%;
} }
} }

@ -0,0 +1,18 @@
import PropTypes from 'prop-types';
import ReactDOM from 'react-dom';
function Portal(props) {
const { children, target } = props;
return ReactDOM.createPortal(children, target);
}
Portal.propTypes = {
children: PropTypes.node.isRequired,
target: PropTypes.object.isRequired
};
Portal.defaultProps = {
target: document.getElementById('portal-root')
};
export default Portal;

@ -47,6 +47,10 @@
.danger { .danger {
background-color: $dangerColor; background-color: $dangerColor;
&:global(.colorImpaired) {
background: repeating-linear-gradient(90deg, color($dangerColor shade(5%)), color($dangerColor shade(5%)) 5px, color($dangerColor shade(15%)) 5px, color($dangerColor shade(15%)) 10px);
}
} }
.success { .success {
@ -59,6 +63,10 @@
.warning { .warning {
background-color: $warningColor; background-color: $warningColor;
&:global(.colorImpaired) {
background: repeating-linear-gradient(45deg, $warningColor, $warningColor 5px, color($warningColor tint(15%)) 5px, color($warningColor tint(15%)) 10px);
}
} }
.info { .info {

@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { kinds, sizes } from 'Helpers/Props'; import { kinds, sizes } from 'Helpers/Props';
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
import styles from './ProgressBar.css'; import styles from './ProgressBar.css';
function ProgressBar(props) { function ProgressBar(props) {
@ -23,55 +24,65 @@ function ProgressBar(props) {
const actualWidth = width ? `${width}px` : '100%'; const actualWidth = width ? `${width}px` : '100%';
return ( return (
<div <ColorImpairedConsumer>
className={classNames( {(enableColorImpairedMode) => {
containerClassName, return (
styles[size]
)}
title={title}
style={{ width: actualWidth }}
>
{
showText && !!width &&
<div <div
className={styles.backTextContainer} className={classNames(
containerClassName,
styles[size]
)}
title={title}
style={{ width: actualWidth }} style={{ width: actualWidth }}
> >
<div className={styles.backText}> {
<div> showText && width ?
{progressText} <div
</div> className={styles.backTextContainer}
</div> style={{ width: actualWidth }}
</div> >
} <div className={styles.backText}>
<div>
{progressText}
</div>
</div>
</div> :
null
}
<div <div
className={classNames( className={classNames(
className, className,
styles[kind] styles[kind],
)} enableColorImpairedMode && 'colorImpaired'
aria-valuenow={progress} )}
aria-valuemin="0" aria-valuenow={progress}
aria-valuemax="100" aria-valuemin="0"
style={{ width: progressPercent }} aria-valuemax="100"
/> style={{ width: progressPercent }}
{ />
showText &&
<div {
className={styles.frontTextContainer} showText ?
style={{ width: progressPercent }} <div
> className={styles.frontTextContainer}
<div style={{ width: progressPercent }}
className={styles.frontText} >
style={{ width: actualWidth }} <div
> className={styles.frontText}
<div> style={{ width: actualWidth }}
{progressText} >
</div> <div>
{progressText}
</div>
</div>
</div> :
null
}
</div> </div>
</div> );
} }}
</div> </ColorImpairedConsumer>
); );
} }

@ -223,10 +223,6 @@ class SignalRConnector extends Component {
this.props.dispatchUpdate({ section: 'queue.status', data: body.resource }); this.props.dispatchUpdate({ section: 'queue.status', data: body.resource });
} }
handleRootfolder = () => {
this.props.dispatchFetchRootFolders();
}
handleVersion = (body) => { handleVersion = (body) => {
const version = body.Version; const version = body.Version;
@ -237,6 +233,10 @@ class SignalRConnector extends Component {
// No-op for now, we may want this later // No-op for now, we may want this later
} }
handleRootfolder = () => {
this.props.dispatchFetchRootFolders();
}
handleTag = (body) => { handleTag = (body) => {
if (body.action === 'sync') { if (body.action === 'sync') {
this.props.dispatchFetchTags(); this.props.dispatchFetchTags();

@ -1,97 +1,3 @@
.tether {
z-index: 2000;
}
.popoverContainer {
margin: 10px 15px;
}
.popover {
position: relative;
background-color: $white;
box-shadow: 0 5px 10px $popoverShadowColor;
}
.arrow,
.arrow::after {
position: absolute;
display: block;
width: 0;
height: 0;
border-width: 11px;
border-style: solid;
border-color: transparent;
}
.arrow::after {
border-width: 10px;
content: '';
}
.top {
bottom: -11px;
left: 50%;
margin-left: -11px;
border-top-color: $popoverArrowBorderColor;
border-bottom-width: 0;
&::after {
bottom: 1px;
margin-left: -10px;
border-top-color: $white;
border-bottom-width: 0;
content: ' ';
}
}
.right {
top: 50%;
left: -11px;
margin-top: -11px;
border-right-color: $popoverArrowBorderColor;
border-left-width: 0;
&::after {
bottom: -10px;
left: 1px;
border-right-color: $white;
border-left-width: 0;
content: ' ';
}
}
.bottom {
top: -11px;
left: 50%;
margin-left: -11px;
border-top-width: 0;
border-bottom-color: $popoverArrowBorderColor;
&::after {
top: 1px;
margin-left: -10px;
border-top-width: 0;
border-bottom-color: $white;
content: ' ';
}
}
.left {
top: 50%;
right: -11px;
margin-top: -11px;
border-right-width: 0;
border-left-color: $popoverArrowBorderColor;
&::after {
right: 1px;
bottom: -10px;
border-right-width: 0;
border-left-color: $white;
content: ' ';
}
}
.title { .title {
padding: 10px 20px; padding: 10px 20px;
border-bottom: 1px solid $popoverTitleBorderColor; border-bottom: 1px solid $popoverTitleBorderColor;
@ -103,3 +9,7 @@
overflow: auto; overflow: auto;
padding: 10px; padding: 10px;
} }
.tooltipBody {
padding: 0;
}

@ -1,171 +1,37 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React from 'react';
import TetherComponent from 'react-tether'; import Tooltip from './Tooltip';
import classNames from 'classnames';
import isMobileUtil from 'Utilities/isMobile';
import { tooltipPositions } from 'Helpers/Props';
import styles from './Popover.css'; import styles from './Popover.css';
const baseTetherOptions = { function Popover(props) {
skipMoveElement: true, const {
constraints: [ title,
{ body,
to: 'window', ...otherProps
attachment: 'together', } = props;
pin: true
} return (
] <Tooltip
}; {...otherProps}
bodyClassName={styles.tooltipBody}
const tetherOptions = { tooltip={
[tooltipPositions.TOP]: { <div>
...baseTetherOptions, <div className={styles.title}>
attachment: 'bottom center', {title}
targetAttachment: 'top center' </div>
},
<div className={styles.body}>
[tooltipPositions.RIGHT]: { {body}
...baseTetherOptions, </div>
attachment: 'middle left', </div>
targetAttachment: 'middle right' }
}, />
);
[tooltipPositions.BOTTOM]: {
...baseTetherOptions,
attachment: 'top center',
targetAttachment: 'bottom center'
},
[tooltipPositions.LEFT]: {
...baseTetherOptions,
attachment: 'middle right',
targetAttachment: 'middle left'
}
};
class Popover extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isOpen: false
};
this._closeTimeout = null;
}
componentWillUnmount() {
if (this._closeTimeout) {
this._closeTimeout = clearTimeout(this._closeTimeout);
}
}
//
// Listeners
onClick = () => {
if (isMobileUtil()) {
this.setState({ isOpen: !this.state.isOpen });
}
}
onMouseEnter = () => {
if (this._closeTimeout) {
this._closeTimeout = clearTimeout(this._closeTimeout);
}
this.setState({ isOpen: true });
}
onMouseLeave = () => {
this._closeTimeout = setTimeout(() => {
this.setState({ isOpen: false });
}, 100);
}
//
// Render
render() {
const {
className,
anchor,
title,
body,
position
} = this.props;
return (
<TetherComponent
classes={{
element: styles.tether
}}
{...tetherOptions[position]}
renderTarget={
(ref) => (
<span
ref={ref}
className={className}
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
{anchor}
</span>
)
}
renderElement={
(ref) => {
if (!this.state.isOpen) {
return null;
}
return (
<div
ref={ref}
className={styles.popoverContainer}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
>
<div className={styles.popover}>
<div
className={classNames(
styles.arrow,
styles[position]
)}
/>
<div className={styles.title}>
{title}
</div>
<div className={styles.body}>
{body}
</div>
</div>
</div>
);
}
}
/>
);
}
} }
Popover.propTypes = { Popover.propTypes = {
className: PropTypes.string,
anchor: PropTypes.node.isRequired,
title: PropTypes.string.isRequired, title: PropTypes.string.isRequired,
body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired
position: PropTypes.oneOf(tooltipPositions.all)
};
Popover.defaultProps = {
position: tooltipPositions.TOP
}; };
export default Popover; export default Popover;

@ -1,8 +1,5 @@
.tether {
z-index: 2000;
}
.tooltipContainer { .tooltipContainer {
z-index: $popperZIndex;
margin: 10px 15px; margin: 10px 15px;
} }

@ -1,48 +1,12 @@
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import TetherComponent from 'react-tether'; import { Manager, Popper, Reference } from 'react-popper';
import classNames from 'classnames'; import classNames from 'classnames';
import isMobileUtil from 'Utilities/isMobile'; import isMobileUtil from 'Utilities/isMobile';
import { kinds, tooltipPositions } from 'Helpers/Props'; import { kinds, tooltipPositions } from 'Helpers/Props';
import Portal from 'Components/Portal';
import styles from './Tooltip.css'; import styles from './Tooltip.css';
const baseTetherOptions = {
skipMoveElement: true,
constraints: [
{
to: 'window',
attachment: 'together',
pin: true
}
]
};
const tetherOptions = {
[tooltipPositions.TOP]: {
...baseTetherOptions,
attachment: 'bottom center',
targetAttachment: 'top center'
},
[tooltipPositions.RIGHT]: {
...baseTetherOptions,
attachment: 'middle left',
targetAttachment: 'middle right'
},
[tooltipPositions.BOTTOM]: {
...baseTetherOptions,
attachment: 'top center',
targetAttachment: 'bottom center'
},
[tooltipPositions.LEFT]: {
...baseTetherOptions,
attachment: 'middle right',
targetAttachment: 'middle left'
}
};
class Tooltip extends Component { class Tooltip extends Component {
// //
@ -51,11 +15,18 @@ class Tooltip extends Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this._scheduleUpdate = null;
this._closeTimeout = null;
this.state = { this.state = {
isOpen: false isOpen: false
}; };
}
this._closeTimeout = null; componentDidUpdate() {
if (this._scheduleUpdate && this.state.isOpen) {
this._scheduleUpdate();
}
} }
componentWillUnmount() { componentWillUnmount() {
@ -64,9 +35,40 @@ class Tooltip extends Component {
} }
} }
//
// Control
computeMaxSize = (data) => {
const {
top,
right,
bottom,
left
} = data.offsets.reference;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
if ((/^top/).test(data.placement)) {
data.styles.maxHeight = top - 20;
} else if ((/^bottom/).test(data.placement)) {
data.styles.maxHeight = windowHeight - bottom - 20;
} else if ((/^right/).test(data.placement)) {
data.styles.maxWidth = windowWidth - right - 30;
} else {
data.styles.maxWidth = left - 30;
}
return data;
}
// //
// Listeners // Listeners
onMeasure = ({ width }) => {
this.setState({ width });
}
onClick = () => { onClick = () => {
if (isMobileUtil()) { if (isMobileUtil()) {
this.setState({ isOpen: !this.state.isOpen }); this.setState({ isOpen: !this.state.isOpen });
@ -93,20 +95,18 @@ class Tooltip extends Component {
render() { render() {
const { const {
className, className,
bodyClassName,
anchor, anchor,
tooltip, tooltip,
kind, kind,
position position,
canFlip
} = this.props; } = this.props;
return ( return (
<TetherComponent <Manager>
classes={{ <Reference>
element: styles.tether {({ ref }) => (
}}
{...tetherOptions[position]}
renderTarget={
(ref) => (
<span <span
ref={ref} ref={ref}
className={className} className={className}
@ -116,59 +116,91 @@ class Tooltip extends Component {
> >
{anchor} {anchor}
</span> </span>
) )}
} </Reference>
renderElement={
(ref) => { <Portal>
if (!this.state.isOpen) { <Popper
return; placement={position}
} // Disable events to improve performance when many tooltips
// are shown (Quality Definitions for example).
return ( eventsEnabled={false}
<div modifiers={{
ref={ref} computeMaxHeight: {
className={styles.tooltipContainer} order: 851,
onMouseEnter={this.onMouseEnter} enabled: true,
onMouseLeave={this.onMouseLeave} fn: this.computeMaxSize
> },
preventOverflow: {
// Fixes positioning for tooltips in the queue
// and likely others.
escapeWithReference: true
},
flip: {
enabled: canFlip
}
}}
>
{({ ref, style, placement, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
return (
<div <div
className={classNames( ref={ref}
styles.tooltip, className={styles.tooltipContainer}
styles[kind] style={style}
)} onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
> >
<div {
className={classNames( this.state.isOpen ?
styles.arrow, <div
styles[kind], className={classNames(
styles[position] styles.tooltip,
)} styles[kind]
/> )}
>
<div className={styles.body}> <div
{tooltip} className={classNames(
</div> styles.arrow,
styles[kind],
styles[placement.split('-')[0]]
)}
/>
<div
className={bodyClassName}
>
{tooltip}
</div>
</div> :
null
}
</div> </div>
</div> );
); }}
} </Popper>
} </Portal>
/> </Manager>
); );
} }
} }
Tooltip.propTypes = { Tooltip.propTypes = {
className: PropTypes.string, className: PropTypes.string,
bodyClassName: PropTypes.string.isRequired,
anchor: PropTypes.node.isRequired, anchor: PropTypes.node.isRequired,
tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired, tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]), kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]),
position: PropTypes.oneOf(tooltipPositions.all) position: PropTypes.oneOf(tooltipPositions.all),
canFlip: PropTypes.bool.isRequired
}; };
Tooltip.defaultProps = { Tooltip.defaultProps = {
bodyClassName: styles.body,
kind: kinds.DEFAULT, kind: kinds.DEFAULT,
position: tooltipPositions.TOP position: tooltipPositions.TOP,
canFlip: true
}; };
export default Tooltip; export default Tooltip;

@ -24,6 +24,7 @@
.added, .added,
.inCinemas, .inCinemas,
.physicalRelease,
.genres { .genres {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';

@ -38,7 +38,8 @@ function EditIndexerModalContent(props) {
implementationName, implementationName,
name, name,
enableRss, enableRss,
enableSearch, enableAutomaticSearch,
enableInteractiveSearch,
supportsRss, supportsRss,
supportsSearch, supportsSearch,
fields fields
@ -63,9 +64,7 @@ function EditIndexerModalContent(props) {
{ {
!isFetching && !error && !isFetching && !error &&
<Form <Form {...otherProps}>
{...otherProps}
>
<FormGroup> <FormGroup>
<FormLabel>Name</FormLabel> <FormLabel>Name</FormLabel>
@ -91,15 +90,29 @@ function EditIndexerModalContent(props) {
</FormGroup> </FormGroup>
<FormGroup> <FormGroup>
<FormLabel>Enable Search</FormLabel> <FormLabel>Enable Automatic Search</FormLabel>
<FormInputGroup <FormInputGroup
type={inputTypes.CHECK} type={inputTypes.CHECK}
name="enableSearch" name="enableAutomaticSearch"
helpText={supportsSearch.value ? 'Will be used when automatic searches are performed via the UI or by Radarr' : undefined} helpText={supportsSearch.value ? 'Will be used when automatic searches are performed via the UI or by Radarr' : undefined}
helpTextWarning={supportsSearch.value ? undefined : 'Search is not supported with this indexer'} helpTextWarning={supportsSearch.value ? undefined : 'Search is not supported with this indexer'}
isDisabled={!supportsSearch.value} isDisabled={!supportsSearch.value}
{...enableSearch} {...enableAutomaticSearch}
onChange={onInputChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>Enable Interactive Search</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="enableInteractiveSearch"
helpText={supportsSearch.value ? 'Will be used when interactive search is used' : undefined}
helpTextWarning={supportsSearch.value ? undefined : 'Search is not supported with this indexer'}
isDisabled={!supportsSearch.value}
{...enableInteractiveSearch}
onChange={onInputChange} onChange={onInputChange}
/> />
</FormGroup> </FormGroup>

@ -55,7 +55,8 @@ class Indexer extends Component {
id, id,
name, name,
enableRss, enableRss,
enableSearch, enableAutomaticSearch,
enableInteractiveSearch,
supportsRss, supportsRss,
supportsSearch supportsSearch
} = this.props; } = this.props;
@ -80,14 +81,21 @@ class Indexer extends Component {
} }
{ {
supportsSearch && enableSearch && supportsSearch && enableAutomaticSearch &&
<Label kind={kinds.SUCCESS}> <Label kind={kinds.SUCCESS}>
Search Automatic Search
</Label> </Label>
} }
{ {
!enableRss && !enableSearch && supportsSearch && enableInteractiveSearch &&
<Label kind={kinds.SUCCESS}>
Interactive Search
</Label>
}
{
!enableRss && !enableAutomaticSearch && !enableInteractiveSearch &&
<Label <Label
kind={kinds.DISABLED} kind={kinds.DISABLED}
outline={true} outline={true}
@ -122,7 +130,8 @@ Indexer.propTypes = {
id: PropTypes.number.isRequired, id: PropTypes.number.isRequired,
name: PropTypes.string.isRequired, name: PropTypes.string.isRequired,
enableRss: PropTypes.bool.isRequired, enableRss: PropTypes.bool.isRequired,
enableSearch: PropTypes.bool.isRequired, enableAutomaticSearch: PropTypes.bool.isRequired,
enableInteractiveSearch: PropTypes.bool.isRequired,
supportsRss: PropTypes.bool.isRequired, supportsRss: PropTypes.bool.isRequired,
supportsSearch: PropTypes.bool.isRequired, supportsSearch: PropTypes.bool.isRequired,
onConfirmDeleteIndexer: PropTypes.func.isRequired onConfirmDeleteIndexer: PropTypes.func.isRequired

@ -0,0 +1,33 @@
import PropTypes from 'prop-types';
import React from 'react';
import formatBytes from 'Utilities/Number/formatBytes';
function QualityDefinitionLimits(props) {
const {
bytes,
message
} = props;
if (!bytes) {
return <div>{message}</div>;
}
const thirty = formatBytes(bytes * 30);
const fourtyFive = formatBytes(bytes * 45);
const sixty = formatBytes(bytes * 60);
return (
<div>
<div>30 Minutes: {thirty}</div>
<div>45 Minutes: {fourtyFive}</div>
<div>60 Minutes: {sixty}</div>
</div>
);
}
QualityDefinitionLimits.propTypes = {
bytes: PropTypes.number,
message: PropTypes.string.isRequired
};
export default QualityDefinitionLimits;

@ -98,6 +98,12 @@ export const defaultState = {
isSortable: true, isSortable: true,
isVisible: false isVisible: false
}, },
{
name: 'outputPath',
label: 'Output Path',
isSortable: false,
isVisible: false
},
{ {
name: 'estimatedCompletionTime', name: 'estimatedCompletionTime',
label: 'Timeleft', label: 'Timeleft',

@ -6,12 +6,12 @@ function createProfileInUseSelector(profileProp) {
return createSelector( return createSelector(
(state, { id }) => id, (state, { id }) => id,
createAllMoviesSelector(), createAllMoviesSelector(),
(id, series) => { (id, movies) => {
if (!id) { if (!id) {
return false; return false;
} }
return _.some(series, { [profileProp]: id }); return _.some(movies, { [profileProp]: id });
} }
); );
} }

@ -162,7 +162,7 @@ module.exports = {
popoverTitleBackgroundColor: '#f7f7f7', popoverTitleBackgroundColor: '#f7f7f7',
popoverTitleBorderColor: '#ebebeb', popoverTitleBorderColor: '#ebebeb',
popoverShadowColor: 'rgba(0, 0, 0, 0.2)', popoverShadowColor: 'rgba(0, 0, 0, 0.2)',
popoverArrowBorderColor: 'rgba(0, 0, 0, 0.25)', popoverArrowBorderColor: '#fff',
popoverTitleBackgroundInverseColor: '#3a3f51', popoverTitleBackgroundInverseColor: '#3a3f51',
popoverTitleBorderInverseColor: '#4f566f', popoverTitleBorderInverseColor: '#4f566f',

@ -0,0 +1,4 @@
module.exports = {
modalZIndex: 1000,
popperZIndex: 2000
};

@ -48,7 +48,7 @@
</head> </head>
<body> <body>
<div id="modal-root"></div> <div id="portal-root"></div>
<div id="root" class="root"></div> <div id="root" class="root"></div>
</body> </body>

@ -66,7 +66,7 @@
"gulp-stripbom": "1.0.4", "gulp-stripbom": "1.0.4",
"gulp-watch": "5.0.1", "gulp-watch": "5.0.1",
"gulp-wrap": "0.15.0", "gulp-wrap": "0.15.0",
"history": "4.9.0", "history": "4.7.2",
"jdu": "1.0.0", "jdu": "1.0.0",
"jquery": "3.4.0", "jquery": "3.4.0",
"loader-utils": "^1.1.0", "loader-utils": "^1.1.0",
@ -96,11 +96,11 @@
"react-google-recaptcha": "1.0.5", "react-google-recaptcha": "1.0.5",
"react-lazyload": "2.5.0", "react-lazyload": "2.5.0",
"react-measure": "1.4.7", "react-measure": "1.4.7",
"react-popper": "1.3.3",
"react-redux": "6.0.1", "react-redux": "6.0.1",
"react-router-dom": "4.3.1", "react-router-dom": "4.3.1",
"react-slider": "0.11.2", "react-slider": "0.11.2",
"react-tabs": "3.0.0", "react-tabs": "3.0.0",
"react-tether": "2.0.1",
"react-text-truncate": "0.14.1", "react-text-truncate": "0.14.1",
"react-virtualized": "9.21.0", "react-virtualized": "9.21.0",
"redux": "4.0.1", "redux": "4.0.1",

@ -1,4 +1,4 @@
using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers;
namespace NzbDrone.Api.Indexers namespace NzbDrone.Api.Indexers
{ {
@ -14,7 +14,7 @@ namespace NzbDrone.Api.Indexers
base.MapToResource(resource, definition); base.MapToResource(resource, definition);
resource.EnableRss = definition.EnableRss; resource.EnableRss = definition.EnableRss;
resource.EnableSearch = definition.EnableSearch; resource.EnableSearch = definition.EnableAutomaticSearch || definition.EnableInteractiveSearch;
resource.SupportsRss = definition.SupportsRss; resource.SupportsRss = definition.SupportsRss;
resource.SupportsSearch = definition.SupportsSearch; resource.SupportsSearch = definition.SupportsSearch;
resource.Protocol = definition.Protocol; resource.Protocol = definition.Protocol;
@ -25,7 +25,8 @@ namespace NzbDrone.Api.Indexers
base.MapToModel(definition, resource); base.MapToModel(definition, resource);
definition.EnableRss = resource.EnableRss; definition.EnableRss = resource.EnableRss;
definition.EnableSearch = resource.EnableSearch; definition.EnableAutomaticSearch = resource.EnableSearch;
definition.EnableInteractiveSearch = resource.EnableSearch;
} }
protected override void Validate(IndexerDefinition definition, bool includeWarnings) protected override void Validate(IndexerDefinition definition, bool includeWarnings)
@ -34,4 +35,4 @@ namespace NzbDrone.Api.Indexers
base.Validate(definition, includeWarnings); base.Validate(definition, includeWarnings);
} }
} }
} }

@ -63,7 +63,7 @@ namespace NzbDrone.Api.Indexers
} }
try try
{ {
_downloadService.DownloadReport(remoteMovie, false); _downloadService.DownloadReport(remoteMovie);
} }
catch (ReleaseDownloadException ex) catch (ReleaseDownloadException ex)
{ {

@ -2,6 +2,7 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using Newtonsoft.Json; using Newtonsoft.Json;
using Radarr.Http.REST; using Radarr.Http.REST;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser; using NzbDrone.Core.Parser;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;
using NzbDrone.Core.Indexers; using NzbDrone.Core.Indexers;

@ -8,6 +8,7 @@ using NzbDrone.Core.Qualities;
using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles;
using NzbDrone.Core.Movies.AlternativeTitles; using NzbDrone.Core.Movies.AlternativeTitles;
using NzbDrone.Core.Parser; using NzbDrone.Core.Parser;
using NzbDrone.Core.Languages;
namespace NzbDrone.Api.Movies namespace NzbDrone.Api.Movies
{ {

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser; using NzbDrone.Core.Parser;
using Radarr.Http; using Radarr.Http;

@ -3,7 +3,7 @@ using System.Linq;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
using NzbDrone.Api.Qualities; using NzbDrone.Api.Qualities;
using Radarr.Http.REST; using Radarr.Http.REST;
using NzbDrone.Core.Parser; using NzbDrone.Core.Languages;
using NzbDrone.Core.Profiles; using NzbDrone.Core.Profiles;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;

@ -1,6 +1,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using NzbDrone.Core.CustomFormats; using NzbDrone.Core.CustomFormats;
using NzbDrone.Core.Languages;
using NzbDrone.Core.Parser; using NzbDrone.Core.Parser;
using NzbDrone.Core.Profiles; using NzbDrone.Core.Profiles;
using NzbDrone.Core.Qualities; using NzbDrone.Core.Qualities;

@ -106,7 +106,7 @@ namespace NzbDrone.Api.Queue
throw new NotFoundException(); throw new NotFoundException();
} }
_downloadService.DownloadReport(pendingRelease.RemoteMovie, false); _downloadService.DownloadReport(pendingRelease.RemoteMovie);
return resource.AsResponse(); return resource.AsResponse();
} }

@ -19,7 +19,8 @@ namespace NzbDrone.Api.RootFolders
MappedNetworkDriveValidator mappedNetworkDriveValidator, MappedNetworkDriveValidator mappedNetworkDriveValidator,
StartupFolderValidator startupFolderValidator, StartupFolderValidator startupFolderValidator,
SystemFolderValidator systemFolderValidator, SystemFolderValidator systemFolderValidator,
FolderWritableValidator folderWritableValidator) FolderWritableValidator folderWritableValidator
)
: base(signalRBroadcaster) : base(signalRBroadcaster)
{ {
_rootFolderService = rootFolderService; _rootFolderService = rootFolderService;
@ -54,7 +55,7 @@ namespace NzbDrone.Api.RootFolders
private List<RootFolderResource> GetRootFolders() private List<RootFolderResource> GetRootFolders()
{ {
return _rootFolderService.AllWithSpace().ToResource(); return _rootFolderService.AllWithUnmappedFolders().ToResource();
} }
private void DeleteFolder(int id) private void DeleteFolder(int id)

@ -1,4 +1,4 @@
using System; using System;
using System.ServiceProcess; using System.ServiceProcess;
using FluentAssertions; using FluentAssertions;
using NUnit.Framework; using NUnit.Framework;
@ -36,7 +36,7 @@ namespace NzbDrone.Common.Test
{ {
if (Subject.ServiceExist(TEMP_SERVICE_NAME)) if (Subject.ServiceExist(TEMP_SERVICE_NAME))
{ {
Subject.UnInstall(TEMP_SERVICE_NAME); Subject.Uninstall(TEMP_SERVICE_NAME);
} }
if (Subject.IsServiceRunning(ALWAYS_INSTALLED_SERVICE)) if (Subject.IsServiceRunning(ALWAYS_INSTALLED_SERVICE))
@ -65,7 +65,7 @@ namespace NzbDrone.Common.Test
Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeFalse("Service already installed"); Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeFalse("Service already installed");
Subject.Install(TEMP_SERVICE_NAME); Subject.Install(TEMP_SERVICE_NAME);
Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeTrue(); Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeTrue();
Subject.UnInstall(TEMP_SERVICE_NAME); Subject.Uninstall(TEMP_SERVICE_NAME);
Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeFalse(); Subject.ServiceExist(TEMP_SERVICE_NAME).Should().BeFalse();
ExceptionVerification.ExpectedWarns(1); ExceptionVerification.ExpectedWarns(1);
@ -76,7 +76,7 @@ namespace NzbDrone.Common.Test
[ManualTest] [ManualTest]
public void UnInstallService() public void UnInstallService()
{ {
Subject.UnInstall(ServiceProvider.SERVICE_NAME); Subject.Uninstall(ServiceProvider.SERVICE_NAME);
Subject.ServiceExist(ServiceProvider.SERVICE_NAME).Should().BeFalse(); Subject.ServiceExist(ServiceProvider.SERVICE_NAME).Should().BeFalse();
} }

@ -5,7 +5,6 @@ using ICSharpCode.SharpZipLib.GZip;
using ICSharpCode.SharpZipLib.Tar; using ICSharpCode.SharpZipLib.Tar;
using ICSharpCode.SharpZipLib.Zip; using ICSharpCode.SharpZipLib.Zip;
using NLog; using NLog;
using NzbDrone.Common.EnvironmentInfo;
namespace NzbDrone.Common namespace NzbDrone.Common
{ {
@ -32,7 +31,6 @@ namespace NzbDrone.Common
{ {
ExtractZip(compressedFile, destination); ExtractZip(compressedFile, destination);
} }
else else
{ {
ExtractTgz(compressedFile, destination); ExtractTgz(compressedFile, destination);

@ -96,4 +96,4 @@ namespace NzbDrone.Common.Composition
); );
} }
} }
} }

@ -6,7 +6,6 @@ using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Messaging; using NzbDrone.Common.Messaging;
using TinyIoC; using TinyIoC;
namespace NzbDrone.Common.Composition namespace NzbDrone.Common.Composition
{ {
public abstract class ContainerBuilderBase public abstract class ContainerBuilderBase

@ -21,9 +21,17 @@ namespace NzbDrone.Common
Console.WriteLine(); Console.WriteLine();
Console.WriteLine(" Usage: {0} <command> ", Process.GetCurrentProcess().MainModule.ModuleName); Console.WriteLine(" Usage: {0} <command> ", Process.GetCurrentProcess().MainModule.ModuleName);
Console.WriteLine(" Commands:"); Console.WriteLine(" Commands:");
Console.WriteLine(" /{0} Install the application as a Windows Service ({1}).", StartupContext.INSTALL_SERVICE, ServiceProvider.SERVICE_NAME);
Console.WriteLine(" /{0} Uninstall already installed Windows Service ({1}).", StartupContext.UNINSTALL_SERVICE, ServiceProvider.SERVICE_NAME); if (OsInfo.IsWindows)
{
Console.WriteLine(" /{0} Install the application as a Windows Service ({1}).", StartupContext.INSTALL_SERVICE, ServiceProvider.SERVICE_NAME);
Console.WriteLine(" /{0} Uninstall already installed Windows Service ({1}).", StartupContext.UNINSTALL_SERVICE, ServiceProvider.SERVICE_NAME);
Console.WriteLine(" /{0} Register URL and open firewall port (allows access from other devices on your network).", StartupContext.REGISTER_URL);
}
Console.WriteLine(" /{0} Don't open Radarr in a browser", StartupContext.NO_BROWSER); Console.WriteLine(" /{0} Don't open Radarr in a browser", StartupContext.NO_BROWSER);
Console.WriteLine(" /{0} Start Radarr terminating any other instances", StartupContext.TERMINATE);
Console.WriteLine(" /{0}=path Path to use as the AppData location (stores database, config, logs, etc)", StartupContext.APPDATA);
Console.WriteLine(" <No Arguments> Run application in console mode."); Console.WriteLine(" <No Arguments> Run application in console mode.");
} }

@ -0,0 +1,29 @@
using System;
using System.IO;
using System.Runtime.Serialization;
namespace NzbDrone.Common.Disk
{
public class DestinationAlreadyExistsException : IOException
{
public DestinationAlreadyExistsException()
{
}
public DestinationAlreadyExistsException(string message) : base(message)
{
}
public DestinationAlreadyExistsException(string message, int hresult) : base(message, hresult)
{
}
public DestinationAlreadyExistsException(string message, Exception innerException) : base(message, innerException)
{
}
protected DestinationAlreadyExistsException(SerializationInfo info, StreamingContext context) : base(info, context)
{
}
}
}

@ -109,12 +109,12 @@ namespace NzbDrone.Common.Disk
switch (stringComparison) switch (stringComparison)
{ {
case StringComparison.CurrentCulture: case StringComparison.CurrentCulture:
case StringComparison.InvariantCulture: case StringComparison.InvariantCulture:
case StringComparison.Ordinal: case StringComparison.Ordinal:
{ {
return File.Exists(path) && path == path.GetActualCasing(); return File.Exists(path) && path == path.GetActualCasing();
} }
default: default:
{ {
return File.Exists(path); return File.Exists(path);

@ -5,6 +5,7 @@ using System.Threading;
using NLog; using NLog;
using NzbDrone.Common.EnsureThat; using NzbDrone.Common.EnsureThat;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Exceptions;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
namespace NzbDrone.Common.Disk namespace NzbDrone.Common.Disk
@ -55,6 +56,23 @@ namespace NzbDrone.Common.Disk
Ensure.That(sourcePath, () => sourcePath).IsValidPath(); Ensure.That(sourcePath, () => sourcePath).IsValidPath();
Ensure.That(targetPath, () => targetPath).IsValidPath(); Ensure.That(targetPath, () => targetPath).IsValidPath();
if (mode == TransferMode.Move && !_diskProvider.FolderExists(targetPath))
{
if (verificationMode == DiskTransferVerificationMode.TryTransactional || verificationMode == DiskTransferVerificationMode.VerifyOnly)
{
var sourceMount = _diskProvider.GetMount(sourcePath);
var targetMount = _diskProvider.GetMount(targetPath);
// If we're on the same mount, do a simple folder move.
if (sourceMount != null && targetMount != null && sourceMount.RootDirectory == targetMount.RootDirectory)
{
_logger.Debug("Move Directory [{0}] > [{1}]", sourcePath, targetPath);
_diskProvider.MoveFolder(sourcePath, targetPath);
return mode;
}
}
}
if (!_diskProvider.FolderExists(targetPath)) if (!_diskProvider.FolderExists(targetPath))
{ {
_diskProvider.CreateFolder(targetPath); _diskProvider.CreateFolder(targetPath);
@ -64,11 +82,15 @@ namespace NzbDrone.Common.Disk
foreach (var subDir in _diskProvider.GetDirectoryInfos(sourcePath)) foreach (var subDir in _diskProvider.GetDirectoryInfos(sourcePath))
{ {
if (ShouldIgnore(subDir)) continue;
result &= TransferFolder(subDir.FullName, Path.Combine(targetPath, subDir.Name), mode, verificationMode); result &= TransferFolder(subDir.FullName, Path.Combine(targetPath, subDir.Name), mode, verificationMode);
} }
foreach (var sourceFile in _diskProvider.GetFileInfos(sourcePath)) foreach (var sourceFile in _diskProvider.GetFileInfos(sourcePath))
{ {
if (ShouldIgnore(sourceFile)) continue;
var destFile = Path.Combine(targetPath, sourceFile.Name); var destFile = Path.Combine(targetPath, sourceFile.Name);
result &= TransferFile(sourceFile.FullName, destFile, mode, true, verificationMode); result &= TransferFile(sourceFile.FullName, destFile, mode, true, verificationMode);
@ -101,11 +123,15 @@ namespace NzbDrone.Common.Disk
foreach (var subDir in targetFolders.Where(v => !sourceFolders.Any(d => d.Name == v.Name))) foreach (var subDir in targetFolders.Where(v => !sourceFolders.Any(d => d.Name == v.Name)))
{ {
if (ShouldIgnore(subDir)) continue;
_diskProvider.DeleteFolder(subDir.FullName, true); _diskProvider.DeleteFolder(subDir.FullName, true);
} }
foreach (var subDir in sourceFolders) foreach (var subDir in sourceFolders)
{ {
if (ShouldIgnore(subDir)) continue;
filesCopied += MirrorFolder(subDir.FullName, Path.Combine(targetPath, subDir.Name)); filesCopied += MirrorFolder(subDir.FullName, Path.Combine(targetPath, subDir.Name));
} }
@ -114,11 +140,15 @@ namespace NzbDrone.Common.Disk
foreach (var targetFile in targetFiles.Where(v => !sourceFiles.Any(d => d.Name == v.Name))) foreach (var targetFile in targetFiles.Where(v => !sourceFiles.Any(d => d.Name == v.Name)))
{ {
if (ShouldIgnore(targetFile)) continue;
_diskProvider.DeleteFile(targetFile.FullName); _diskProvider.DeleteFile(targetFile.FullName);
} }
foreach (var sourceFile in sourceFiles) foreach (var sourceFile in sourceFiles)
{ {
if (ShouldIgnore(sourceFile)) continue;
var targetFile = Path.Combine(targetPath, sourceFile.Name); var targetFile = Path.Combine(targetPath, sourceFile.Name);
if (CompareFiles(sourceFile.FullName, targetFile)) if (CompareFiles(sourceFile.FullName, targetFile))
@ -211,7 +241,7 @@ namespace NzbDrone.Common.Disk
_diskProvider.MoveFile(sourcePath, tempPath, true); _diskProvider.MoveFile(sourcePath, tempPath, true);
try try
{ {
ClearTargetPath(targetPath, overwrite); ClearTargetPath(sourcePath, targetPath, overwrite);
_diskProvider.MoveFile(tempPath, targetPath); _diskProvider.MoveFile(tempPath, targetPath);
@ -241,7 +271,7 @@ namespace NzbDrone.Common.Disk
throw new IOException(string.Format("Destination cannot be a child of the source [{0}] => [{1}]", sourcePath, targetPath)); throw new IOException(string.Format("Destination cannot be a child of the source [{0}] => [{1}]", sourcePath, targetPath));
} }
ClearTargetPath(targetPath, overwrite); ClearTargetPath(sourcePath, targetPath, overwrite);
if (mode.HasFlag(TransferMode.HardLink)) if (mode.HasFlag(TransferMode.HardLink))
{ {
@ -318,7 +348,7 @@ namespace NzbDrone.Common.Disk
return TransferMode.None; return TransferMode.None;
} }
private void ClearTargetPath(string targetPath, bool overwrite) private void ClearTargetPath(string sourcePath, string targetPath, bool overwrite)
{ {
if (_diskProvider.FileExists(targetPath)) if (_diskProvider.FileExists(targetPath))
{ {
@ -328,7 +358,7 @@ namespace NzbDrone.Common.Disk
} }
else else
{ {
throw new IOException(string.Format("Destination already exists [{0}]", targetPath)); throw new DestinationAlreadyExistsException($"Destination {targetPath} already exists.");
} }
} }
} }
@ -352,7 +382,7 @@ namespace NzbDrone.Common.Disk
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.Error(ex, string.Format("Failed to properly rollback the file move [{0}] to [{1}], incomplete file may be left in target path.", sourcePath, targetPath)); _logger.Error(ex, "Failed to properly rollback the file move [{0}] to [{1}], incomplete file may be left in target path.", sourcePath, targetPath);
} }
} }
@ -368,7 +398,7 @@ namespace NzbDrone.Common.Disk
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.Error(ex, string.Format("Failed to properly rollback the file move [{0}] to [{1}], file may be left in target path.", sourcePath, targetPath)); _logger.Error(ex, "Failed to properly rollback the file move [{0}] to [{1}], file may be left in target path.", sourcePath, targetPath);
} }
} }
@ -387,7 +417,7 @@ namespace NzbDrone.Common.Disk
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.Error(ex, string.Format("Failed to properly rollback the file copy [{0}] to [{1}], file may be left in target path.", sourcePath, targetPath)); _logger.Error(ex, "Failed to properly rollback the file copy [{0}] to [{1}], file may be left in target path.", sourcePath, targetPath);
} }
} }
@ -429,7 +459,7 @@ namespace NzbDrone.Common.Disk
if (i == RetryCount) if (i == RetryCount)
{ {
_logger.Error("Failed to completely transfer [{0}] to [{1}], aborting.", sourcePath, targetPath, i + 1, RetryCount); _logger.Error("Failed to completely transfer [{0}] to [{1}], aborting.", sourcePath, targetPath);
} }
else else
{ {
@ -564,5 +594,27 @@ namespace NzbDrone.Common.Disk
throw; throw;
} }
} }
private bool ShouldIgnore(DirectoryInfo folder)
{
if (folder.Name.StartsWith(".nfs"))
{
_logger.Trace("Ignoring folder {0}", folder.FullName);
return true;
}
return false;
}
private bool ShouldIgnore(FileInfo file)
{
if (file.Name.StartsWith(".nfs") || file.Name == "debug.log" || file.Name.EndsWith(".socket"))
{
_logger.Trace("Ignoring file {0}", file.FullName);
return true;
}
return false;
}
} }
} }

@ -2,7 +2,6 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using NLog;
using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.EnvironmentInfo;
using NzbDrone.Common.Extensions; using NzbDrone.Common.Extensions;
@ -98,15 +97,16 @@ namespace NzbDrone.Common.Disk
{ {
return d.DriveType != DriveType.Network; return d.DriveType != DriveType.Network;
} }
return true; return true;
}) })
.Select(d => new FileSystemModel .Select(d => new FileSystemModel
{ {
Type = FileSystemEntityType.Drive, Type = FileSystemEntityType.Drive,
Name = GetVolumeName(d), Name = GetVolumeName(d),
Path = d.RootDirectory, Path = d.RootDirectory,
LastModified = null LastModified = null
}) })
.ToList(); .ToList();
} }
@ -118,6 +118,7 @@ namespace NzbDrone.Common.Disk
{ {
result.Parent = GetParent(path); result.Parent = GetParent(path);
result.Directories = GetDirectories(path); result.Directories = GetDirectories(path);
if (includeFiles) if (includeFiles)
{ {
result.Files = GetFiles(path); result.Files = GetFiles(path);
@ -149,12 +150,12 @@ namespace NzbDrone.Common.Disk
var directories = _diskProvider.GetDirectoryInfos(path) var directories = _diskProvider.GetDirectoryInfos(path)
.OrderBy(d => d.Name) .OrderBy(d => d.Name)
.Select(d => new FileSystemModel .Select(d => new FileSystemModel
{ {
Name = d.Name, Name = d.Name,
Path = GetDirectoryPath(d.FullName.GetActualCasing()), Path = GetDirectoryPath(d.FullName.GetActualCasing()),
LastModified = d.LastWriteTimeUtc, LastModified = d.LastWriteTimeUtc,
Type = FileSystemEntityType.Folder Type = FileSystemEntityType.Folder
}) })
.ToList(); .ToList();
directories.RemoveAll(d => _setToRemove.Contains(d.Name.ToLowerInvariant())); directories.RemoveAll(d => _setToRemove.Contains(d.Name.ToLowerInvariant()));
@ -167,14 +168,14 @@ namespace NzbDrone.Common.Disk
return _diskProvider.GetFileInfos(path) return _diskProvider.GetFileInfos(path)
.OrderBy(d => d.Name) .OrderBy(d => d.Name)
.Select(d => new FileSystemModel .Select(d => new FileSystemModel
{ {
Name = d.Name, Name = d.Name,
Path = d.FullName.GetActualCasing(), Path = d.FullName.GetActualCasing(),
LastModified = d.LastWriteTimeUtc, LastModified = d.LastWriteTimeUtc,
Extension = d.Extension, Extension = d.Extension,
Size = d.Length, Size = d.Length,
Type = FileSystemEntityType.File Type = FileSystemEntityType.File
}) })
.ToList(); .ToList();
} }
@ -184,6 +185,7 @@ namespace NzbDrone.Common.Disk
{ {
return mountInfo.Name; return mountInfo.Name;
} }
return $"{mountInfo.Name} ({mountInfo.VolumeLabel})"; return $"{mountInfo.Name} ({mountInfo.VolumeLabel})";
} }

@ -0,0 +1,15 @@
using System;
namespace NzbDrone.Common.Disk
{
public static class LongPathSupport
{
public static void Enable()
{
// Mono has an issue with enabling long path support via app.config.
// This works for both mono and .net on Windows.
AppContext.SetSwitch("Switch.System.IO.UseLegacyPathHandling", false);
AppContext.SetSwitch("Switch.System.IO.BlockLongPaths", false);
}
}
}

@ -0,0 +1,15 @@
using NzbDrone.Common.Exceptions;
namespace NzbDrone.Common.Disk
{
public class NotParentException : NzbDroneException
{
public NotParentException(string message, params object[] args) : base(message, args)
{
}
public NotParentException(string message) : base(message)
{
}
}
}

@ -105,7 +105,7 @@ namespace NzbDrone.Common.EnsureThat
{ {
throw ExceptionFactory.CreateForParamValidation(param.Name, string.Format("value [{0}] is not a valid Windows path. paths must be a full path eg. C:\\Windows", param.Value)); throw ExceptionFactory.CreateForParamValidation(param.Name, string.Format("value [{0}] is not a valid Windows path. paths must be a full path eg. C:\\Windows", param.Value));
} }
throw ExceptionFactory.CreateForParamValidation(param.Name, string.Format("value [{0}] is not a valid *nix path. paths must start with /", param.Value)); throw ExceptionFactory.CreateForParamValidation(param.Name, string.Format("value [{0}] is not a valid *nix path. paths must start with /", param.Value));
} }
} }

@ -25,9 +25,9 @@ namespace NzbDrone.Common.EnvironmentInfo
private readonly Logger _logger; private readonly Logger _logger;
public AppFolderFactory(IAppFolderInfo appFolderInfo, public AppFolderFactory(IAppFolderInfo appFolderInfo,
IStartupContext startupContext, IStartupContext startupContext,
IDiskProvider diskProvider, IDiskProvider diskProvider,
IDiskTransferService diskTransferService) IDiskTransferService diskTransferService)
{ {
_appFolderInfo = appFolderInfo; _appFolderInfo = appFolderInfo;
_startupContext = startupContext; _startupContext = startupContext;
@ -43,9 +43,9 @@ namespace NzbDrone.Common.EnvironmentInfo
MigrateAppDataFolder(); MigrateAppDataFolder();
_diskProvider.EnsureFolder(_appFolderInfo.AppDataFolder); _diskProvider.EnsureFolder(_appFolderInfo.AppDataFolder);
} }
catch (UnauthorizedAccessException ex) catch (UnauthorizedAccessException)
{ {
throw new RadarrStartupException(ex, "Cannot create AppFolder, Access to the path {0} is denied", _appFolderInfo.AppDataFolder); throw new RadarrStartupException("Cannot create AppFolder, Access to the path {0} is denied", _appFolderInfo.AppDataFolder);
} }
@ -112,6 +112,7 @@ namespace NzbDrone.Common.EnvironmentInfo
} }
} }
private void InitializeMonoApplicationData() private void InitializeMonoApplicationData()
{ {
if (OsInfo.IsWindows) return; if (OsInfo.IsWindows) return;
@ -149,6 +150,7 @@ namespace NzbDrone.Common.EnvironmentInfo
.ToList() .ToList()
.ForEach(_diskProvider.DeleteFile); .ForEach(_diskProvider.DeleteFile);
} }
private void RemovePidFile() private void RemovePidFile()
{ {
if (OsInfo.IsNotWindows) if (OsInfo.IsNotWindows)

@ -17,7 +17,6 @@ namespace NzbDrone.Common.EnvironmentInfo
{ {
private readonly Environment.SpecialFolder DATA_SPECIAL_FOLDER = Environment.SpecialFolder.CommonApplicationData; private readonly Environment.SpecialFolder DATA_SPECIAL_FOLDER = Environment.SpecialFolder.CommonApplicationData;
private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(AppFolderInfo)); private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(AppFolderInfo));
public AppFolderInfo(IStartupContext startupContext) public AppFolderInfo(IStartupContext startupContext)

@ -26,6 +26,7 @@ namespace NzbDrone.Common.EnvironmentInfo
Release = $"{Version}-{Branch}"; Release = $"{Version}-{Branch}";
} }
public static Version Version { get; } public static Version Version { get; }
public static String Branch { get; } public static String Branch { get; }
public static string Release { get; } public static string Release { get; }
@ -51,4 +52,4 @@ namespace NzbDrone.Common.EnvironmentInfo
} }
} }
} }
} }

@ -6,4 +6,4 @@ namespace NzbDrone.Common.EnvironmentInfo
string Name { get; } string Name { get; }
string FullName { get; } string FullName { get; }
} }
} }

@ -6,4 +6,4 @@ namespace NzbDrone.Common.EnvironmentInfo
bool Enabled { get; } bool Enabled { get; }
OsVersionModel Read(); OsVersionModel Read();
} }
} }

@ -82,6 +82,9 @@ namespace NzbDrone.Common.EnvironmentInfo
Name = Os.ToString(); Name = Os.ToString();
FullName = Name; FullName = Name;
} }
Environment.SetEnvironmentVariable("OS_NAME", Name);
Environment.SetEnvironmentVariable("OS_VERSION", Version);
} }
} }
@ -98,4 +101,4 @@ namespace NzbDrone.Common.EnvironmentInfo
Linux, Linux,
Osx Osx
} }
} }

@ -26,4 +26,4 @@ namespace NzbDrone.Common.EnvironmentInfo
public string FullName { get; } public string FullName { get; }
public string Version { get; } public string Version { get; }
} }
} }

@ -29,7 +29,7 @@ namespace NzbDrone.Common.EnvironmentInfo
if (entry != null) if (entry != null)
{ {
ExecutingApplication = entry.Location; ExecutingApplication = entry.Location;
IsWindowsTray = entry.ManifestModule.Name == $"{ProcessProvider.RADARR_PROCESS_NAME}.exe"; IsWindowsTray = OsInfo.IsWindows && entry.ManifestModule.Name == $"{ProcessProvider.RADARR_PROCESS_NAME}.exe";
} }
} }
@ -129,8 +129,8 @@ namespace NzbDrone.Common.EnvironmentInfo
try try
{ {
var currentAssmeblyLocation = typeof(RuntimeInfo).Assembly.Location; var currentAssemblyLocation = typeof(RuntimeInfo).Assembly.Location;
if (currentAssmeblyLocation.ToLower().Contains("_output")) return false; if (currentAssemblyLocation.ToLower().Contains("_output")) return false;
} }
catch catch
{ {
@ -139,6 +139,7 @@ namespace NzbDrone.Common.EnvironmentInfo
var lowerCurrentDir = Directory.GetCurrentDirectory().ToLower(); var lowerCurrentDir = Directory.GetCurrentDirectory().ToLower();
if (lowerCurrentDir.Contains("teamcity")) return false; if (lowerCurrentDir.Contains("teamcity")) return false;
if (lowerCurrentDir.Contains("buildagent")) return false;
if (lowerCurrentDir.Contains("_output")) return false; if (lowerCurrentDir.Contains("_output")) return false;
return true; return true;

@ -6,8 +6,10 @@ namespace NzbDrone.Common.EnvironmentInfo
{ {
HashSet<string> Flags { get; } HashSet<string> Flags { get; }
Dictionary<string, string> Args { get; } Dictionary<string, string> Args { get; }
bool Help { get; }
bool InstallService { get; } bool InstallService { get; }
bool UninstallService { get; } bool UninstallService { get; }
bool RegisterUrl { get; }
string PreservedArguments { get; } string PreservedArguments { get; }
} }
@ -21,6 +23,7 @@ namespace NzbDrone.Common.EnvironmentInfo
public const string HELP = "?"; public const string HELP = "?";
public const string TERMINATE = "terminateexisting"; public const string TERMINATE = "terminateexisting";
public const string RESTART = "restart"; public const string RESTART = "restart";
public const string REGISTER_URL = "registerurl";
public StartupContext(params string[] args) public StartupContext(params string[] args)
{ {
@ -47,9 +50,10 @@ namespace NzbDrone.Common.EnvironmentInfo
public HashSet<string> Flags { get; private set; } public HashSet<string> Flags { get; private set; }
public Dictionary<string, string> Args { get; private set; } public Dictionary<string, string> Args { get; private set; }
public bool Help => Flags.Contains(HELP);
public bool InstallService => Flags.Contains(INSTALL_SERVICE); public bool InstallService => Flags.Contains(INSTALL_SERVICE);
public bool UninstallService => Flags.Contains(UNINSTALL_SERVICE); public bool UninstallService => Flags.Contains(UNINSTALL_SERVICE);
public bool RegisterUrl => Flags.Contains(REGISTER_URL);
public string PreservedArguments public string PreservedArguments
{ {
@ -71,4 +75,4 @@ namespace NzbDrone.Common.EnvironmentInfo
} }
} }
} }
} }

@ -0,0 +1,67 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace NzbDrone.Common.Extensions
{
public static class ExceptionExtensions
{
public static T WithData<T>(this T ex, string key, string value) where T : Exception
{
ex.AddData(key, value);
return ex;
}
public static T WithData<T>(this T ex, string key, int value) where T : Exception
{
ex.AddData(key, value.ToString());
return ex;
}
public static T WithData<T>(this T ex, string key, Http.HttpUri value) where T : Exception
{
ex.AddData(key, value.ToString());
return ex;
}
public static T WithData<T>(this T ex, Http.HttpResponse response, int maxSampleLength = 512) where T : Exception
{
if (response == null || response.Content == null) return ex;
var contentSample = response.Content.Substring(0, Math.Min(response.Content.Length, maxSampleLength));
if (response.Request != null)
{
ex.AddData("RequestUri", response.Request.Url.ToString());
if (response.Request.ContentSummary != null)
{
ex.AddData("RequestSummary", response.Request.ContentSummary);
}
}
ex.AddData("StatusCode", response.StatusCode.ToString());
if (response.Headers != null)
{
ex.AddData("ContentType", response.Headers.ContentType ?? string.Empty);
}
ex.AddData("ContentLength", response.Content.Length.ToString());
ex.AddData("ContentSample", contentSample);
return ex;
}
private static void AddData(this Exception ex, string key, string value)
{
if (value.IsNullOrWhiteSpace()) return;
ex.Data[key] = value;
}
}
}

@ -51,6 +51,34 @@ namespace NzbDrone.Common.Extensions
} }
} }
public static Dictionary<TKey, TItem> ToDictionaryIgnoreDuplicates<TItem, TKey>(this IEnumerable<TItem> src, Func<TItem, TKey> keySelector)
{
var result = new Dictionary<TKey, TItem>();
foreach (var item in src)
{
var key = keySelector(item);
if (!result.ContainsKey(key))
{
result[key] = item;
}
}
return result;
}
public static Dictionary<TKey, TValue> ToDictionaryIgnoreDuplicates<TItem, TKey, TValue>(this IEnumerable<TItem> src, Func<TItem, TKey> keySelector, Func<TItem, TValue> valueSelector)
{
var result = new Dictionary<TKey, TValue>();
foreach (var item in src)
{
var key = keySelector(item);
if (!result.ContainsKey(key))
{
result[key] = valueSelector(item);
}
}
return result;
}
public static void AddIfNotNull<TSource>(this List<TSource> source, TSource item) public static void AddIfNotNull<TSource>(this List<TSource> source, TSource item)
{ {
if (item == null) if (item == null)

@ -25,6 +25,8 @@ namespace NzbDrone.Common.Extensions
private static readonly string UPDATE_CLIENT_FOLDER_NAME = "Radarr.Update" + Path.DirectorySeparatorChar; private static readonly string UPDATE_CLIENT_FOLDER_NAME = "Radarr.Update" + Path.DirectorySeparatorChar;
private static readonly string UPDATE_LOG_FOLDER_NAME = "UpdateLogs" + Path.DirectorySeparatorChar; private static readonly string UPDATE_LOG_FOLDER_NAME = "UpdateLogs" + Path.DirectorySeparatorChar;
private static readonly Regex PARENT_PATH_END_SLASH_REGEX = new Regex(@"(?<!:)\\$", RegexOptions.Compiled);
public static string CleanFilePath(this string path) public static string CleanFilePath(this string path)
{ {
Ensure.That(path, () => path).IsNotNullOrWhiteSpace(); Ensure.That(path, () => path).IsNotNullOrWhiteSpace();
@ -60,7 +62,7 @@ namespace NzbDrone.Common.Extensions
{ {
if (!parentPath.IsParentPath(childPath)) if (!parentPath.IsParentPath(childPath))
{ {
throw new Exceptions.NotParentException("{0} is not a child of {1}", childPath, parentPath); throw new NotParentException("{0} is not a child of {1}", childPath, parentPath);
} }
return childPath.Substring(parentPath.Length).Trim(Path.DirectorySeparatorChar); return childPath.Substring(parentPath.Length).Trim(Path.DirectorySeparatorChar);
@ -68,24 +70,25 @@ namespace NzbDrone.Common.Extensions
public static string GetParentPath(this string childPath) public static string GetParentPath(this string childPath)
{ {
var parentPath = childPath.TrimEnd('\\', '/'); var cleanPath = OsInfo.IsWindows
? PARENT_PATH_END_SLASH_REGEX.Replace(childPath, "")
var index = parentPath.LastIndexOfAny(new[] { '\\', '/' }); : childPath.TrimEnd(Path.DirectorySeparatorChar);
if (index != -1) if (cleanPath.IsNullOrWhiteSpace())
{ {
return parentPath.Substring(0, index); return null;
} }
return null;
return Directory.GetParent(cleanPath)?.FullName;
} }
public static bool IsParentPath(this string parentPath, string childPath) public static bool IsParentPath(this string parentPath, string childPath)
{ {
if (parentPath != "/") if (parentPath != "/" && !parentPath.EndsWith(":\\"))
{ {
parentPath = parentPath.TrimEnd(Path.DirectorySeparatorChar); parentPath = parentPath.TrimEnd(Path.DirectorySeparatorChar);
} }
if (childPath != "/") if (childPath != "/" && !parentPath.EndsWith(":\\"))
{ {
childPath = childPath.TrimEnd(Path.DirectorySeparatorChar); childPath = childPath.TrimEnd(Path.DirectorySeparatorChar);
} }
@ -192,6 +195,24 @@ namespace NzbDrone.Common.Extensions
return directories; return directories;
} }
public static string GetAncestorPath(this string path, string ancestorName)
{
var parent = Path.GetDirectoryName(path);
while (parent != null)
{
var currentPath = parent;
parent = Path.GetDirectoryName(parent);
if (Path.GetFileName(currentPath) == ancestorName)
{
return currentPath;
}
}
return null;
}
public static string GetAppDataPath(this IAppFolderInfo appFolderInfo) public static string GetAppDataPath(this IAppFolderInfo appFolderInfo)
{ {
return appFolderInfo.AppDataFolder; return appFolderInfo.AppDataFolder;

@ -0,0 +1,13 @@
using System;
using System.Text.RegularExpressions;
namespace NzbDrone.Common.Extensions
{
public static class RegexExtensions
{
public static int EndIndex(this Capture regexMatch)
{
return regexMatch.Index + regexMatch.Length;
}
}
}

@ -22,9 +22,14 @@ namespace NzbDrone.Common.Extensions
return "[NULL]"; return "[NULL]";
} }
public static string FirstCharToLower(this string input)
{
return input.First().ToString().ToLower() + input.Substring(1);
}
public static string FirstCharToUpper(this string input) public static string FirstCharToUpper(this string input)
{ {
return input.First().ToString().ToUpper() + string.Join("", input.Skip(1)); return input.First().ToString().ToUpper() + input.Substring(1);
} }
public static string Inject(this string format, params object[] formattingArgs) public static string Inject(this string format, params object[] formattingArgs)
@ -65,6 +70,7 @@ namespace NzbDrone.Common.Extensions
return text; return text;
} }
public static string Join(this IEnumerable<string> values, string separator) public static string Join(this IEnumerable<string> values, string separator)
{ {
return string.Join(separator, values); return string.Join(separator, values);
@ -144,5 +150,10 @@ namespace NzbDrone.Common.Extensions
{ {
return CamelCaseRegex.Replace(input, match => " " + match.Value); return CamelCaseRegex.Replace(input, match => " " + match.Value);
} }
public static bool ContainsIgnoreCase(this IEnumerable<string> source, string value)
{
return source.Contains(value, StringComparer.InvariantCultureIgnoreCase);
}
} }
} }

@ -11,6 +11,11 @@ namespace NzbDrone.Common.Extensions
return false; return false;
} }
if (path.StartsWith(" ") || path.EndsWith(" "))
{
return false;
}
Uri uri; Uri uri;
if (!Uri.TryCreate(path, UriKind.Absolute, out uri)) if (!Uri.TryCreate(path, UriKind.Absolute, out uri))
{ {

@ -24,7 +24,13 @@ namespace NzbDrone.Common
} }
} }
} }
return string.Format("{0:x8}", mCrc); return $"{mCrc:x8}";
}
public static string AnonymousToken()
{
var seed = $"{Environment.ProcessorCount}_{Environment.OSVersion.Platform}_{Environment.MachineName}_{Environment.UserName}";
return HashUtil.CalculateCrc(seed);
} }
} }
} }

@ -37,7 +37,7 @@ namespace NzbDrone.Common.Http
} }
if (values.Length > 1) if (values.Length > 1)
{ {
throw new ApplicationException(string.Format("Expected {0} to occur only once.", key)); throw new ApplicationException($"Expected {key} to occur only once, but was {values.Join("|")}.");
} }
return values[0]; return values[0];
@ -54,7 +54,7 @@ namespace NzbDrone.Common.Http
return converter(value); return converter(value);
} }
protected void SetSingleValue(string key, string value) protected void SetSingleValue(string key, string value)
{ {
if (value == null) if (value == null)
{ {
Remove(key); Remove(key);

@ -3,11 +3,12 @@ namespace NzbDrone.Common.Http
public enum HttpMethod public enum HttpMethod
{ {
GET, GET,
PUT,
POST, POST,
HEAD, PUT,
DELETE, DELETE,
HEAD,
OPTIONS,
PATCH, PATCH,
OPTIONS MERGE
} }
} }

@ -58,6 +58,6 @@ namespace NzbDrone.Common.Http
} }
} }
} }
} }

@ -16,7 +16,8 @@ namespace NzbDrone.Common.Http
StoreRequestCookie = true; StoreRequestCookie = true;
IgnorePersistentCookies = false; IgnorePersistentCookies = false;
Cookies = new Dictionary<string, string>(); Cookies = new Dictionary<string, string>();
if (!RuntimeInfo.IsProduction) if (!RuntimeInfo.IsProduction)
{ {
AllowAutoRedirect = false; AllowAutoRedirect = false;

@ -355,7 +355,7 @@ namespace NzbDrone.Common.Http
FormData.Add(new HttpFormData FormData.Add(new HttpFormData
{ {
Name = key, Name = key,
ContentData = Encoding.UTF8.GetBytes(value.ToString()) ContentData = Encoding.UTF8.GetBytes(Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture))
}); });
return this; return this;

@ -168,7 +168,7 @@ namespace NzbDrone.Common.Http
{ {
return basePath.Substring(0, baseSlashIndex) + "/" + relativePath; return basePath.Substring(0, baseSlashIndex) + "/" + relativePath;
} }
return relativePath; return relativePath;
} }
@ -263,7 +263,7 @@ namespace NzbDrone.Common.Http
{ {
return new HttpUri(baseUrl.Scheme, baseUrl.Host, baseUrl.Port, CombineRelativePath(baseUrl.Path, relativeUrl.Path), relativeUrl.Query, relativeUrl.Fragment); return new HttpUri(baseUrl.Scheme, baseUrl.Host, baseUrl.Port, CombineRelativePath(baseUrl.Path, relativeUrl.Path), relativeUrl.Query, relativeUrl.Fragment);
} }
return new HttpUri(baseUrl.Scheme, baseUrl.Host, baseUrl.Port, baseUrl.Path, relativeUrl.Query, relativeUrl.Fragment); return new HttpUri(baseUrl.Scheme, baseUrl.Host, baseUrl.Port, baseUrl.Path, relativeUrl.Query, relativeUrl.Fragment);
} }
} }

@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Newtonsoft.Json.Linq;
using NzbDrone.Common.Serializer;
namespace NzbDrone.Common.Instrumentation
{
public class CleansingJsonVisitor : JsonVisitor
{
public override void Visit(JArray json)
{
for (var i = 0; i < json.Count; i++)
{
if (json[i].Type == JTokenType.String)
{
var text = json[i].Value<string>();
json[i] = new JValue(CleanseLogMessage.Cleanse(text));
}
}
foreach (JToken token in json)
{
Visit(token);
}
}
public override void Visit(JProperty property)
{
if (property.Value.Type == JTokenType.String)
{
property.Value = CleanseValue(property.Value as JValue);
}
else
{
base.Visit(property);
}
}
private JValue CleanseValue(JValue value)
{
var text = value.Value<string>();
var cleansed = CleanseLogMessage.Cleanse(text);
return new JValue(cleansed);
}
}
}

@ -19,7 +19,7 @@ namespace NzbDrone.Common.Instrumentation
var exception = e.Exception; var exception = e.Exception;
Console.WriteLine("Task Error: {0}", exception); Console.WriteLine("Task Error: {0}", exception);
Logger.Error(exception, "Task Error: " + exception.Message); Logger.Error(exception, "Task Error");
} }
private static void HandleAppDomainException(object sender, UnhandledExceptionEventArgs e) private static void HandleAppDomainException(object sender, UnhandledExceptionEventArgs e)
@ -31,7 +31,7 @@ namespace NzbDrone.Common.Instrumentation
if (exception is NullReferenceException && if (exception is NullReferenceException &&
exception.ToString().Contains("Microsoft.AspNet.SignalR.Transports.TransportHeartbeat.ProcessServerCommand")) exception.ToString().Contains("Microsoft.AspNet.SignalR.Transports.TransportHeartbeat.ProcessServerCommand"))
{ {
Logger.Warn("SignalR Heartbeat interupted"); Logger.Warn("SignalR Heartbeat interrupted");
return; return;
} }
@ -44,11 +44,9 @@ namespace NzbDrone.Common.Instrumentation
return; return;
} }
} }
Console.WriteLine(exception.StackTrace);
Console.WriteLine("EPIC FAIL: {0}", exception); Console.WriteLine("EPIC FAIL: {0}", exception);
Logger.Fatal(exception, "EPIC FAIL: " + exception.Message); Logger.Fatal(exception, "EPIC FAIL.");
} }
} }
} }

@ -88,10 +88,13 @@
<Compile Include="ConsoleService.cs" /> <Compile Include="ConsoleService.cs" />
<Compile Include="ConvertBase32.cs" /> <Compile Include="ConvertBase32.cs" />
<Compile Include="Crypto\HashProvider.cs" /> <Compile Include="Crypto\HashProvider.cs" />
<Compile Include="Disk\DestinationAlreadyExistsException.cs" />
<Compile Include="Disk\FileSystemLookupService.cs" /> <Compile Include="Disk\FileSystemLookupService.cs" />
<Compile Include="Disk\DriveInfoMount.cs" /> <Compile Include="Disk\DriveInfoMount.cs" />
<Compile Include="Disk\IMount.cs" /> <Compile Include="Disk\IMount.cs" />
<Compile Include="Disk\LongPathSupport.cs" />
<Compile Include="Disk\MountOptions.cs" /> <Compile Include="Disk\MountOptions.cs" />
<Compile Include="Disk\NotParentException.cs" />
<Compile Include="Disk\RelativeFileSystemModel.cs" /> <Compile Include="Disk\RelativeFileSystemModel.cs" />
<Compile Include="Disk\FileSystemModel.cs" /> <Compile Include="Disk\FileSystemModel.cs" />
<Compile Include="Disk\FileSystemResult.cs" /> <Compile Include="Disk\FileSystemResult.cs" />
@ -161,9 +164,11 @@
<Compile Include="Extensions\Base64Extensions.cs" /> <Compile Include="Extensions\Base64Extensions.cs" />
<Compile Include="Extensions\DateTimeExtensions.cs" /> <Compile Include="Extensions\DateTimeExtensions.cs" />
<Compile Include="Crypto\HashConverter.cs" /> <Compile Include="Crypto\HashConverter.cs" />
<Compile Include="Extensions\ExceptionExtensions.cs" />
<Compile Include="Extensions\Int64Extensions.cs" /> <Compile Include="Extensions\Int64Extensions.cs" />
<Compile Include="Extensions\IpAddressExtensions.cs" /> <Compile Include="Extensions\IpAddressExtensions.cs" />
<Compile Include="Extensions\ObjectExtensions.cs" /> <Compile Include="Extensions\ObjectExtensions.cs" />
<Compile Include="Extensions\RegexExtensions.cs" />
<Compile Include="Extensions\StreamExtensions.cs" /> <Compile Include="Extensions\StreamExtensions.cs" />
<Compile Include="Extensions\UrlExtensions.cs" /> <Compile Include="Extensions\UrlExtensions.cs" />
<Compile Include="Extensions\XmlExtensions.cs" /> <Compile Include="Extensions\XmlExtensions.cs" />
@ -203,6 +208,7 @@
<Compile Include="Http\UnexpectedHtmlContentException.cs" /> <Compile Include="Http\UnexpectedHtmlContentException.cs" />
<Compile Include="Http\UserAgentBuilder.cs" /> <Compile Include="Http\UserAgentBuilder.cs" />
<Compile Include="Instrumentation\CleanseLogMessage.cs" /> <Compile Include="Instrumentation\CleanseLogMessage.cs" />
<Compile Include="Instrumentation\CleansingJsonVisitor.cs" />
<Compile Include="Instrumentation\Extensions\LoggerProgressExtensions.cs" /> <Compile Include="Instrumentation\Extensions\LoggerProgressExtensions.cs" />
<Compile Include="Instrumentation\GlobalExceptionHandlers.cs" /> <Compile Include="Instrumentation\GlobalExceptionHandlers.cs" />
<Compile Include="Instrumentation\LogEventExtensions.cs" /> <Compile Include="Instrumentation\LogEventExtensions.cs" />
@ -226,6 +232,7 @@
<Compile Include="Serializer\HttpUriConverter.cs" /> <Compile Include="Serializer\HttpUriConverter.cs" />
<Compile Include="Serializer\IntConverter.cs" /> <Compile Include="Serializer\IntConverter.cs" />
<Compile Include="Serializer\Json.cs" /> <Compile Include="Serializer\Json.cs" />
<Compile Include="Serializer\JsonVisitor.cs" />
<Compile Include="Serializer\UnderscoreStringEnumConverter.cs" /> <Compile Include="Serializer\UnderscoreStringEnumConverter.cs" />
<Compile Include="ServiceFactory.cs" /> <Compile Include="ServiceFactory.cs" />
<Compile Include="ServiceProvider.cs" /> <Compile Include="ServiceProvider.cs" />

@ -60,6 +60,11 @@ namespace NzbDrone.Common.Reflection
return (T)attribute; return (T)attribute;
} }
public static T[] GetAttributes<T>(this MemberInfo member) where T : Attribute
{
return member.GetCustomAttributes(typeof(T), false).OfType<T>().ToArray();
}
public static Type FindTypeByName(this Assembly assembly, string name) public static Type FindTypeByName(this Assembly assembly, string name)
{ {
return assembly.GetTypes().SingleOrDefault(c => c.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase)); return assembly.GetTypes().SingleOrDefault(c => c.Name.Equals(name, StringComparison.InvariantCultureIgnoreCase));

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save