diff --git a/frontend/src/Components/Card.css b/frontend/src/Components/Card.css index e500ca154..6186b7607 100644 --- a/frontend/src/Components/Card.css +++ b/frontend/src/Components/Card.css @@ -1,4 +1,5 @@ .card { + position: relative; margin: 10px; padding: 10px; border-radius: 3px; @@ -6,3 +7,13 @@ box-shadow: 0 0 10px 1px $cardShadowColor; color: $defaultColor; } + +.underlay { + @add-mixin cover; +} + +.overlay { + @add-mixin linkOverlay; + + position: relative; +} \ No newline at end of file diff --git a/frontend/src/Components/Card.js b/frontend/src/Components/Card.js index cc45edba3..c5a4d164c 100644 --- a/frontend/src/Components/Card.js +++ b/frontend/src/Components/Card.js @@ -11,10 +11,27 @@ class Card extends Component { render() { const { className, + overlayClassName, + overlayContent, children, onPress } = this.props; + if (overlayContent) { + return ( +
+ + +
+ {children} +
+
+ ); + } + return ( diff --git a/frontend/src/Components/Form/TextInput.js b/frontend/src/Components/Form/TextInput.js index 67c7776bf..a787a1d1d 100644 --- a/frontend/src/Components/Form/TextInput.js +++ b/frontend/src/Components/Form/TextInput.js @@ -5,14 +5,107 @@ import styles from './TextInput.css'; class TextInput extends Component { + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this._input = null; + this._selectionStart = null; + this._selectionEnd = null; + this._selectionTimeout = null; + this._isMouseTarget = false; + } + + componentDidMount() { + window.addEventListener('mouseup', this.onDocumentMouseUp); + } + + componentWillUnmount() { + window.removeEventListener('mouseup', this.onDocumentMouseUp); + } + + // + // Control + + setInputRef = (ref) => { + this._input = ref; + } + + selectionChange() { + if (this._selectionTimeout) { + this._selectionTimeout = clearTimeout(this._selectionTimeout); + } + + this._selectionTimeout = setTimeout(() => { + const selectionStart = this._input.selectionStart; + const selectionEnd = this._input.selectionEnd; + + const selectionChanged = ( + this._selectionStart !== selectionStart || + this._selectionEnd !== selectionEnd + ); + + this._selectionStart = selectionStart; + this._selectionEnd = selectionEnd; + + if (this.props.onSelectionChange && selectionChanged) { + this.props.onSelectionChange(selectionStart, selectionEnd); + } + }, 10); + } + // // Listeners onChange = (event) => { - this.props.onChange({ - name: this.props.name, + const { + name, + type, + onChange + } = this.props; + + const payload = { + name, value: event.target.value - }); + }; + + // Also return the files for a file input type. + + if (type === 'file') { + payload.files = event.target.files; + } + + onChange(payload); + } + + onFocus = (event) => { + if (this.props.onFocus) { + this.props.onFocus(event); + } + + this.selectionChange(); + } + + onKeyUp = () => { + this.selectionChange(); + } + + onMouseDown = () => { + this._isMouseTarget = true; + } + + onMouseUp = () => { + this.selectionChange(); + } + + onDocumentMouseUp = () => { + if (this._isMouseTarget) { + this.selectionChange(); + } + + this._isMouseTarget = false; } // @@ -29,12 +122,12 @@ class TextInput extends Component { value, hasError, hasWarning, - hasButton, - onFocus + hasButton } = this.props; return ( ); } @@ -67,7 +163,8 @@ TextInput.propTypes = { hasWarning: PropTypes.bool, hasButton: PropTypes.bool, onChange: PropTypes.func.isRequired, - onFocus: PropTypes.func + onFocus: PropTypes.func, + onSelectionChange: PropTypes.func }; TextInput.defaultProps = { diff --git a/frontend/src/Components/Icon.js b/frontend/src/Components/Icon.js index 4fd1c4c11..c905602d3 100644 --- a/frontend/src/Components/Icon.js +++ b/frontend/src/Components/Icon.js @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { kinds } from 'Helpers/Props'; +import { icons, kinds } from 'Helpers/Props'; import classNames from 'classnames'; import styles from './Icon.css'; @@ -10,7 +10,8 @@ function Icon(props) { name, kind, size, - title + title, + isSpinning } = props; return ( @@ -18,7 +19,8 @@ function Icon(props) { className={classNames( name, className, - styles[kind] + styles[kind], + isSpinning && icons.SPIN )} title={title} style={{ @@ -33,12 +35,14 @@ Icon.propTypes = { name: PropTypes.string.isRequired, kind: PropTypes.string.isRequired, size: PropTypes.number.isRequired, - title: PropTypes.string + title: PropTypes.string, + isSpinning: PropTypes.bool.isRequired }; Icon.defaultProps = { kind: kinds.DEFAULT, - size: 14 + size: 14, + isSpinning: false }; export default Icon; diff --git a/frontend/src/Components/Link/IconButton.js b/frontend/src/Components/Link/IconButton.js index 3751530cc..a22384229 100644 --- a/frontend/src/Components/Link/IconButton.js +++ b/frontend/src/Components/Link/IconButton.js @@ -11,6 +11,7 @@ function IconButton(props) { name, kind, size, + isSpinning, ...otherProps } = props; @@ -24,6 +25,7 @@ function IconButton(props) { name={name} kind={kind} size={size} + isSpinning={isSpinning} /> ); @@ -34,7 +36,8 @@ IconButton.propTypes = { iconClassName: PropTypes.string, kind: PropTypes.string, name: PropTypes.string.isRequired, - size: PropTypes.number + size: PropTypes.number, + isSpinning: PropTypes.bool }; IconButton.defaultProps = { diff --git a/frontend/src/Components/Link/SpinnerButton.js b/frontend/src/Components/Link/SpinnerButton.js index 8e5101afe..e724fca9e 100644 --- a/frontend/src/Components/Link/SpinnerButton.js +++ b/frontend/src/Components/Link/SpinnerButton.js @@ -29,10 +29,8 @@ function SpinnerButton(props) { diff --git a/frontend/src/Components/Link/SpinnerIconButton.js b/frontend/src/Components/Link/SpinnerIconButton.js index 8f62d6031..eff51d0d4 100644 --- a/frontend/src/Components/Link/SpinnerIconButton.js +++ b/frontend/src/Components/Link/SpinnerIconButton.js @@ -14,7 +14,7 @@ function SpinnerIconButton(props) { return ( diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js index 752ca15ac..0d762bca2 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js @@ -26,7 +26,8 @@ function PageToolbarButton(props) { {...otherProps} > diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js index f1bd05c93..b8bf063d7 100644 --- a/frontend/src/Components/SignalRConnector.js +++ b/frontend/src/Components/SignalRConnector.js @@ -312,7 +312,8 @@ class SignalRConnector extends Component { this.props.setAppValue({ isConnected: true, isReconnecting: false, - isDisconnected: false + isDisconnected: false, + isRestarting: false }); this.retryInterval = 5; diff --git a/frontend/src/Components/SpinnerIcon.js b/frontend/src/Components/SpinnerIcon.js index 4c5cbb700..f45f6937d 100644 --- a/frontend/src/Components/SpinnerIcon.js +++ b/frontend/src/Components/SpinnerIcon.js @@ -13,7 +13,7 @@ function SpinnerIcon(props) { return ( ); diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 6a4c02282..2aca96375 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -18,6 +18,7 @@ export const CIRCLE_OUTLINE = 'fa fa-circle-o'; export const CLEAR = 'fa fa-trash'; export const CLIPBOARD = 'fa fa-clipboard'; export const CLOSE = 'fa fa-times'; +export const CLONE = 'fa fa-clone'; export const COLLAPSE = 'fa fa-chevron-circle-up'; export const COMPUTER = 'fa fa-desktop'; export const DANGER = 'fa fa-exclamation-circle'; @@ -67,6 +68,7 @@ export const QUICK = 'fa fa-rocket'; export const REFRESH = 'fa fa-refresh'; export const REMOVE = 'fa fa-remove'; export const RESTART = 'fa fa-repeat'; +export const RESTORE = 'fa fa-recycle'; export const REORDER = 'fa fa-bars'; export const RSS = 'fa fa-rss'; export const SAVE = 'fa fa-floppy-o'; diff --git a/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js b/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js index 4a9988b68..7b73c22ee 100644 --- a/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js +++ b/frontend/src/InteractiveImport/Language/SelectLanguageModalContentConnector.js @@ -13,7 +13,7 @@ function createMapStateToProps() { (languageProfiles) => { const { isFetchingSchema: isFetching, - schemaPopulated: isPopulated, + isSchemaPopulated: isPopulated, schemaError: error, schema } = languageProfiles; diff --git a/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js index 2f9a234df..fba89b7ff 100644 --- a/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js +++ b/frontend/src/InteractiveImport/Quality/SelectQualityModalContentConnector.js @@ -14,7 +14,7 @@ function createMapStateToProps() { (qualityProfiles) => { const { isFetchingSchema: isFetching, - schemaPopulated: isPopulated, + isSchemaPopulated: isPopulated, schemaError: error, schema } = qualityProfiles; diff --git a/frontend/src/Settings/General/AnalyticSettings.js b/frontend/src/Settings/General/AnalyticSettings.js new file mode 100644 index 000000000..85dc959fc --- /dev/null +++ b/frontend/src/Settings/General/AnalyticSettings.js @@ -0,0 +1,42 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function AnalyticSettings(props) { + const { + settings, + onInputChange + } = props; + + const { + analyticsEnabled + } = settings; + + return ( +
+ + Send Anonymous Usage Data + + + +
+ ); +} + +AnalyticSettings.propTypes = { + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default AnalyticSettings; diff --git a/frontend/src/Settings/General/BackupSettings.js b/frontend/src/Settings/General/BackupSettings.js new file mode 100644 index 000000000..0314beb5d --- /dev/null +++ b/frontend/src/Settings/General/BackupSettings.js @@ -0,0 +1,82 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function BackupSettings(props) { + const { + advancedSettings, + settings, + onInputChange + } = props; + + const { + backupFolder, + backupInterval, + backupRetention + } = settings; + + if (!advancedSettings) { + return null; + } + + return ( +
+ + Folder + + + + + + Interval + + + + + + Retention + + + +
+ ); +} + +BackupSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default BackupSettings; diff --git a/frontend/src/Settings/General/GeneralSettings.js b/frontend/src/Settings/General/GeneralSettings.js index ba71837a5..e13e0f534 100644 --- a/frontend/src/Settings/General/GeneralSettings.js +++ b/frontend/src/Settings/General/GeneralSettings.js @@ -1,20 +1,20 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { icons, inputTypes, kinds, sizes } from 'Helpers/Props'; +import { kinds } from 'Helpers/Props'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import FieldSet from 'Components/FieldSet'; -import Icon from 'Components/Icon'; -import ClipboardButton from 'Components/Link/ClipboardButton'; import PageContent from 'Components/Page/PageContent'; import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector'; import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormInputButton from 'Components/Form/FormInputButton'; import ConfirmModal from 'Components/Modal/ConfirmModal'; +import AnalyticSettings from './AnalyticSettings'; +import BackupSettings from './BackupSettings'; +import HostSettings from './HostSettings'; +import LoggingSettings from './LoggingSettings'; +import ProxySettings from './ProxySettings'; +import SecuritySettings from './SecuritySettings'; +import UpdateSettings from './UpdateSettings'; class GeneralSettings extends Component { @@ -25,7 +25,6 @@ class GeneralSettings extends Component { super(props, context); this.state = { - isConfirmApiKeyResetModalOpen: false, isRestartRequiredModalOpen: false }; } @@ -76,23 +75,6 @@ class GeneralSettings extends Component { // // Listeners - onApikeyFocus = (event) => { - event.target.select(); - } - - onResetApiKeyPress = () => { - this.setState({ isConfirmApiKeyResetModalOpen: true }); - } - - onConfirmResetApiKey = () => { - this.setState({ isConfirmApiKeyResetModalOpen: false }); - this.props.onConfirmResetApiKey(); - } - - onCloseResetApiKeyModal = () => { - this.setState({ isConfirmApiKeyResetModalOpen: false }); - } - onConfirmRestart = () => { this.setState({ isRestartRequiredModalOpen: false }); this.props.onConfirmRestart(); @@ -118,67 +100,10 @@ class GeneralSettings extends Component { isWindows, mode, onInputChange, + onConfirmResetApiKey, ...otherProps } = this.props; - const { - isConfirmApiKeyResetModalOpen, - isRestartRequiredModalOpen - } = this.state; - - const { - bindAddress, - port, - urlBase, - enableSsl, - sslPort, - sslCertHash, - launchBrowser, - authenticationMethod, - username, - password, - apiKey, - proxyEnabled, - proxyType, - proxyHostname, - proxyPort, - proxyUsername, - proxyPassword, - proxyBypassFilter, - proxyBypassLocalAddresses, - logLevel, - analyticsEnabled, - branch, - updateAutomatically, - updateMechanism, - updateScriptPath - } = settings; - - const authenticationMethodOptions = [ - { key: 'none', value: 'None' }, - { key: 'basic', value: 'Basic (Browser Popup)' }, - { key: 'forms', value: 'Forms (Login Page)' } - ]; - - const proxyTypeOptions = [ - { key: 'http', value: 'HTTP(S)' }, - { key: 'socks4', value: 'Socks4' }, - { key: 'socks5', value: 'Socks5 (Support TOR)' } - ]; - - const logLevelOptions = [ - { key: 'info', value: 'Info' }, - { key: 'debug', value: 'Debug' }, - { key: 'trace', value: 'Trace' } - ]; - - const updateOptions = [ - { key: 'builtIn', value: 'Built-In' }, - { key: 'script', value: 'Script' } - ]; - - const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none'; - return ( -
- - Bind Address - - - - - - Port Number - - - - - - URL Base - - - - - - Enable SSL - - - - - { - enableSsl.value && - - SSL Port - - - - } - - { - isWindows && enableSsl.value && - - SSL Cert Hash - - - - } - - { - mode !== 'service' && - - Open browser on start - - - - } - -
- -
- - Authentication - - - - - { - authenticationEnabled && - - Username - - - - } - - { - authenticationEnabled && - - Password - - - - } - - - API Key - - , - - - - - ]} - onChange={onInputChange} - onFocus={this.onApikeyFocus} - {...apiKey} - /> - -
- -
- - Use Proxy - - - - - { - proxyEnabled.value && -
- - Proxy Type - - - - - - Hostname - - - - - - Port - - - - - - Username - - - - - - Password - - - - - - Ignored Addresses - - - - - - Bypass Proxy for Local Addresses - - - -
- } -
- -
- - Log Level - - - -
- -
- - Send Anonymous Usage Data - - - -
- - { - advancedSettings && -
- - Branch - - - - - { - isMono && -
- - Automatic - - - - - - Mechanism - - - - - { - updateMechanism.value === 'script' && - - Script Path - - - - } -
- } -
- } + + + + + + + + + + + + + + } - - + + Bind Address + + + + + + Port Number + + + + + + URL Base + + + + + + Enable SSL + + + + + { + enableSsl.value && + + SSL Port + + + + } + + { + isWindows && enableSsl.value && + + SSL Cert Hash + + + + } + + { + mode !== 'service' && + + Open browser on start + + + + } + + + ); +} + +HostSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + settings: PropTypes.object.isRequired, + isWindows: PropTypes.bool.isRequired, + mode: PropTypes.string.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default HostSettings; diff --git a/frontend/src/Settings/General/LoggingSettings.js b/frontend/src/Settings/General/LoggingSettings.js new file mode 100644 index 000000000..e7853328e --- /dev/null +++ b/frontend/src/Settings/General/LoggingSettings.js @@ -0,0 +1,48 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function LoggingSettings(props) { + const { + settings, + onInputChange + } = props; + + const { + logLevel + } = settings; + + const logLevelOptions = [ + { key: 'info', value: 'Info' }, + { key: 'debug', value: 'Debug' }, + { key: 'trace', value: 'Trace' } + ]; + + return ( +
+ + Log Level + + + +
+ ); +} + +LoggingSettings.propTypes = { + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default LoggingSettings; diff --git a/frontend/src/Settings/General/ProxySettings.js b/frontend/src/Settings/General/ProxySettings.js new file mode 100644 index 000000000..97f756ed7 --- /dev/null +++ b/frontend/src/Settings/General/ProxySettings.js @@ -0,0 +1,139 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function ProxySettings(props) { + const { + settings, + onInputChange + } = props; + + const { + proxyEnabled, + proxyType, + proxyHostname, + proxyPort, + proxyUsername, + proxyPassword, + proxyBypassFilter, + proxyBypassLocalAddresses + } = settings; + + const proxyTypeOptions = [ + { key: 'http', value: 'HTTP(S)' }, + { key: 'socks4', value: 'Socks4' }, + { key: 'socks5', value: 'Socks5 (Support TOR)' } + ]; + + return ( +
+ + Use Proxy + + + + + { + proxyEnabled.value && +
+ + Proxy Type + + + + + + Hostname + + + + + + Port + + + + + + Username + + + + + + Password + + + + + + Ignored Addresses + + + + + + Bypass Proxy for Local Addresses + + + +
+ } +
+ ); +} + +ProxySettings.propTypes = { + settings: PropTypes.object.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default ProxySettings; diff --git a/frontend/src/Settings/General/SecuritySettings.js b/frontend/src/Settings/General/SecuritySettings.js new file mode 100644 index 000000000..82ed39d0c --- /dev/null +++ b/frontend/src/Settings/General/SecuritySettings.js @@ -0,0 +1,170 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, inputTypes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import Icon from 'Components/Icon'; +import ClipboardButton from 'Components/Link/ClipboardButton'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormInputButton from 'Components/Form/FormInputButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; + +class SecuritySettings extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isConfirmApiKeyResetModalOpen: false + }; + } + + // + // Listeners + + onApikeyFocus = (event) => { + event.target.select(); + } + + onResetApiKeyPress = () => { + this.setState({ isConfirmApiKeyResetModalOpen: true }); + } + + onConfirmResetApiKey = () => { + this.setState({ isConfirmApiKeyResetModalOpen: false }); + this.props.onConfirmResetApiKey(); + } + + onCloseResetApiKeyModal = () => { + this.setState({ isConfirmApiKeyResetModalOpen: false }); + } + + // + // Render + + render() { + const { + settings, + isResettingApiKey, + onInputChange + } = this.props; + + const { + authenticationMethod, + username, + password, + apiKey + } = settings; + + const authenticationMethodOptions = [ + { key: 'none', value: 'None' }, + { key: 'basic', value: 'Basic (Browser Popup)' }, + { key: 'forms', value: 'Forms (Login Page)' } + ]; + + const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none'; + + return ( +
+ + Authentication + + + + + { + authenticationEnabled && + + Username + + + + } + + { + authenticationEnabled && + + Password + + + + } + + + API Key + + , + + + + + ]} + onChange={onInputChange} + onFocus={this.onApikeyFocus} + {...apiKey} + /> + + + +
+ ); + } +} + +SecuritySettings.propTypes = { + settings: PropTypes.object.isRequired, + isResettingApiKey: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onConfirmResetApiKey: PropTypes.func.isRequired +}; + +export default SecuritySettings; diff --git a/frontend/src/Settings/General/UpdateSettings.js b/frontend/src/Settings/General/UpdateSettings.js new file mode 100644 index 000000000..ccbc4d787 --- /dev/null +++ b/frontend/src/Settings/General/UpdateSettings.js @@ -0,0 +1,117 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { inputTypes, sizes } from 'Helpers/Props'; +import FieldSet from 'Components/FieldSet'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +function UpdateSettings(props) { + const { + advancedSettings, + settings, + isMono, + onInputChange + } = props; + + const { + branch, + updateAutomatically, + updateMechanism, + updateScriptPath + } = settings; + + if (!advancedSettings) { + return null; + } + + const updateOptions = [ + { key: 'builtIn', value: 'Built-In' }, + { key: 'script', value: 'Script' } + ]; + + return ( +
+ + Branch + + + + + { + isMono && +
+ + Automatic + + + + + + Mechanism + + + + + { + updateMechanism.value === 'script' && + + Script Path + + + + } +
+ } +
+ ); +} + +UpdateSettings.propTypes = { + advancedSettings: PropTypes.bool.isRequired, + settings: PropTypes.object.isRequired, + isMono: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired +}; + +export default UpdateSettings; diff --git a/frontend/src/Settings/MediaManagement/Naming/Naming.js b/frontend/src/Settings/MediaManagement/Naming/Naming.js index 8e060aea0..1bb2e3c0c 100644 --- a/frontend/src/Settings/MediaManagement/Naming/Naming.js +++ b/frontend/src/Settings/MediaManagement/Naming/Naming.js @@ -96,43 +96,19 @@ class Naming extends Component { if (examples.singleTrackExample) { standardTrackFormatHelpTexts.push(`Single Track: ${examples.singleTrackExample}`); } else { - standardTrackFormatErrors.push('Single Track: Invalid Format'); + standardTrackFormatErrors.push({ message: 'Single Track: Invalid Format' }); } - // if (examples.multiEpisodeExample) { - // standardTrackFormatHelpTexts.push(`Multi Episode: ${examples.multiEpisodeExample}`); - // } else { - // standardTrackFormatErrors.push('Multi Episode: Invalid Format'); - // } - - // if (examples.dailyEpisodeExample) { - // dailyEpisodeFormatHelpTexts.push(`Example: ${examples.dailyEpisodeExample}`); - // } else { - // dailyEpisodeFormatErrors.push('Invalid Format'); - // } - - // if (examples.animeEpisodeExample) { - // animeEpisodeFormatHelpTexts.push(`Single Episode: ${examples.animeEpisodeExample}`); - // } else { - // animeEpisodeFormatErrors.push('Single Episode: Invalid Format'); - // } - - // if (examples.animeMultiEpisodeExample) { - // animeEpisodeFormatHelpTexts.push(`Multi Episode: ${examples.animeMultiEpisodeExample}`); - // } else { - // animeEpisodeFormatErrors.push('Multi Episode: Invalid Format'); - // } - if (examples.artistFolderExample) { artistFolderFormatHelpTexts.push(`Example: ${examples.artistFolderExample}`); } else { - artistFolderFormatErrors.push('Invalid Format'); + artistFolderFormatErrors.push({ message: 'Invalid Format' }); } if (examples.albumFolderExample) { albumFolderFormatHelpTexts.push(`Example: ${examples.albumFolderExample}`); } else { - albumFolderFormatErrors.push('Invalid Format'); + albumFolderFormatErrors.push({ message: 'Invalid Format' }); } } diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js index 4708417fa..cc6d7e4db 100644 --- a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js +++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js @@ -21,6 +21,9 @@ class NamingModal extends Component { constructor(props, context) { super(props, context); + this._selectionStart = null; + this._selectionEnd = null; + this.state = { case: 'title' }; @@ -33,6 +36,40 @@ class NamingModal extends Component { this.setState({ case: event.value }); } + onInputSelectionChange = (selectionStart, selectionEnd) => { + this._selectionStart = selectionStart; + this._selectionEnd = selectionEnd; + } + + onOptionPress = ({ isFullFilename, tokenValue }) => { + const { + name, + value, + onInputChange + } = this.props; + + const selectionStart = this._selectionStart; + const selectionEnd = this._selectionEnd; + + if (isFullFilename) { + onInputChange({ name, value: tokenValue }); + } else if (selectionStart == null) { + onInputChange({ + name, + value: `${value}${tokenValue}` + }); + } else { + const start = value.substring(0, selectionStart); + const end = value.substring(selectionEnd); + const newValue = `${start}${tokenValue}${end}`; + + onInputChange({ name, value: newValue }); + this._selectionStart = newValue.length - 1; + this._selectionEnd = newValue.length - 1; + } + } + + // // Render @@ -188,7 +225,7 @@ class NamingModal extends Component { isFullFilename={true} tokenCase={this.state.case} size={sizes.LARGE} - onInputChange={onInputChange} + onPress={this.onOptionPress} /> ); } @@ -210,7 +247,7 @@ class NamingModal extends Component { token={token} example={example} tokenCase={this.state.case} - onInputChange={onInputChange} + onPress={this.onOptionPress} /> ); } @@ -234,7 +271,7 @@ class NamingModal extends Component { token={token} example={example} tokenCase={this.state.case} - onInputChange={onInputChange} + onPress={this.onOptionPress} /> ); } @@ -255,7 +292,7 @@ class NamingModal extends Component { token={token} example={example} tokenCase={this.state.case} - onInputChange={onInputChange} + onPress={this.onOptionPress} /> ); } @@ -281,7 +318,7 @@ class NamingModal extends Component { token={token} example={example} tokenCase={this.state.case} - onInputChange={onInputChange} + onPress={this.onOptionPress} /> ); } @@ -302,7 +339,7 @@ class NamingModal extends Component { token={token} example={example} tokenCase={this.state.case} - onInputChange={onInputChange} + onPress={this.onOptionPress} /> ); } @@ -323,7 +360,7 @@ class NamingModal extends Component { token={token} example={example} tokenCase={this.state.case} - onInputChange={onInputChange} + onPress={this.onOptionPress} /> ); } @@ -350,7 +387,7 @@ class NamingModal extends Component { token={token} example={example} tokenCase={this.state.case} - onInputChange={onInputChange} + onPress={this.onOptionPress} /> ); } @@ -371,7 +408,7 @@ class NamingModal extends Component { token={token} example={example} tokenCase={this.state.case} - onInputChange={onInputChange} + onPress={this.onOptionPress} /> ); } @@ -392,7 +429,7 @@ class NamingModal extends Component { token={token} example={example} tokenCase={this.state.case} - onInputChange={onInputChange} + onPress={this.onOptionPress} /> ); } @@ -413,7 +450,7 @@ class NamingModal extends Component { token={token} example={example} tokenCase={this.state.case} - onInputChange={onInputChange} + onPress={this.onOptionPress} /> ); } @@ -435,7 +472,7 @@ class NamingModal extends Component { example={example} tokenCase={this.state.case} size={sizes.LARGE} - onInputChange={onInputChange} + onPress={this.onOptionPress} /> ); } @@ -452,6 +489,7 @@ class NamingModal extends Component { name={name} value={value} onChange={onInputChange} + onSelectionChange={this.onInputSelectionChange} /> + + + Restore + + + + ); + } +} + +RestoreBackupModalContent.propTypes = { + id: PropTypes.number, + name: PropTypes.string, + path: PropTypes.string, + isRestoring: PropTypes.bool.isRequired, + restoreError: PropTypes.object, + isRestarting: PropTypes.bool.isRequired, + dispatchRestart: PropTypes.func.isRequired, + onRestorePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default RestoreBackupModalContent; diff --git a/frontend/src/System/Backup/RestoreBackupModalContentConnector.js b/frontend/src/System/Backup/RestoreBackupModalContentConnector.js new file mode 100644 index 000000000..7f2b7a6e8 --- /dev/null +++ b/frontend/src/System/Backup/RestoreBackupModalContentConnector.js @@ -0,0 +1,37 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { restoreBackup, restart } from 'Store/Actions/systemActions'; +import RestoreBackupModalContent from './RestoreBackupModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.system.backups, + (state) => state.app.isRestarting, + (backups, isRestarting) => { + const { + isRestoring, + restoreError + } = backups; + + return { + isRestoring, + restoreError, + isRestarting + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onRestorePress(payload) { + dispatch(restoreBackup(payload)); + }, + + dispatchRestart() { + dispatch(restart()); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(RestoreBackupModalContent); diff --git a/src/Lidarr.Api.V1/Config/HostConfigModule.cs b/src/Lidarr.Api.V1/Config/HostConfigModule.cs index 9ac31d467..da4fce8ea 100644 --- a/src/Lidarr.Api.V1/Config/HostConfigModule.cs +++ b/src/Lidarr.Api.V1/Config/HostConfigModule.cs @@ -1,3 +1,4 @@ +using System.IO; using System.Linq; using System.Reflection; using FluentValidation; @@ -46,6 +47,11 @@ namespace Lidarr.Api.V1.Config SharedValidator.RuleFor(c => c.Branch).NotEmpty().WithMessage("Branch name is required, 'master' is the default"); SharedValidator.RuleFor(c => c.UpdateScriptPath).IsValidPath().When(c => c.UpdateMechanism == UpdateMechanism.Script); + + SharedValidator.RuleFor(c => c.BackupFolder).IsValidPath().When(c => Path.IsPathRooted(c.BackupFolder)); + SharedValidator.RuleFor(c => c.BackupInterval).InclusiveBetween(1, 7); + SharedValidator.RuleFor(c => c.BackupRetention).InclusiveBetween(1, 90); + } private HostConfigResource GetHostConfig() diff --git a/src/Lidarr.Api.V1/Config/HostConfigResource.cs b/src/Lidarr.Api.V1/Config/HostConfigResource.cs index 71d01e10e..2f62d7064 100644 --- a/src/Lidarr.Api.V1/Config/HostConfigResource.cs +++ b/src/Lidarr.Api.V1/Config/HostConfigResource.cs @@ -1,4 +1,4 @@ -using NzbDrone.Common.Http.Proxy; +using NzbDrone.Common.Http.Proxy; using NzbDrone.Core.Authentication; using NzbDrone.Core.Configuration; using NzbDrone.Core.Update; @@ -33,6 +33,9 @@ namespace Lidarr.Api.V1.Config public string ProxyPassword { get; set; } public string ProxyBypassFilter { get; set; } public bool ProxyBypassLocalAddresses { get; set; } + public string BackupFolder { get; set; } + public int BackupInterval { get; set; } + public int BackupRetention { get; set; } } public static class HostConfigResourceMapper @@ -66,7 +69,10 @@ namespace Lidarr.Api.V1.Config ProxyUsername = configService.ProxyUsername, ProxyPassword = configService.ProxyPassword, ProxyBypassFilter = configService.ProxyBypassFilter, - ProxyBypassLocalAddresses = configService.ProxyBypassLocalAddresses + ProxyBypassLocalAddresses = configService.ProxyBypassLocalAddresses, + BackupFolder = configService.BackupFolder, + BackupInterval = configService.BackupInterval, + BackupRetention = configService.BackupRetention }; } } diff --git a/src/Lidarr.Api.V1/System/Backup/BackupModule.cs b/src/Lidarr.Api.V1/System/Backup/BackupModule.cs index 4279f2d73..3d5d556d6 100644 --- a/src/Lidarr.Api.V1/System/Backup/BackupModule.cs +++ b/src/Lidarr.Api.V1/System/Backup/BackupModule.cs @@ -1,19 +1,39 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using Nancy; +using NzbDrone.Common.Crypto; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; using NzbDrone.Core.Backup; using Lidarr.Http; +using Lidarr.Http.Extensions; +using Lidarr.Http.REST; namespace Lidarr.Api.V1.System.Backup { public class BackupModule : LidarrRestModule { private readonly IBackupService _backupService; + private readonly IAppFolderInfo _appFolderInfo; + private readonly IDiskProvider _diskProvider; - public BackupModule(IBackupService backupService) : base("system/backup") + private static readonly List ValidExtensions = new List { ".zip", ".db", ".xml" }; + + public BackupModule(IBackupService backupService, + IAppFolderInfo appFolderInfo, + IDiskProvider diskProvider) + : base("system/backup") { _backupService = backupService; + _appFolderInfo = appFolderInfo; + _diskProvider = diskProvider; GetResourceAll = GetBackupFiles; + DeleteResource = DeleteBackup; + + Post[@"/restore/(?[\d]{1,10})"] = x => Restore((int)x.Id); + Post["/restore/upload"] = x => UploadAndRestore(); } public List GetBackupFiles() @@ -21,15 +41,93 @@ namespace Lidarr.Api.V1.System.Backup var backups = _backupService.GetBackups(); return backups.Select(b => new BackupResource - { - Id = b.Name.GetHashCode(), - Name = b.Name, - Path = $"/backup/{b.Type.ToString().ToLower()}/{b.Name}", - Type = b.Type, - Time = b.Time - }) + { + Id = GetBackupId(b), + Name = b.Name, + Path = $"/backup/{b.Type.ToString().ToLower()}/{b.Name}", + Type = b.Type, + Time = b.Time + }) .OrderByDescending(b => b.Time) .ToList(); } + + private void DeleteBackup(int id) + { + var backup = GetBackup(id); + var path = GetBackupPath(backup); + + if (!_diskProvider.FileExists(path)) + { + throw new NotFoundException(); + } + + _diskProvider.DeleteFile(path); + } + + public Response Restore(int id) + { + var backup = GetBackup(id); + + if (backup == null) + { + throw new NotFoundException(); + } + + var path = GetBackupPath(backup); + + _backupService.Restore(path); + + return new + { + RestartRequired = true + }.AsResponse(); + } + + public Response UploadAndRestore() + { + var files = Context.Request.Files.ToList(); + + if (files.Empty()) + { + throw new BadRequestException("file must be provided"); + } + + var file = files.First(); + var extension = Path.GetExtension(file.Name); + + if (!ValidExtensions.Contains(extension)) + { + throw new UnsupportedMediaTypeException($"Invalid extension, must be one of: {ValidExtensions.Join(", ")}"); + } + + var path = Path.Combine(_appFolderInfo.TempFolder, $"lidarr_backup_restore{extension}"); + + _diskProvider.SaveStream(file.Value, path); + _backupService.Restore(path); + + // Cleanup restored file + _diskProvider.DeleteFile(path); + + return new + { + RestartRequired = true + }.AsResponse(); + } + + private string GetBackupPath(NzbDrone.Core.Backup.Backup backup) + { + return Path.Combine(_backupService.GetBackupFolder(), backup.Type.ToString(), backup.Name); + } + + private int GetBackupId(NzbDrone.Core.Backup.Backup backup) + { + return HashConverter.GetHashInt31($"backup-{backup.Type}-{backup.Name}"); + } + + private NzbDrone.Core.Backup.Backup GetBackup(int id) + { + return _backupService.GetBackups().SingleOrDefault(b => id == GetBackupId(b)); + } } } diff --git a/src/Lidarr.Api.V1/System/SystemModule.cs b/src/Lidarr.Api.V1/System/SystemModule.cs index 04d5ca995..02897e814 100644 --- a/src/Lidarr.Api.V1/System/SystemModule.cs +++ b/src/Lidarr.Api.V1/System/SystemModule.cs @@ -1,3 +1,4 @@ +using System.Threading.Tasks; using Nancy; using Nancy.Routing; using NzbDrone.Common.EnvironmentInfo; @@ -81,14 +82,14 @@ namespace Lidarr.Api.V1.System private Response Shutdown() { - _lifecycleService.Shutdown(); - return "".AsResponse(); + Task.Factory.StartNew(() => _lifecycleService.Shutdown()); + return new { ShuttingDown = true }.AsResponse(); } private Response Restart() { - _lifecycleService.Restart(); - return "".AsResponse(); + Task.Factory.StartNew(() => _lifecycleService.Restart()); + return new { Restarting = true }.AsResponse(); } } } diff --git a/src/Lidarr.Http/Frontend/Mappers/BackupFileMapper.cs b/src/Lidarr.Http/Frontend/Mappers/BackupFileMapper.cs index 771c5944e..610fdf47e 100644 --- a/src/Lidarr.Http/Frontend/Mappers/BackupFileMapper.cs +++ b/src/Lidarr.Http/Frontend/Mappers/BackupFileMapper.cs @@ -1,31 +1,30 @@ using System.IO; using NLog; using NzbDrone.Common.Disk; -using NzbDrone.Common.EnvironmentInfo; -using NzbDrone.Common.Extensions; +using NzbDrone.Core.Backup; namespace Lidarr.Http.Frontend.Mappers { public class BackupFileMapper : StaticResourceMapperBase { - private readonly IAppFolderInfo _appFolderInfo; + private readonly IBackupService _backupService; - public BackupFileMapper(IAppFolderInfo appFolderInfo, IDiskProvider diskProvider, Logger logger) + public BackupFileMapper(IBackupService backupService, IDiskProvider diskProvider, Logger logger) : base(diskProvider, logger) { - _appFolderInfo = appFolderInfo; + _backupService = backupService; } public override string Map(string resourceUrl) { var path = resourceUrl.Replace("/backup/", "").Replace('/', Path.DirectorySeparatorChar); - return Path.Combine(_appFolderInfo.GetBackupFolder(), path); + return Path.Combine(_backupService.GetBackupFolder(), path); } public override bool CanHandle(string resourceUrl) { - return resourceUrl.StartsWith("/backup/") && resourceUrl.ContainsIgnoreCase("lidarr_backup_") && resourceUrl.EndsWith(".zip"); + return resourceUrl.StartsWith("/backup/") && BackupService.BackupFileRegex.IsMatch(resourceUrl); } } } diff --git a/src/Lidarr.Http/Lidarr.Http.csproj b/src/Lidarr.Http/Lidarr.Http.csproj index 493e163b8..a8c3b01ff 100644 --- a/src/Lidarr.Http/Lidarr.Http.csproj +++ b/src/Lidarr.Http/Lidarr.Http.csproj @@ -119,6 +119,7 @@ + diff --git a/src/Lidarr.Http/REST/UnsupportedMediaTypeException.cs b/src/Lidarr.Http/REST/UnsupportedMediaTypeException.cs new file mode 100644 index 000000000..6755a51c3 --- /dev/null +++ b/src/Lidarr.Http/REST/UnsupportedMediaTypeException.cs @@ -0,0 +1,13 @@ +using Nancy; +using Lidarr.Http.Exceptions; + +namespace Lidarr.Http.REST +{ + public class UnsupportedMediaTypeException : ApiException + { + public UnsupportedMediaTypeException(object content = null) + : base(HttpStatusCode.UnsupportedMediaType, content) + { + } + } +} diff --git a/src/NzbDrone.Common/Disk/DiskProviderBase.cs b/src/NzbDrone.Common/Disk/DiskProviderBase.cs index 6054f5d34..1f856c7d5 100644 --- a/src/NzbDrone.Common/Disk/DiskProviderBase.cs +++ b/src/NzbDrone.Common/Disk/DiskProviderBase.cs @@ -474,5 +474,13 @@ namespace NzbDrone.Common.Disk } } } + + public void SaveStream(Stream stream, string path) + { + using (var fileStream = OpenWriteStream(path)) + { + stream.CopyTo(fileStream); + } + } } } diff --git a/src/NzbDrone.Common/Disk/IDiskProvider.cs b/src/NzbDrone.Common/Disk/IDiskProvider.cs index 5ed461fbb..f98529ead 100644 --- a/src/NzbDrone.Common/Disk/IDiskProvider.cs +++ b/src/NzbDrone.Common/Disk/IDiskProvider.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Security.AccessControl; @@ -48,5 +48,6 @@ namespace NzbDrone.Common.Disk List GetDirectoryInfos(string path); List GetFileInfos(string path); void RemoveEmptySubfolders(string path); + void SaveStream(Stream stream, string path); } } diff --git a/src/NzbDrone.Common/Extensions/PathExtensions.cs b/src/NzbDrone.Common/Extensions/PathExtensions.cs index 598bf2494..e1b568dbe 100644 --- a/src/NzbDrone.Common/Extensions/PathExtensions.cs +++ b/src/NzbDrone.Common/Extensions/PathExtensions.cs @@ -12,10 +12,10 @@ namespace NzbDrone.Common.Extensions { private const string APP_CONFIG_FILE = "config.xml"; private const string DB = "lidarr.db"; + private const string DB_RESTORE = "lidarr.restore"; private const string LOG_DB = "logs.db"; private const string NLOG_CONFIG_FILE = "nlog.config"; private const string UPDATE_CLIENT_EXE = "Lidarr.Update.exe"; - private const string BACKUP_FOLDER = "Backups"; private static readonly string UPDATE_SANDBOX_FOLDER_NAME = "lidarr_update" + Path.DirectorySeparatorChar; private static readonly string UPDATE_PACKAGE_FOLDER_NAME = "Lidarr" + Path.DirectorySeparatorChar; @@ -256,14 +256,14 @@ namespace NzbDrone.Common.Extensions return Path.Combine(GetUpdateSandboxFolder(appFolderInfo), UPDATE_CLIENT_EXE); } - public static string GetBackupFolder(this IAppFolderInfo appFolderInfo) + public static string GetDatabase(this IAppFolderInfo appFolderInfo) { - return Path.Combine(GetAppDataPath(appFolderInfo), BACKUP_FOLDER); + return Path.Combine(GetAppDataPath(appFolderInfo), DB); } - public static string GetDatabase(this IAppFolderInfo appFolderInfo) + public static string GetDatabaseRestore(this IAppFolderInfo appFolderInfo) { - return Path.Combine(GetAppDataPath(appFolderInfo), DB); + return Path.Combine(GetAppDataPath(appFolderInfo), DB_RESTORE); } public static string GetLogDatabase(this IAppFolderInfo appFolderInfo) diff --git a/src/NzbDrone.Core/Backup/BackupService.cs b/src/NzbDrone.Core/Backup/BackupService.cs index 8d5238c2c..5cd1ca9cd 100644 --- a/src/NzbDrone.Core/Backup/BackupService.cs +++ b/src/NzbDrone.Core/Backup/BackupService.cs @@ -1,17 +1,17 @@ using System; using System.Collections.Generic; -using System.Data; -using System.Data.SQLite; using System.IO; using System.Linq; +using System.Net; +using System.Text; using System.Text.RegularExpressions; -using Marr.Data; using NLog; using NzbDrone.Common; using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Configuration; using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Commands; @@ -21,6 +21,8 @@ namespace NzbDrone.Core.Backup { void Backup(BackupType backupType); List GetBackups(); + void Restore(string backupFileName); + string GetBackupFolder(); } public class BackupService : IBackupService, IExecute @@ -31,11 +33,12 @@ namespace NzbDrone.Core.Backup private readonly IDiskProvider _diskProvider; private readonly IAppFolderInfo _appFolderInfo; private readonly IArchiveService _archiveService; + private readonly IConfigService _configService; private readonly Logger _logger; private string _backupTempFolder; - private static readonly Regex BackupFileRegex = new Regex(@"lidarr_backup_[._0-9]+\.zip", RegexOptions.Compiled | RegexOptions.IgnoreCase); + public static readonly Regex BackupFileRegex = new Regex(@"lidarr_backup_[._0-9]+\.zip", RegexOptions.Compiled | RegexOptions.IgnoreCase); public BackupService(IMainDatabase maindDb, IMakeDatabaseBackup makeDatabaseBackup, @@ -43,6 +46,7 @@ namespace NzbDrone.Core.Backup IDiskProvider diskProvider, IAppFolderInfo appFolderInfo, IArchiveService archiveService, + IConfigService configService, Logger logger) { _maindDb = maindDb; @@ -51,6 +55,7 @@ namespace NzbDrone.Core.Backup _diskProvider = diskProvider; _appFolderInfo = appFolderInfo; _archiveService = archiveService; + _configService = configService; _logger = logger; _backupTempFolder = Path.Combine(_appFolderInfo.TempFolder, "lidarr_backup"); @@ -75,9 +80,15 @@ namespace NzbDrone.Core.Backup BackupConfigFile(); BackupDatabase(); + CreateVersionInfo(); _logger.ProgressDebug("Creating backup zip"); + + // Delete journal file created during database backup + _diskProvider.DeleteFile(Path.Combine(_backupTempFolder, "lidarr.db-journal")); + _archiveService.CreateZip(backupPath, _diskProvider.GetFiles(_backupTempFolder, SearchOption.TopDirectoryOnly)); + _logger.ProgressDebug("Backup zip created"); } @@ -103,6 +114,62 @@ namespace NzbDrone.Core.Backup return backups; } + public void Restore(string backupFileName) + { + if (backupFileName.EndsWith(".zip")) + { + var restoredFile = false; + var temporaryPath = Path.Combine(_appFolderInfo.TempFolder, "lidarr_backup_restore"); + + _archiveService.Extract(backupFileName, temporaryPath); + + foreach (var file in _diskProvider.GetFiles(temporaryPath, SearchOption.TopDirectoryOnly)) + { + var fileName = Path.GetFileName(file); + + if (fileName.Equals("Config.xml", StringComparison.InvariantCultureIgnoreCase)) + { + _diskProvider.MoveFile(file, _appFolderInfo.GetConfigPath(), true); + restoredFile = true; + } + + if (fileName.Equals("lidarr.db", StringComparison.InvariantCultureIgnoreCase)) + { + _diskProvider.MoveFile(file, _appFolderInfo.GetDatabaseRestore(), true); + restoredFile = true; + } + } + + if (!restoredFile) + { + throw new RestoreBackupFailedException(HttpStatusCode.NotFound, "Unable to restore database file from backup"); + } + + _diskProvider.DeleteFolder(temporaryPath, true); + + return; + } + + _diskProvider.MoveFile(backupFileName, _appFolderInfo.GetDatabaseRestore(), true); + } + + public string GetBackupFolder() + { + var backupFolder = _configService.BackupFolder; + + if (Path.IsPathRooted(backupFolder)) + { + return backupFolder; + } + + return Path.Combine(_appFolderInfo.GetAppDataPath(), backupFolder); + } + + private string GetBackupFolder(BackupType backupType) + { + return Path.Combine(GetBackupFolder(), backupType.ToString().ToLower()); + } + private void Cleanup() { if (_diskProvider.FolderExists(_backupTempFolder)) @@ -128,16 +195,25 @@ namespace NzbDrone.Core.Backup _diskTransferService.TransferFile(configFile, tempConfigFile, TransferMode.Copy); } + private void CreateVersionInfo() + { + var builder = new StringBuilder(); + + builder.AppendLine(BuildInfo.Version.ToString()); + } + private void CleanupOldBackups(BackupType backupType) { - _logger.Debug("Cleaning up old backup files"); + var retention = _configService.BackupRetention; + + _logger.Debug("Cleaning up backup files older than {0} days", retention); var files = GetBackupFiles(GetBackupFolder(backupType)); foreach (var file in files) { var lastWriteTime = _diskProvider.FileGetLastWrite(file); - if (lastWriteTime.AddDays(28) < DateTime.UtcNow) + if (lastWriteTime.AddDays(retention) < DateTime.UtcNow) { _logger.Debug("Deleting old backup file: {0}", file); _diskProvider.DeleteFile(file); @@ -147,11 +223,6 @@ namespace NzbDrone.Core.Backup _logger.Debug("Finished cleaning up old backup files"); } - private string GetBackupFolder(BackupType backupType) - { - return Path.Combine(_appFolderInfo.GetBackupFolder(), backupType.ToString().ToLower()); - } - private IEnumerable GetBackupFiles(string path) { var files = _diskProvider.GetFiles(path, SearchOption.TopDirectoryOnly); diff --git a/src/NzbDrone.Core/Backup/RestoreBackupFailedException.cs b/src/NzbDrone.Core/Backup/RestoreBackupFailedException.cs new file mode 100644 index 000000000..3a06b1b1b --- /dev/null +++ b/src/NzbDrone.Core/Backup/RestoreBackupFailedException.cs @@ -0,0 +1,16 @@ +using System.Net; +using NzbDrone.Core.Exceptions; + +namespace NzbDrone.Core.Backup +{ + public class RestoreBackupFailedException : NzbDroneClientException + { + public RestoreBackupFailedException(HttpStatusCode statusCode, string message, params object[] args) : base(statusCode, message, args) + { + } + + public RestoreBackupFailedException(HttpStatusCode statusCode, string message) : base(statusCode, message) + { + } + } +} diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index c18898c38..91cf7bd20 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -4,7 +4,6 @@ using System.Globalization; using System.Linq; using NLog; using NzbDrone.Common.EnsureThat; -using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Messaging.Events; @@ -332,6 +331,12 @@ namespace NzbDrone.Core.Configuration public bool ProxyBypassLocalAddresses => GetValueBoolean("ProxyBypassLocalAddresses", true); + public string BackupFolder => GetValue("BackupFolder", "Backups"); + + public int BackupInterval => GetValueInt("BackupInterval", 7); + + public int BackupRetention => GetValueInt("BackupRetention", 28); + private string GetValue(string key) { return GetValue(key, string.Empty); diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index 2f3d03594..60327fa01 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -78,5 +78,11 @@ namespace NzbDrone.Core.Configuration string ProxyPassword { get; } string ProxyBypassFilter { get; } bool ProxyBypassLocalAddresses { get; } + + // Backups + string BackupFolder { get; } + int BackupInterval { get; } + int BackupRetention { get; } + } } diff --git a/src/NzbDrone.Core/Datastore/DatabaseRestorationService.cs b/src/NzbDrone.Core/Datastore/DatabaseRestorationService.cs new file mode 100644 index 000000000..4be69f5d0 --- /dev/null +++ b/src/NzbDrone.Core/Datastore/DatabaseRestorationService.cs @@ -0,0 +1,56 @@ +using System; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation; + +namespace NzbDrone.Core.Datastore +{ + public interface IRestoreDatabase + { + void Restore(); + } + + public class DatabaseRestorationService : IRestoreDatabase + { + private readonly IDiskProvider _diskProvider; + private readonly IAppFolderInfo _appFolderInfo; + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(DatabaseRestorationService)); + + public DatabaseRestorationService(IDiskProvider diskProvider, IAppFolderInfo appFolderInfo) + { + _diskProvider = diskProvider; + _appFolderInfo = appFolderInfo; + } + + public void Restore() + { + var dbRestorePath = _appFolderInfo.GetDatabaseRestore(); + + if (!_diskProvider.FileExists(dbRestorePath)) + { + return; + } + + try + { + Logger.Info("Restoring Database"); + + var dbPath = _appFolderInfo.GetDatabase(); + + _diskProvider.DeleteFile(dbPath + "-shm"); + _diskProvider.DeleteFile(dbPath + "-wal"); + _diskProvider.DeleteFile(dbPath + "-journal"); + _diskProvider.DeleteFile(dbPath); + + _diskProvider.MoveFile(dbRestorePath, dbPath); + } + catch (Exception e) + { + Logger.Error(e, "Failed to restore database"); + throw; + } + } + } +} diff --git a/src/NzbDrone.Core/Datastore/DbFactory.cs b/src/NzbDrone.Core/Datastore/DbFactory.cs index 5e572beec..62edab9a4 100644 --- a/src/NzbDrone.Core/Datastore/DbFactory.cs +++ b/src/NzbDrone.Core/Datastore/DbFactory.cs @@ -24,6 +24,7 @@ namespace NzbDrone.Core.Datastore private readonly IMigrationController _migrationController; private readonly IConnectionStringFactory _connectionStringFactory; private readonly IDiskProvider _diskProvider; + private readonly IRestoreDatabase _restoreDatabaseService; static DbFactory() { @@ -44,11 +45,13 @@ namespace NzbDrone.Core.Datastore public DbFactory(IMigrationController migrationController, IConnectionStringFactory connectionStringFactory, - IDiskProvider diskProvider) + IDiskProvider diskProvider, + IRestoreDatabase restoreDatabaseService) { _migrationController = migrationController; _connectionStringFactory = connectionStringFactory; _diskProvider = diskProvider; + _restoreDatabaseService = restoreDatabaseService; } public IDatabase Create(MigrationType migrationType = MigrationType.Main) @@ -59,18 +62,21 @@ namespace NzbDrone.Core.Datastore public IDatabase Create(MigrationContext migrationContext) { string connectionString; - - + switch (migrationContext.MigrationType) { case MigrationType.Main: { connectionString = _connectionStringFactory.MainDbConnectionString; + CreateMain(connectionString, migrationContext); + break; } case MigrationType.Log: { connectionString = _connectionStringFactory.LogDbConnectionString; + CreateLog(connectionString, migrationContext); + break; } default: @@ -79,60 +85,71 @@ namespace NzbDrone.Core.Datastore } } + var db = new Database(migrationContext.MigrationType.ToString(), () => + { + var dataMapper = new DataMapper(SQLiteFactory.Instance, connectionString) + { + SqlMode = SqlModes.Text, + }; + + return dataMapper; + }); + + if (db.Migration > 100) //Quick DB Migration Check. This should get rid of users on old DB format + { + throw new CorruptDatabaseException("Invalid DB, Please Delete and Restart Lidarr"); + } + + return db; + } + + private void CreateMain(string connectionString, MigrationContext migrationContext) + { + try { + _restoreDatabaseService.Restore(); _migrationController.Migrate(connectionString, migrationContext); } - catch (SQLiteException ex) + catch (SQLiteException e) { var fileName = _connectionStringFactory.GetDatabasePath(connectionString); - if (migrationContext.MigrationType == MigrationType.Log) + if (OsInfo.IsOsx) { - Logger.Error(ex, "Logging database is corrupt, attempting to recreate it automatically"); - - try - { - _diskProvider.DeleteFile(fileName + "-shm"); - _diskProvider.DeleteFile(fileName + "-wal"); - _diskProvider.DeleteFile(fileName + "-journal"); - _diskProvider.DeleteFile(fileName); - } - catch (Exception) - { - Logger.Error("Unable to recreate logging database automatically. It will need to be removed manually."); - } - - _migrationController.Migrate(connectionString, migrationContext); + throw new CorruptDatabaseException("Database file: {0} is corrupt, restore from backup if available. See: https://github.com/Sonarr/Sonarr/wiki/FAQ#i-use-sonarr-on-a-mac-and-it-suddenly-stopped-working-what-happened", e, fileName); } - else - { - if (OsInfo.IsOsx) - { - throw new CorruptDatabaseException("Database file: {0} is corrupt, restore from backup if available. See: https://github.com/Lidarr/Lidarr/wiki/FAQ#i-use-Lidarr-on-a-mac-and-it-suddenly-stopped-working-what-happened", ex, fileName); - } + throw new CorruptDatabaseException("Database file: {0} is corrupt, restore from backup if available. See: https://github.com/Sonarr/Sonarr/wiki/FAQ#i-am-getting-an-error-database-disk-image-is-malformed", e, fileName); + } + } - throw new CorruptDatabaseException("Database file: {0} is corrupt, restore from backup if available. See: https://github.com/Lidarr/Lidarr/wiki/FAQ#i-am-getting-an-error-database-disk-image-is-malformed", ex, fileName); - } + private void CreateLog(string connectionString, MigrationContext migrationContext) + { + try + { + _migrationController.Migrate(connectionString, migrationContext); } + catch (SQLiteException e) + { + var fileName = _connectionStringFactory.GetDatabasePath(connectionString); - var db = new Database(migrationContext.MigrationType.ToString(), () => - { - var dataMapper = new DataMapper(SQLiteFactory.Instance, connectionString) - { - SqlMode = SqlModes.Text, - }; + Logger.Error(e, "Logging database is corrupt, attempting to recreate it automatically"); - return dataMapper; - }); + try + { + _diskProvider.DeleteFile(fileName + "-shm"); + _diskProvider.DeleteFile(fileName + "-wal"); + _diskProvider.DeleteFile(fileName + "-journal"); + _diskProvider.DeleteFile(fileName); + } + catch (Exception) + { + Logger.Error("Unable to recreate logging database automatically. It will need to be removed manually."); + } - if (db.Migration > 100) //Quick DB Migration Check. This should get rid of users on old DB format - { - throw new CorruptDatabaseException("Invalid DB, Please Delete and Restart Lidarr"); + _migrationController.Migrate(connectionString, migrationContext); } - - return db; } } } diff --git a/src/NzbDrone.Core/Jobs/TaskManager.cs b/src/NzbDrone.Core/Jobs/TaskManager.cs index fbc63abbd..1fa1a8a76 100644 --- a/src/NzbDrone.Core/Jobs/TaskManager.cs +++ b/src/NzbDrone.Core/Jobs/TaskManager.cs @@ -65,7 +65,12 @@ namespace NzbDrone.Core.Jobs new ScheduledTask{ Interval = 6*60, TypeName = typeof(CheckHealthCommand).FullName}, new ScheduledTask{ Interval = 12*60, TypeName = typeof(RefreshArtistCommand).FullName}, new ScheduledTask{ Interval = 24*60, TypeName = typeof(HousekeepingCommand).FullName}, - new ScheduledTask{ Interval = 7*24*60, TypeName = typeof(BackupCommand).FullName}, + + new ScheduledTask + { + Interval = GetBackupInterval(), + TypeName = typeof(BackupCommand).FullName + }, new ScheduledTask { @@ -102,6 +107,13 @@ namespace NzbDrone.Core.Jobs } } + private int GetBackupInterval() + { + var interval = _configService.BackupInterval; + + return interval * 60 * 24; + } + private int GetRssSyncInterval() { var interval = _configService.RssSyncInterval; diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 92f3f7471..a2f382bcf 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -128,6 +128,7 @@ + @@ -163,6 +164,7 @@ + diff --git a/src/NzbDrone.Host/Router.cs b/src/NzbDrone.Host/Router.cs index 0a57952ab..93d785d6e 100644 --- a/src/NzbDrone.Host/Router.cs +++ b/src/NzbDrone.Host/Router.cs @@ -90,7 +90,5 @@ namespace NzbDrone.Host } } } - - } }