mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-17 21:26:13 -04:00
Compare commits
13 Commits
v4.0.8.222
...
v4.0.9.225
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e14ce022c | ||
|
|
a9b93dd9c6 | ||
|
|
50d7e8fed4 | ||
|
|
402db9128c | ||
|
|
846333ddf0 | ||
|
|
dde28cbd7e | ||
|
|
8ceb306bf1 | ||
|
|
8af4246ff9 | ||
|
|
a2e06e9e65 | ||
|
|
ae7b187e41 | ||
|
|
63b4998c8e | ||
|
|
45665886d6 | ||
|
|
860424ac22 |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -22,7 +22,7 @@ env:
|
||||
FRAMEWORK: net6.0
|
||||
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
||||
SONARR_MAJOR_VERSION: 4
|
||||
VERSION: 4.0.8
|
||||
VERSION: 4.0.9
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
|
||||
2
frontend/.vscode/settings.json
vendored
2
frontend/.vscode/settings.json
vendored
@@ -9,7 +9,7 @@
|
||||
|
||||
"editor.formatOnSave": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": true
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
|
||||
"typescript.preferences.quoteStyle": "single",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Icon, { IconProps } from 'Components/Icon';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import TooltipPosition from 'Helpers/Props/TooltipPosition';
|
||||
@@ -61,7 +61,7 @@ function QueueStatus(props: QueueStatusProps) {
|
||||
|
||||
// status === 'downloading'
|
||||
let iconName = icons.DOWNLOADING;
|
||||
let iconKind = kinds.DEFAULT;
|
||||
let iconKind: IconProps['kind'] = kinds.DEFAULT;
|
||||
let title = translate('Downloading');
|
||||
|
||||
if (status === 'paused') {
|
||||
|
||||
@@ -6,6 +6,7 @@ import AppSectionState, {
|
||||
PagedAppSectionState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import Language from 'Language/Language';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import DownloadClient from 'typings/DownloadClient';
|
||||
import ImportList from 'typings/ImportList';
|
||||
import ImportListExclusion from 'typings/ImportListExclusion';
|
||||
@@ -48,6 +49,11 @@ export interface QualityProfilesAppState
|
||||
extends AppSectionState<QualityProfile>,
|
||||
AppSectionItemSchemaState<QualityProfile> {}
|
||||
|
||||
export interface CustomFormatAppState
|
||||
extends AppSectionState<CustomFormat>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface ImportListOptionsSettingsAppState
|
||||
extends AppSectionItemState<ImportListOptionsSettings>,
|
||||
AppSectionSaveState {}
|
||||
@@ -66,6 +72,7 @@ export type UiSettingsAppState = AppSectionItemState<UiSettings>;
|
||||
|
||||
interface SettingsAppState {
|
||||
advancedSettings: boolean;
|
||||
customFormats: CustomFormatAppState;
|
||||
downloadClients: DownloadClientAppState;
|
||||
general: GeneralAppState;
|
||||
importListExclusions: ImportListExclusionsSettingsAppState;
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { PureComponent } from 'react';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import styles from './Icon.css';
|
||||
|
||||
class Icon extends PureComponent {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
containerClassName,
|
||||
className,
|
||||
name,
|
||||
kind,
|
||||
size,
|
||||
title,
|
||||
isSpinning,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const icon = (
|
||||
<FontAwesomeIcon
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind]
|
||||
)}
|
||||
icon={name}
|
||||
spin={isSpinning}
|
||||
style={{
|
||||
fontSize: `${size}px`
|
||||
}}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
|
||||
if (title) {
|
||||
return (
|
||||
<span
|
||||
className={containerClassName}
|
||||
title={typeof title === 'function' ? title() : title}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
|
||||
Icon.propTypes = {
|
||||
containerClassName: PropTypes.string,
|
||||
className: PropTypes.string,
|
||||
name: PropTypes.object.isRequired,
|
||||
kind: PropTypes.string.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
title: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
||||
isSpinning: PropTypes.bool.isRequired,
|
||||
fixedWidth: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
Icon.defaultProps = {
|
||||
kind: kinds.DEFAULT,
|
||||
size: 14,
|
||||
isSpinning: false,
|
||||
fixedWidth: false
|
||||
};
|
||||
|
||||
export default Icon;
|
||||
59
frontend/src/Components/Icon.tsx
Normal file
59
frontend/src/Components/Icon.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconProps,
|
||||
} from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
import React, { ComponentProps } from 'react';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import styles from './Icon.css';
|
||||
|
||||
export interface IconProps
|
||||
extends Omit<
|
||||
FontAwesomeIconProps,
|
||||
'icon' | 'spin' | 'name' | 'title' | 'size'
|
||||
> {
|
||||
containerClassName?: ComponentProps<'span'>['className'];
|
||||
name: FontAwesomeIconProps['icon'];
|
||||
kind?: Extract<(typeof kinds.all)[number], keyof typeof styles>;
|
||||
size?: number;
|
||||
isSpinning?: FontAwesomeIconProps['spin'];
|
||||
title?: string | (() => string);
|
||||
}
|
||||
|
||||
export default function Icon({
|
||||
containerClassName,
|
||||
className,
|
||||
name,
|
||||
kind = kinds.DEFAULT,
|
||||
size = 14,
|
||||
title,
|
||||
isSpinning = false,
|
||||
fixedWidth = false,
|
||||
...otherProps
|
||||
}: IconProps) {
|
||||
const icon = (
|
||||
<FontAwesomeIcon
|
||||
className={classNames(className, styles[kind])}
|
||||
icon={name}
|
||||
spin={isSpinning}
|
||||
fixedWidth={fixedWidth}
|
||||
style={{
|
||||
fontSize: `${size}px`,
|
||||
}}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
|
||||
if (title) {
|
||||
return (
|
||||
<span
|
||||
className={containerClassName}
|
||||
title={typeof title === 'function' ? title() : title}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return icon;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { align, kinds, sizes } from 'Helpers/Props';
|
||||
import Link from './Link';
|
||||
import styles from './Button.css';
|
||||
|
||||
class Button extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
buttonGroupPosition,
|
||||
kind,
|
||||
size,
|
||||
children,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind],
|
||||
styles[size],
|
||||
buttonGroupPosition && styles[buttonGroupPosition]
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Button.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
buttonGroupPosition: PropTypes.oneOf(align.all),
|
||||
kind: PropTypes.oneOf(kinds.all),
|
||||
size: PropTypes.oneOf(sizes.all),
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
Button.defaultProps = {
|
||||
className: styles.button,
|
||||
kind: kinds.DEFAULT,
|
||||
size: sizes.MEDIUM
|
||||
};
|
||||
|
||||
export default Button;
|
||||
35
frontend/src/Components/Link/Button.tsx
Normal file
35
frontend/src/Components/Link/Button.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { align, kinds, sizes } from 'Helpers/Props';
|
||||
import Link, { LinkProps } from './Link';
|
||||
import styles from './Button.css';
|
||||
|
||||
export interface ButtonProps extends Omit<LinkProps, 'children' | 'size'> {
|
||||
buttonGroupPosition?: Extract<
|
||||
(typeof align.all)[number],
|
||||
keyof typeof styles
|
||||
>;
|
||||
kind?: Extract<(typeof kinds.all)[number], keyof typeof styles>;
|
||||
size?: Extract<(typeof sizes.all)[number], keyof typeof styles>;
|
||||
children: Required<LinkProps['children']>;
|
||||
}
|
||||
|
||||
export default function Button({
|
||||
className = styles.button,
|
||||
buttonGroupPosition,
|
||||
kind = kinds.DEFAULT,
|
||||
size = sizes.MEDIUM,
|
||||
...otherProps
|
||||
}: ButtonProps) {
|
||||
return (
|
||||
<Link
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind],
|
||||
styles[size],
|
||||
buttonGroupPosition && styles[buttonGroupPosition]
|
||||
)}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,96 +1,89 @@
|
||||
import classNames from 'classnames';
|
||||
import React, {
|
||||
ComponentClass,
|
||||
FunctionComponent,
|
||||
ComponentPropsWithoutRef,
|
||||
ElementType,
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import styles from './Link.css';
|
||||
|
||||
interface ReactRouterLinkProps {
|
||||
to?: string;
|
||||
}
|
||||
export type LinkProps<C extends ElementType = 'button'> =
|
||||
ComponentPropsWithoutRef<C> & {
|
||||
component?: C;
|
||||
to?: string;
|
||||
target?: string;
|
||||
isDisabled?: LinkProps<C>['disabled'];
|
||||
noRouter?: boolean;
|
||||
onPress?(event: SyntheticEvent): void;
|
||||
};
|
||||
|
||||
export interface LinkProps extends React.HTMLProps<HTMLAnchorElement> {
|
||||
className?: string;
|
||||
component?:
|
||||
| string
|
||||
| FunctionComponent<LinkProps>
|
||||
| ComponentClass<LinkProps, unknown>;
|
||||
to?: string;
|
||||
target?: string;
|
||||
isDisabled?: boolean;
|
||||
noRouter?: boolean;
|
||||
onPress?(event: SyntheticEvent): void;
|
||||
}
|
||||
function Link(props: LinkProps) {
|
||||
const {
|
||||
className,
|
||||
component = 'button',
|
||||
to,
|
||||
target,
|
||||
type,
|
||||
isDisabled,
|
||||
noRouter = false,
|
||||
onPress,
|
||||
...otherProps
|
||||
} = props;
|
||||
export default function Link<C extends ElementType = 'button'>({
|
||||
className,
|
||||
component,
|
||||
to,
|
||||
target,
|
||||
type,
|
||||
isDisabled,
|
||||
noRouter,
|
||||
onPress,
|
||||
...otherProps
|
||||
}: LinkProps<C>) {
|
||||
const Component = component || 'button';
|
||||
|
||||
const onClick = useCallback(
|
||||
(event: SyntheticEvent) => {
|
||||
if (!isDisabled && onPress) {
|
||||
onPress(event);
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
onPress?.(event);
|
||||
},
|
||||
[isDisabled, onPress]
|
||||
);
|
||||
|
||||
const linkProps: React.HTMLProps<HTMLAnchorElement> & ReactRouterLinkProps = {
|
||||
target,
|
||||
};
|
||||
let el = component;
|
||||
|
||||
if (to) {
|
||||
if (/\w+?:\/\//.test(to)) {
|
||||
el = 'a';
|
||||
linkProps.href = to;
|
||||
linkProps.target = target || '_blank';
|
||||
linkProps.rel = 'noreferrer';
|
||||
} else if (noRouter) {
|
||||
el = 'a';
|
||||
linkProps.href = to;
|
||||
linkProps.target = target || '_self';
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
el = RouterLink;
|
||||
linkProps.to = `${window.Sonarr.urlBase}/${to.replace(/^\//, '')}`;
|
||||
linkProps.target = target;
|
||||
}
|
||||
}
|
||||
|
||||
if (el === 'button' || el === 'input') {
|
||||
linkProps.type = type || 'button';
|
||||
linkProps.disabled = isDisabled;
|
||||
}
|
||||
|
||||
linkProps.className = classNames(
|
||||
const linkClass = classNames(
|
||||
className,
|
||||
styles.link,
|
||||
to && styles.to,
|
||||
isDisabled && 'isDisabled'
|
||||
);
|
||||
|
||||
const elementProps = {
|
||||
...otherProps,
|
||||
type,
|
||||
...linkProps,
|
||||
};
|
||||
if (to) {
|
||||
const toLink = /\w+?:\/\//.test(to);
|
||||
|
||||
elementProps.onClick = onClick;
|
||||
if (toLink || noRouter) {
|
||||
return (
|
||||
<a
|
||||
href={to}
|
||||
target={target || (toLink ? '_blank' : '_self')}
|
||||
rel={toLink ? 'noreferrer' : undefined}
|
||||
className={linkClass}
|
||||
onClick={onClick}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return React.createElement(el, elementProps);
|
||||
return (
|
||||
<RouterLink
|
||||
to={`${window.Sonarr.urlBase}/${to.replace(/^\//, '')}`}
|
||||
target={target}
|
||||
className={linkClass}
|
||||
onClick={onClick}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Component
|
||||
type={type || 'button'}
|
||||
target={target}
|
||||
className={linkClass}
|
||||
disabled={isDisabled}
|
||||
onClick={onClick}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Link;
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.isDisabled {
|
||||
color: var(--disabledColor);
|
||||
cursor: not-allowed;
|
||||
&.isDisabled {
|
||||
color: var(--disabledColor);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import Link from 'Components/Link/Link';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
|
||||
import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector';
|
||||
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
|
||||
import SeriesSearchInputConnector from './SeriesSearchInputConnector';
|
||||
import styles from './PageHeader.css';
|
||||
|
||||
@@ -83,7 +83,8 @@ class PageHeader extends Component {
|
||||
size={14}
|
||||
title={translate('Donate')}
|
||||
/>
|
||||
<PageHeaderActionsMenuConnector
|
||||
|
||||
<PageHeaderActionsMenu
|
||||
onKeyboardShortcutsPress={this.onOpenKeyboardShortcutsModal}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Menu from 'Components/Menu/Menu';
|
||||
import MenuButton from 'Components/Menu/MenuButton';
|
||||
import MenuContent from 'Components/Menu/MenuContent';
|
||||
import MenuItem from 'Components/Menu/MenuItem';
|
||||
import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './PageHeaderActionsMenu.css';
|
||||
|
||||
function PageHeaderActionsMenu(props) {
|
||||
const {
|
||||
formsAuth,
|
||||
onKeyboardShortcutsPress,
|
||||
onRestartPress,
|
||||
onShutdownPress
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Menu alignMenu={align.RIGHT}>
|
||||
<MenuButton className={styles.menuButton} aria-label="Menu Button">
|
||||
<Icon
|
||||
name={icons.INTERACTIVE}
|
||||
title={translate('Menu')}
|
||||
/>
|
||||
</MenuButton>
|
||||
|
||||
<MenuContent>
|
||||
<MenuItem onPress={onKeyboardShortcutsPress}>
|
||||
<Icon
|
||||
className={styles.itemIcon}
|
||||
name={icons.KEYBOARD}
|
||||
/>
|
||||
{translate('KeyboardShortcuts')}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItemSeparator />
|
||||
|
||||
<MenuItem onPress={onRestartPress}>
|
||||
<Icon
|
||||
className={styles.itemIcon}
|
||||
name={icons.RESTART}
|
||||
/>
|
||||
{translate('Restart')}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onPress={onShutdownPress}>
|
||||
<Icon
|
||||
className={styles.itemIcon}
|
||||
name={icons.SHUTDOWN}
|
||||
kind={kinds.DANGER}
|
||||
/>
|
||||
{translate('Shutdown')}
|
||||
</MenuItem>
|
||||
|
||||
{
|
||||
formsAuth &&
|
||||
<div className={styles.separator} />
|
||||
}
|
||||
|
||||
{
|
||||
formsAuth &&
|
||||
<MenuItem
|
||||
to={`${window.Sonarr.urlBase}/logout`}
|
||||
noRouter={true}
|
||||
>
|
||||
<Icon
|
||||
className={styles.itemIcon}
|
||||
name={icons.LOGOUT}
|
||||
/>
|
||||
{translate('Logout')}
|
||||
</MenuItem>
|
||||
}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PageHeaderActionsMenu.propTypes = {
|
||||
formsAuth: PropTypes.bool.isRequired,
|
||||
onKeyboardShortcutsPress: PropTypes.func.isRequired,
|
||||
onRestartPress: PropTypes.func.isRequired,
|
||||
onShutdownPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default PageHeaderActionsMenu;
|
||||
@@ -0,0 +1,87 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Icon from 'Components/Icon';
|
||||
import Menu from 'Components/Menu/Menu';
|
||||
import MenuButton from 'Components/Menu/MenuButton';
|
||||
import MenuContent from 'Components/Menu/MenuContent';
|
||||
import MenuItem from 'Components/Menu/MenuItem';
|
||||
import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import { restart, shutdown } from 'Store/Actions/systemActions';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './PageHeaderActionsMenu.css';
|
||||
|
||||
interface PageHeaderActionsMenuProps {
|
||||
onKeyboardShortcutsPress(): void;
|
||||
}
|
||||
|
||||
function PageHeaderActionsMenu(props: PageHeaderActionsMenuProps) {
|
||||
const { onKeyboardShortcutsPress } = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { authentication, isDocker } = useSelector(
|
||||
(state: AppState) => state.system.status.item
|
||||
);
|
||||
|
||||
const formsAuth = authentication === 'forms';
|
||||
|
||||
const handleRestartPress = useCallback(() => {
|
||||
dispatch(restart());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleShutdownPress = useCallback(() => {
|
||||
dispatch(shutdown());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Menu alignMenu={align.RIGHT}>
|
||||
<MenuButton className={styles.menuButton} aria-label="Menu Button">
|
||||
<Icon name={icons.INTERACTIVE} title={translate('Menu')} />
|
||||
</MenuButton>
|
||||
|
||||
<MenuContent>
|
||||
<MenuItem onPress={onKeyboardShortcutsPress}>
|
||||
<Icon className={styles.itemIcon} name={icons.KEYBOARD} />
|
||||
{translate('KeyboardShortcuts')}
|
||||
</MenuItem>
|
||||
|
||||
{isDocker ? null : (
|
||||
<>
|
||||
<MenuItemSeparator />
|
||||
|
||||
<MenuItem onPress={handleRestartPress}>
|
||||
<Icon className={styles.itemIcon} name={icons.RESTART} />
|
||||
{translate('Restart')}
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onPress={handleShutdownPress}>
|
||||
<Icon
|
||||
className={styles.itemIcon}
|
||||
name={icons.SHUTDOWN}
|
||||
kind={kinds.DANGER}
|
||||
/>
|
||||
{translate('Shutdown')}
|
||||
</MenuItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
{formsAuth ? (
|
||||
<>
|
||||
<MenuItemSeparator />
|
||||
|
||||
<MenuItem to={`${window.Sonarr.urlBase}/logout`} noRouter={true}>
|
||||
<Icon className={styles.itemIcon} name={icons.LOGOUT} />
|
||||
{translate('Logout')}
|
||||
</MenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageHeaderActionsMenu;
|
||||
@@ -1,56 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { restart, shutdown } from 'Store/Actions/systemActions';
|
||||
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.system.status,
|
||||
(status) => {
|
||||
return {
|
||||
formsAuth: status.item.authentication === 'forms'
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
restart,
|
||||
shutdown
|
||||
};
|
||||
|
||||
class PageHeaderActionsMenuConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onRestartPress = () => {
|
||||
this.props.restart();
|
||||
};
|
||||
|
||||
onShutdownPress = () => {
|
||||
this.props.shutdown();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<PageHeaderActionsMenu
|
||||
{...this.props}
|
||||
onRestartPress={this.onRestartPress}
|
||||
onShutdownPress={this.onShutdownPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PageHeaderActionsMenuConnector.propTypes = {
|
||||
restart: PropTypes.func.isRequired,
|
||||
shutdown: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(PageHeaderActionsMenuConnector);
|
||||
@@ -525,7 +525,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
<>
|
||||
{indexerFlags ? (
|
||||
<Popover
|
||||
anchor={<Icon name={icons.FLAG} kind={kinds.PRIMARY} />}
|
||||
anchor={<Icon name={icons.FLAG} />}
|
||||
title={translate('IndexerFlags')}
|
||||
body={<IndexerFlags indexerFlags={indexerFlags} />}
|
||||
position={tooltipPositions.LEFT}
|
||||
|
||||
@@ -264,7 +264,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
|
||||
<TableRowCell className={styles.indexerFlags}>
|
||||
{indexerFlags ? (
|
||||
<Popover
|
||||
anchor={<Icon name={icons.FLAG} kind={kinds.PRIMARY} />}
|
||||
anchor={<Icon name={icons.FLAG} />}
|
||||
title={translate('IndexerFlags')}
|
||||
body={<IndexerFlags indexerFlags={indexerFlags} />}
|
||||
position={tooltipPositions.LEFT}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { IconDefinition } from '@fortawesome/free-regular-svg-icons';
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Icon, { IconProps } from 'Components/Icon';
|
||||
import styles from './SeriesIndexOverviewInfoRow.css';
|
||||
|
||||
interface SeriesIndexOverviewInfoRowProps {
|
||||
title?: string;
|
||||
iconName?: IconDefinition;
|
||||
iconName: IconProps['name'];
|
||||
label: string | null;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import ParseToolbarButton from 'Parse/ParseToolbarButton';
|
||||
import SettingsToolbarConnector from 'Settings/SettingsToolbarConnector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import CustomFormatsConnector from './CustomFormats/CustomFormatsConnector';
|
||||
import ManageCustomFormatsToolbarButton from './CustomFormats/Manage/ManageCustomFormatsToolbarButton';
|
||||
|
||||
function CustomFormatSettingsPage() {
|
||||
return (
|
||||
@@ -21,6 +22,8 @@ function CustomFormatSettingsPage() {
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<ParseToolbarButton />
|
||||
|
||||
<ManageCustomFormatsToolbarButton />
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ManageCustomFormatsEditModalContent from './ManageCustomFormatsEditModalContent';
|
||||
|
||||
interface ManageCustomFormatsEditModalProps {
|
||||
isOpen: boolean;
|
||||
customFormatIds: number[];
|
||||
onSavePress(payload: object): void;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function ManageCustomFormatsEditModal(
|
||||
props: ManageCustomFormatsEditModalProps
|
||||
) {
|
||||
const { isOpen, customFormatIds, onSavePress, onModalClose } = props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<ManageCustomFormatsEditModalContent
|
||||
customFormatIds={customFormatIds}
|
||||
onSavePress={onSavePress}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManageCustomFormatsEditModal;
|
||||
@@ -0,0 +1,16 @@
|
||||
.modalFooter {
|
||||
composes: modalFooter from '~Components/Modal/ModalFooter.css';
|
||||
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.selected {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointExtraSmall) {
|
||||
.modalFooter {
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'modalFooter': string;
|
||||
'selected': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -0,0 +1,125 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Button from 'Components/Link/Button';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ManageCustomFormatsEditModalContent.css';
|
||||
|
||||
interface SavePayload {
|
||||
includeCustomFormatWhenRenaming?: boolean;
|
||||
}
|
||||
|
||||
interface ManageCustomFormatsEditModalContentProps {
|
||||
customFormatIds: number[];
|
||||
onSavePress(payload: object): void;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
const NO_CHANGE = 'noChange';
|
||||
|
||||
const enableOptions = [
|
||||
{
|
||||
key: NO_CHANGE,
|
||||
get value() {
|
||||
return translate('NoChange');
|
||||
},
|
||||
isDisabled: true,
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
get value() {
|
||||
return translate('Enabled');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'disabled',
|
||||
get value() {
|
||||
return translate('Disabled');
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function ManageCustomFormatsEditModalContent(
|
||||
props: ManageCustomFormatsEditModalContentProps
|
||||
) {
|
||||
const { customFormatIds, onSavePress, onModalClose } = props;
|
||||
|
||||
const [includeCustomFormatWhenRenaming, setIncludeCustomFormatWhenRenaming] =
|
||||
useState(NO_CHANGE);
|
||||
|
||||
const save = useCallback(() => {
|
||||
let hasChanges = false;
|
||||
const payload: SavePayload = {};
|
||||
|
||||
if (includeCustomFormatWhenRenaming !== NO_CHANGE) {
|
||||
hasChanges = true;
|
||||
payload.includeCustomFormatWhenRenaming =
|
||||
includeCustomFormatWhenRenaming === 'enabled';
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
onSavePress(payload);
|
||||
}
|
||||
|
||||
onModalClose();
|
||||
}, [includeCustomFormatWhenRenaming, onSavePress, onModalClose]);
|
||||
|
||||
const onInputChange = useCallback(
|
||||
({ name, value }: { name: string; value: string }) => {
|
||||
switch (name) {
|
||||
case 'includeCustomFormatWhenRenaming':
|
||||
setIncludeCustomFormatWhenRenaming(value);
|
||||
break;
|
||||
default:
|
||||
console.warn(
|
||||
`EditCustomFormatsModalContent Unknown Input: '${name}'`
|
||||
);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const selectedCount = customFormatIds.length;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('EditSelectedCustomFormats')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IncludeCustomFormatWhenRenaming')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="includeCustomFormatWhenRenaming"
|
||||
value={includeCustomFormatWhenRenaming}
|
||||
values={enableOptions}
|
||||
onChange={onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter className={styles.modalFooter}>
|
||||
<div className={styles.selected}>
|
||||
{translate('CountCustomFormatsSelected', {
|
||||
count: selectedCount,
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<Button onPress={save}>{translate('ApplyChanges')}</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManageCustomFormatsEditModalContent;
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ManageCustomFormatsModalContent from './ManageCustomFormatsModalContent';
|
||||
|
||||
interface ManageCustomFormatsModalProps {
|
||||
isOpen: boolean;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function ManageCustomFormatsModal(props: ManageCustomFormatsModalProps) {
|
||||
const { isOpen, onModalClose } = props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<ManageCustomFormatsModalContent onModalClose={onModalClose} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManageCustomFormatsModal;
|
||||
@@ -0,0 +1,16 @@
|
||||
.leftButtons,
|
||||
.rightButtons {
|
||||
display: flex;
|
||||
flex: 1 0 50%;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rightButtons {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.deleteButton {
|
||||
composes: button from '~Components/Link/Button.css';
|
||||
|
||||
margin-right: 10px;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'deleteButton': string;
|
||||
'leftButtons': string;
|
||||
'rightButtons': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -0,0 +1,241 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { CustomFormatAppState } from 'App/State/SettingsAppState';
|
||||
import Alert from 'Components/Alert';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import useSelectState from 'Helpers/Hooks/useSelectState';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import {
|
||||
bulkDeleteCustomFormats,
|
||||
bulkEditCustomFormats,
|
||||
setManageCustomFormatsSort,
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import getSelectedIds from 'Utilities/Table/getSelectedIds';
|
||||
import ManageCustomFormatsEditModal from './Edit/ManageCustomFormatsEditModal';
|
||||
import ManageCustomFormatsModalRow from './ManageCustomFormatsModalRow';
|
||||
import styles from './ManageCustomFormatsModalContent.css';
|
||||
|
||||
// TODO: This feels janky to do, but not sure of a better way currently
|
||||
type OnSelectedChangeCallback = React.ComponentProps<
|
||||
typeof ManageCustomFormatsModalRow
|
||||
>['onSelectedChange'];
|
||||
|
||||
const COLUMNS = [
|
||||
{
|
||||
name: 'name',
|
||||
label: () => translate('Name'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'includeCustomFormatWhenRenaming',
|
||||
label: () => translate('IncludeCustomFormatWhenRenaming'),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
];
|
||||
|
||||
interface ManageCustomFormatsModalContentProps {
|
||||
onModalClose(): void;
|
||||
sortKey?: string;
|
||||
sortDirection?: SortDirection;
|
||||
}
|
||||
|
||||
function ManageCustomFormatsModalContent(
|
||||
props: ManageCustomFormatsModalContentProps
|
||||
) {
|
||||
const { onModalClose } = props;
|
||||
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
isDeleting,
|
||||
isSaving,
|
||||
error,
|
||||
items,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
}: CustomFormatAppState = useSelector(
|
||||
createClientSideCollectionSelector('settings.customFormats')
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
|
||||
const [selectState, setSelectState] = useSelectState();
|
||||
|
||||
const { allSelected, allUnselected, selectedState } = selectState;
|
||||
|
||||
const selectedIds: number[] = useMemo(() => {
|
||||
return getSelectedIds(selectedState);
|
||||
}, [selectedState]);
|
||||
|
||||
const selectedCount = selectedIds.length;
|
||||
|
||||
const onSortPress = useCallback(
|
||||
(value: string) => {
|
||||
dispatch(setManageCustomFormatsSort({ sortKey: value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const onDeletePress = useCallback(() => {
|
||||
setIsDeleteModalOpen(true);
|
||||
}, [setIsDeleteModalOpen]);
|
||||
|
||||
const onDeleteModalClose = useCallback(() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
}, [setIsDeleteModalOpen]);
|
||||
|
||||
const onEditPress = useCallback(() => {
|
||||
setIsEditModalOpen(true);
|
||||
}, [setIsEditModalOpen]);
|
||||
|
||||
const onEditModalClose = useCallback(() => {
|
||||
setIsEditModalOpen(false);
|
||||
}, [setIsEditModalOpen]);
|
||||
|
||||
const onConfirmDelete = useCallback(() => {
|
||||
dispatch(bulkDeleteCustomFormats({ ids: selectedIds }));
|
||||
setIsDeleteModalOpen(false);
|
||||
}, [selectedIds, dispatch]);
|
||||
|
||||
const onSavePress = useCallback(
|
||||
(payload: object) => {
|
||||
setIsEditModalOpen(false);
|
||||
|
||||
dispatch(
|
||||
bulkEditCustomFormats({
|
||||
ids: selectedIds,
|
||||
...payload,
|
||||
})
|
||||
);
|
||||
},
|
||||
[selectedIds, dispatch]
|
||||
);
|
||||
|
||||
const onSelectAllChange = useCallback(
|
||||
({ value }: SelectStateInputProps) => {
|
||||
setSelectState({ type: value ? 'selectAll' : 'unselectAll', items });
|
||||
},
|
||||
[items, setSelectState]
|
||||
);
|
||||
|
||||
const onSelectedChange = useCallback<OnSelectedChangeCallback>(
|
||||
({ id, value, shiftKey = false }) => {
|
||||
setSelectState({
|
||||
type: 'toggleSelected',
|
||||
items,
|
||||
id,
|
||||
isSelected: value,
|
||||
shiftKey,
|
||||
});
|
||||
},
|
||||
[items, setSelectState]
|
||||
);
|
||||
|
||||
const errorMessage = getErrorMessage(error, 'Unable to load custom formats.');
|
||||
const anySelected = selectedCount > 0;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('ManageCustomFormats')}</ModalHeader>
|
||||
<ModalBody>
|
||||
{isFetching ? <LoadingIndicator /> : null}
|
||||
|
||||
{error ? <div>{errorMessage}</div> : null}
|
||||
|
||||
{isPopulated && !error && !items.length ? (
|
||||
<Alert kind={kinds.INFO}>{translate('NoCustomFormatsFound')}</Alert>
|
||||
) : null}
|
||||
|
||||
{isPopulated && !!items.length && !isFetching && !isFetching ? (
|
||||
<Table
|
||||
columns={COLUMNS}
|
||||
horizontalScroll={true}
|
||||
selectAll={true}
|
||||
allSelected={allSelected}
|
||||
allUnselected={allUnselected}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onSelectAllChange={onSelectAllChange}
|
||||
onSortPress={onSortPress}
|
||||
>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<ManageCustomFormatsModalRow
|
||||
key={item.id}
|
||||
isSelected={selectedState[item.id]}
|
||||
{...item}
|
||||
columns={COLUMNS}
|
||||
onSelectedChange={onSelectedChange}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<div className={styles.leftButtons}>
|
||||
<SpinnerButton
|
||||
kind={kinds.DANGER}
|
||||
isSpinning={isDeleting}
|
||||
isDisabled={!anySelected}
|
||||
onPress={onDeletePress}
|
||||
>
|
||||
{translate('Delete')}
|
||||
</SpinnerButton>
|
||||
|
||||
<SpinnerButton
|
||||
isSpinning={isSaving}
|
||||
isDisabled={!anySelected}
|
||||
onPress={onEditPress}
|
||||
>
|
||||
{translate('Edit')}
|
||||
</SpinnerButton>
|
||||
</div>
|
||||
|
||||
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||
</ModalFooter>
|
||||
|
||||
<ManageCustomFormatsEditModal
|
||||
isOpen={isEditModalOpen}
|
||||
customFormatIds={selectedIds}
|
||||
onModalClose={onEditModalClose}
|
||||
onSavePress={onSavePress}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isDeleteModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('DeleteSelectedCustomFormats')}
|
||||
message={translate('DeleteSelectedCustomFormatsMessageText', {
|
||||
count: selectedIds.length,
|
||||
})}
|
||||
confirmLabel={translate('Delete')}
|
||||
onConfirm={onConfirmDelete}
|
||||
onCancel={onDeleteModalClose}
|
||||
/>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManageCustomFormatsModalContent;
|
||||
@@ -0,0 +1,6 @@
|
||||
.name,
|
||||
.includeCustomFormatWhenRenaming {
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
word-break: break-all;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'includeCustomFormatWhenRenaming': string;
|
||||
'name': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
@@ -0,0 +1,54 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
|
||||
import Column from 'Components/Table/Column';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ManageCustomFormatsModalRow.css';
|
||||
|
||||
interface ManageCustomFormatsModalRowProps {
|
||||
id: number;
|
||||
name: string;
|
||||
includeCustomFormatWhenRenaming: boolean;
|
||||
columns: Column[];
|
||||
isSelected?: boolean;
|
||||
onSelectedChange(result: SelectStateInputProps): void;
|
||||
}
|
||||
|
||||
function ManageCustomFormatsModalRow(props: ManageCustomFormatsModalRowProps) {
|
||||
const {
|
||||
id,
|
||||
isSelected,
|
||||
name,
|
||||
includeCustomFormatWhenRenaming,
|
||||
onSelectedChange,
|
||||
} = props;
|
||||
|
||||
const onSelectedChangeWrapper = useCallback(
|
||||
(result: SelectStateInputProps) => {
|
||||
onSelectedChange({
|
||||
...result,
|
||||
});
|
||||
},
|
||||
[onSelectedChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableSelectCell
|
||||
id={id}
|
||||
isSelected={isSelected}
|
||||
onSelectedChange={onSelectedChangeWrapper}
|
||||
/>
|
||||
|
||||
<TableRowCell className={styles.name}>{name}</TableRowCell>
|
||||
|
||||
<TableRowCell className={styles.includeCustomFormatWhenRenaming}>
|
||||
{includeCustomFormatWhenRenaming ? translate('Yes') : translate('No')}
|
||||
</TableRowCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManageCustomFormatsModalRow;
|
||||
@@ -0,0 +1,28 @@
|
||||
import React from 'react';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import ManageCustomFormatsModal from './ManageCustomFormatsModal';
|
||||
|
||||
function ManageCustomFormatsToolbarButton() {
|
||||
const [isManageModalOpen, openManageModal, closeManageModal] =
|
||||
useModalOpenState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageToolbarButton
|
||||
label={translate('ManageCustomFormats')}
|
||||
iconName={icons.MANAGE}
|
||||
onPress={openManageModal}
|
||||
/>
|
||||
|
||||
<ManageCustomFormatsModal
|
||||
isOpen={isManageModalOpen}
|
||||
onModalClose={closeManageModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ManageCustomFormatsToolbarButton;
|
||||
@@ -13,4 +13,4 @@
|
||||
composes: button from '~Components/Link/Button.css';
|
||||
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,9 +220,9 @@ function ManageDownloadClientsModalContent(
|
||||
|
||||
{error ? <div>{errorMessage}</div> : null}
|
||||
|
||||
{isPopulated && !error && !items.length && (
|
||||
{isPopulated && !error && !items.length ? (
|
||||
<Alert kind={kinds.INFO}>{translate('NoDownloadClientsFound')}</Alert>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{isPopulated && !!items.length && !isFetching && !isFetching ? (
|
||||
<Table
|
||||
|
||||
@@ -13,4 +13,4 @@
|
||||
composes: button from '~Components/Link/Button.css';
|
||||
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,9 +198,9 @@ function ManageImportListsModalContent(
|
||||
|
||||
{error ? <div>{errorMessage}</div> : null}
|
||||
|
||||
{isPopulated && !error && !items.length && (
|
||||
{isPopulated && !error && !items.length ? (
|
||||
<Alert kind={kinds.INFO}>{translate('NoImportListsFound')}</Alert>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{isPopulated && !!items.length && !isFetching && !isFetching ? (
|
||||
<Table
|
||||
|
||||
@@ -13,4 +13,4 @@
|
||||
composes: button from '~Components/Link/Button.css';
|
||||
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,9 +215,9 @@ function ManageIndexersModalContent(props: ManageIndexersModalContentProps) {
|
||||
|
||||
{error ? <div>{errorMessage}</div> : null}
|
||||
|
||||
{isPopulated && !error && !items.length && (
|
||||
{isPopulated && !error && !items.length ? (
|
||||
<Alert kind={kinds.INFO}>{translate('NoIndexersFound')}</Alert>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{isPopulated && !!items.length && !isFetching && !isFetching ? (
|
||||
<Table
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createAction } from 'redux-actions';
|
||||
import { sortDirections } from 'Helpers/Props';
|
||||
import createBulkEditItemHandler from 'Store/Actions/Creators/createBulkEditItemHandler';
|
||||
import createBulkRemoveItemHandler from 'Store/Actions/Creators/createBulkRemoveItemHandler';
|
||||
import createFetchHandler from 'Store/Actions/Creators/createFetchHandler';
|
||||
import createRemoveItemHandler from 'Store/Actions/Creators/createRemoveItemHandler';
|
||||
import createSaveProviderHandler from 'Store/Actions/Creators/createSaveProviderHandler';
|
||||
import createSetClientSideCollectionSortReducer
|
||||
from 'Store/Actions/Creators/Reducers/createSetClientSideCollectionSortReducer';
|
||||
import createSetSettingValueReducer from 'Store/Actions/Creators/Reducers/createSetSettingValueReducer';
|
||||
import { createThunk } from 'Store/thunks';
|
||||
import getSectionState from 'Utilities/State/getSectionState';
|
||||
@@ -22,6 +27,9 @@ export const SAVE_CUSTOM_FORMAT = 'settings/customFormats/saveCustomFormat';
|
||||
export const DELETE_CUSTOM_FORMAT = 'settings/customFormats/deleteCustomFormat';
|
||||
export const SET_CUSTOM_FORMAT_VALUE = 'settings/customFormats/setCustomFormatValue';
|
||||
export const CLONE_CUSTOM_FORMAT = 'settings/customFormats/cloneCustomFormat';
|
||||
export const BULK_EDIT_CUSTOM_FORMATS = 'settings/downloadClients/bulkEditCustomFormats';
|
||||
export const BULK_DELETE_CUSTOM_FORMATS = 'settings/downloadClients/bulkDeleteCustomFormats';
|
||||
export const SET_MANAGE_CUSTOM_FORMATS_SORT = 'settings/downloadClients/setManageCustomFormatsSort';
|
||||
|
||||
//
|
||||
// Action Creators
|
||||
@@ -29,6 +37,9 @@ export const CLONE_CUSTOM_FORMAT = 'settings/customFormats/cloneCustomFormat';
|
||||
export const fetchCustomFormats = createThunk(FETCH_CUSTOM_FORMATS);
|
||||
export const saveCustomFormat = createThunk(SAVE_CUSTOM_FORMAT);
|
||||
export const deleteCustomFormat = createThunk(DELETE_CUSTOM_FORMAT);
|
||||
export const bulkEditCustomFormats = createThunk(BULK_EDIT_CUSTOM_FORMATS);
|
||||
export const bulkDeleteCustomFormats = createThunk(BULK_DELETE_CUSTOM_FORMATS);
|
||||
export const setManageCustomFormatsSort = createAction(SET_MANAGE_CUSTOM_FORMATS_SORT);
|
||||
|
||||
export const setCustomFormatValue = createAction(SET_CUSTOM_FORMAT_VALUE, (payload) => {
|
||||
return {
|
||||
@@ -48,20 +59,30 @@ export default {
|
||||
// State
|
||||
|
||||
defaultState: {
|
||||
isSchemaFetching: false,
|
||||
isSchemaPopulated: false,
|
||||
isFetching: false,
|
||||
isPopulated: false,
|
||||
error: null,
|
||||
isSaving: false,
|
||||
saveError: null,
|
||||
isDeleting: false,
|
||||
deleteError: null,
|
||||
items: [],
|
||||
pendingChanges: {},
|
||||
|
||||
isSchemaFetching: false,
|
||||
isSchemaPopulated: false,
|
||||
schemaError: null,
|
||||
schema: {
|
||||
includeCustomFormatWhenRenaming: false
|
||||
},
|
||||
error: null,
|
||||
isDeleting: false,
|
||||
deleteError: null,
|
||||
isSaving: false,
|
||||
saveError: null,
|
||||
items: [],
|
||||
pendingChanges: {}
|
||||
|
||||
sortKey: 'name',
|
||||
sortDirection: sortDirections.ASCENDING,
|
||||
sortPredicates: {
|
||||
name: ({ name }) => {
|
||||
return name.toLocaleLowerCase();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
//
|
||||
@@ -83,7 +104,10 @@ export default {
|
||||
}));
|
||||
|
||||
createSaveProviderHandler(section, '/customformat')(getState, payload, dispatch);
|
||||
}
|
||||
},
|
||||
|
||||
[BULK_EDIT_CUSTOM_FORMATS]: createBulkEditItemHandler(section, '/customformat/bulk'),
|
||||
[BULK_DELETE_CUSTOM_FORMATS]: createBulkRemoveItemHandler(section, '/customformat/bulk')
|
||||
},
|
||||
|
||||
//
|
||||
@@ -103,7 +127,9 @@ export default {
|
||||
newState.pendingChanges = pendingChanges;
|
||||
|
||||
return updateSectionState(state, section, newState);
|
||||
}
|
||||
},
|
||||
|
||||
[SET_MANAGE_CUSTOM_FORMATS_SORT]: createSetClientSideCollectionSortReducer(section)
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -96,8 +96,8 @@ export default {
|
||||
sortKey: 'name',
|
||||
sortDirection: sortDirections.ASCENDING,
|
||||
sortPredicates: {
|
||||
name: function(item) {
|
||||
return item.name.toLowerCase();
|
||||
name: ({ name }) => {
|
||||
return name.toLocaleLowerCase();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -101,8 +101,8 @@ export default {
|
||||
sortKey: 'name',
|
||||
sortDirection: sortDirections.ASCENDING,
|
||||
sortPredicates: {
|
||||
name: function(item) {
|
||||
return item.name.toLowerCase();
|
||||
name: ({ name }) => {
|
||||
return name.toLocaleLowerCase();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Alert from 'Components/Alert';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Icon from 'Components/Icon';
|
||||
import Icon, { IconProps } from 'Components/Icon';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
@@ -97,7 +97,7 @@ function Health() {
|
||||
{items.map((item) => {
|
||||
const source = item.source;
|
||||
|
||||
let kind = kinds.WARNING;
|
||||
let kind: IconProps['kind'] = kinds.WARNING;
|
||||
switch (item.type.toLowerCase()) {
|
||||
case 'error':
|
||||
kind = kinds.DANGER;
|
||||
|
||||
@@ -2,7 +2,7 @@ import moment from 'moment';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { CommandBody } from 'Commands/Command';
|
||||
import Icon from 'Components/Icon';
|
||||
import Icon, { IconProps } from 'Components/Icon';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
@@ -19,7 +19,10 @@ import translate from 'Utilities/String/translate';
|
||||
import QueuedTaskRowNameCell from './QueuedTaskRowNameCell';
|
||||
import styles from './QueuedTaskRow.css';
|
||||
|
||||
function getStatusIconProps(status: string, message: string | undefined) {
|
||||
function getStatusIconProps(
|
||||
status: string,
|
||||
message: string | undefined
|
||||
): IconProps {
|
||||
const title = titleCase(status);
|
||||
|
||||
switch (status) {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import ModelBase from 'App/ModelBase';
|
||||
|
||||
export interface QualityProfileFormatItem {
|
||||
format: number;
|
||||
name: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface CustomFormat {
|
||||
id: number;
|
||||
interface CustomFormat extends ModelBase {
|
||||
name: string;
|
||||
includeCustomFormatWhenRenaming: boolean;
|
||||
}
|
||||
|
||||
export default CustomFormat;
|
||||
|
||||
@@ -30,7 +30,8 @@ namespace NzbDrone.Common.Http.Proxy
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(BypassFilter))
|
||||
{
|
||||
var hostlist = BypassFilter.Split(',');
|
||||
var hostlist = BypassFilter.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
for (var i = 0; i < hostlist.Length; i++)
|
||||
{
|
||||
if (hostlist[i].StartsWith("*"))
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DryIoc.dll" Version="5.4.3" />
|
||||
<PackageReference Include="IPAddressRange" Version="6.0.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" Version="6.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace NzbDrone.Core.Test.Http
|
||||
{
|
||||
private HttpProxySettings GetProxySettings()
|
||||
{
|
||||
return new HttpProxySettings(ProxyType.Socks5, "localhost", 8080, "*.httpbin.org,google.com", true, null, null);
|
||||
return new HttpProxySettings(ProxyType.Socks5, "localhost", 8080, "*.httpbin.org,google.com,172.16.0.0/12", true, null, null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -23,6 +23,7 @@ namespace NzbDrone.Core.Test.Http
|
||||
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://eu.httpbin.org/get")).Should().BeTrue();
|
||||
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://google.com/get")).Should().BeTrue();
|
||||
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://localhost:8654/get")).Should().BeTrue();
|
||||
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://172.21.0.1:8989/api/v3/indexer/schema")).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -31,6 +32,7 @@ namespace NzbDrone.Core.Test.Http
|
||||
var settings = GetProxySettings();
|
||||
|
||||
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://bing.com/get")).Should().BeFalse();
|
||||
Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://172.3.0.1:8989/api/v3/indexer/schema")).Should().BeFalse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,10 +9,12 @@ namespace NzbDrone.Core.CustomFormats
|
||||
public interface ICustomFormatService
|
||||
{
|
||||
void Update(CustomFormat customFormat);
|
||||
void Update(List<CustomFormat> customFormat);
|
||||
CustomFormat Insert(CustomFormat customFormat);
|
||||
List<CustomFormat> All();
|
||||
CustomFormat GetById(int id);
|
||||
void Delete(int id);
|
||||
void Delete(List<int> ids);
|
||||
}
|
||||
|
||||
public class CustomFormatService : ICustomFormatService
|
||||
@@ -51,6 +53,12 @@ namespace NzbDrone.Core.CustomFormats
|
||||
_cache.Clear();
|
||||
}
|
||||
|
||||
public void Update(List<CustomFormat> customFormat)
|
||||
{
|
||||
_formatRepository.UpdateMany(customFormat);
|
||||
_cache.Clear();
|
||||
}
|
||||
|
||||
public CustomFormat Insert(CustomFormat customFormat)
|
||||
{
|
||||
// Add to DB then insert into profiles
|
||||
@@ -72,5 +80,20 @@ namespace NzbDrone.Core.CustomFormats
|
||||
_formatRepository.Delete(id);
|
||||
_cache.Clear();
|
||||
}
|
||||
|
||||
public void Delete(List<int> ids)
|
||||
{
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var format = _formatRepository.Get(id);
|
||||
|
||||
// Remove from profiles before removing from DB
|
||||
_eventAggregator.PublishEvent(new CustomFormatDeletedEvent(format));
|
||||
|
||||
_formatRepository.Delete(id);
|
||||
}
|
||||
|
||||
_cache.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using NetTools;
|
||||
using NzbDrone.Common.Http;
|
||||
using NzbDrone.Common.Http.Proxy;
|
||||
using NzbDrone.Core.Configuration;
|
||||
@@ -52,7 +54,15 @@ namespace NzbDrone.Core.Http
|
||||
// We are utilising the WebProxy implementation here to save us having to reimplement it. This way we use Microsofts implementation
|
||||
var proxy = new WebProxy(proxySettings.Host + ":" + proxySettings.Port, proxySettings.BypassLocalAddress, proxySettings.BypassListAsArray);
|
||||
|
||||
return proxy.IsBypassed((Uri)url);
|
||||
return proxy.IsBypassed((Uri)url) || IsBypassedByIpAddressRange(proxySettings.BypassListAsArray, url.Host);
|
||||
}
|
||||
|
||||
private static bool IsBypassedByIpAddressRange(string[] bypassList, string host)
|
||||
{
|
||||
return bypassList.Any(bypass =>
|
||||
IPAddressRange.TryParse(bypass, out var ipAddressRange) &&
|
||||
IPAddress.TryParse(host, out var ipAddress) &&
|
||||
ipAddressRange.Contains(ipAddress));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,6 +258,7 @@
|
||||
"CopyUsingHardlinksHelpTextWarning": "Occasionally, file locks may prevent renaming files that are being seeded. You may temporarily disable seeding and use {appName}'s rename function as a work around.",
|
||||
"CopyUsingHardlinksSeriesHelpText": "Hardlinks allow {appName} to import seeding torrents to the series folder without taking extra disk space or copying the entire contents of the file. Hardlinks will only work if the source and destination are on the same volume",
|
||||
"CouldNotFindResults": "Couldn't find any results for '{term}'",
|
||||
"CountCustomFormatsSelected": "{count} custom formats(s) selected",
|
||||
"CountDownloadClientsSelected": "{count} download client(s) selected",
|
||||
"CountImportListsSelected": "{count} import list(s) selected",
|
||||
"CountIndexersSelected": "{count} indexer(s) selected",
|
||||
@@ -364,6 +365,8 @@
|
||||
"DeleteRootFolder": "Delete Root Folder",
|
||||
"DeleteRootFolderMessageText": "Are you sure you want to delete the root folder '{path}'?",
|
||||
"DeleteSelected": "Delete Selected",
|
||||
"DeleteSelectedCustomFormats": "Delete Custom Format(s)",
|
||||
"DeleteSelectedCustomFormatsMessageText": "Are you sure you want to delete {count} selected custom format(s)?",
|
||||
"DeleteSelectedDownloadClients": "Delete Download Client(s)",
|
||||
"DeleteSelectedDownloadClientsMessageText": "Are you sure you want to delete {count} selected download client(s)?",
|
||||
"DeleteSelectedEpisodeFiles": "Delete Selected Episode Files",
|
||||
@@ -590,6 +593,7 @@
|
||||
"EditReleaseProfile": "Edit Release Profile",
|
||||
"EditRemotePathMapping": "Edit Remote Path Mapping",
|
||||
"EditRestriction": "Edit Restriction",
|
||||
"EditSelectedCustomFormats": "Edit Selected Custom Formats",
|
||||
"EditSelectedDownloadClients": "Edit Selected Download Clients",
|
||||
"EditSelectedImportLists": "Edit Selected Import Lists",
|
||||
"EditSelectedIndexers": "Edit Selected Indexers",
|
||||
@@ -1106,6 +1110,7 @@
|
||||
"Lowercase": "Lowercase",
|
||||
"MaintenanceRelease": "Maintenance Release: bug fixes and other improvements. See Github Commit History for more details",
|
||||
"ManageClients": "Manage Clients",
|
||||
"ManageCustomFormats": "Manage Custom Formats",
|
||||
"ManageDownloadClients": "Manage Download Clients",
|
||||
"ManageEpisodes": "Manage Episodes",
|
||||
"ManageEpisodesSeason": "Manage Episodes files in this season",
|
||||
@@ -1255,6 +1260,7 @@
|
||||
"NoBlocklistItems": "No blocklist items",
|
||||
"NoChange": "No Change",
|
||||
"NoChanges": "No Changes",
|
||||
"NoCustomFormatsFound": "No custom formats found",
|
||||
"NoDelay": "No Delay",
|
||||
"NoDownloadClientsFound": "No download clients found",
|
||||
"NoEpisodeHistory": "No episode history",
|
||||
|
||||
@@ -2102,5 +2102,7 @@
|
||||
"NotificationsTelegramSettingsMetadataLinksHelpText": "Añade un enlace a los metadatos de la serie cuando se envían notificaciones",
|
||||
"NotificationsTelegramSettingsMetadataLinks": "Enlaces de metadatos",
|
||||
"DeleteSelected": "Borrar seleccionados",
|
||||
"DeleteSelectedImportListExclusionsMessageText": "¿Estás seguro que quieres borrar las exclusiones de listas de importación seleccionadas?"
|
||||
"DeleteSelectedImportListExclusionsMessageText": "¿Estás seguro que quieres borrar las exclusiones de listas de importación seleccionadas?",
|
||||
"LogSizeLimit": "Límite de tamaño de registro",
|
||||
"LogSizeLimitHelpText": "Máximo tamaño de archivo de registro en MB antes de archivarlo. Predeterminado es 1MB."
|
||||
}
|
||||
|
||||
@@ -1176,7 +1176,7 @@
|
||||
"Total": "Total",
|
||||
"Upcoming": "À venir",
|
||||
"UpdateAutomaticallyHelpText": "Téléchargez et installez automatiquement les mises à jour. Vous pourrez toujours installer à partir du système : mises à jour",
|
||||
"UpdateAvailableHealthCheckMessage": "Une nouvelle mise à jour est disponible : {version}",
|
||||
"UpdateAvailableHealthCheckMessage": "Une nouvelle mise à jour est disponible : {version}",
|
||||
"UpdateFiltered": "Mise à jour filtrée",
|
||||
"IconForSpecialsHelpText": "Afficher l'icône pour les épisodes spéciaux (saison 0)",
|
||||
"Ignored": "Ignoré",
|
||||
@@ -1724,8 +1724,8 @@
|
||||
"NotificationsGotifySettingsPriorityHelpText": "Priorité de la notification",
|
||||
"NotificationsGotifySettingsAppTokenHelpText": "Le jeton d'application généré par Gotify",
|
||||
"NotificationsGotifySettingsAppToken": "Jeton d'app",
|
||||
"NotificationsEmbySettingsUpdateLibraryHelpText": "Mettre à jour la bibliothèque lors de l'importation, du changement de nom ou de la suppression",
|
||||
"NotificationsEmbySettingsSendNotificationsHelpText": "Demandez à Emby d'envoyer des notifications aux fournisseurs configurés. Non pris en charge sur Jellyfin.",
|
||||
"NotificationsEmbySettingsUpdateLibraryHelpText": "Mise à jour de la bibliothèque en cas d'importation, de renommage ou de suppression",
|
||||
"NotificationsEmbySettingsSendNotificationsHelpText": "Demander à Emby d'envoyer des notifications aux fournisseurs configurés. Non supporté par Jellyfin.",
|
||||
"NotificationsEmbySettingsSendNotifications": "Envoyer des notifications",
|
||||
"NotificationsEmailSettingsServerHelpText": "Nom d'hôte ou adresse IP du serveur de courriel",
|
||||
"NotificationsEmailSettingsServer": "Serveur",
|
||||
@@ -2100,5 +2100,9 @@
|
||||
"SeasonsMonitoredPartial": "Partielle",
|
||||
"SeasonsMonitoredNone": "Aucune",
|
||||
"SeasonsMonitoredStatus": "Saisons surveillées",
|
||||
"NotificationsTelegramSettingsMetadataLinks": "Liens de métadonnées"
|
||||
"NotificationsTelegramSettingsMetadataLinks": "Liens de métadonnées",
|
||||
"LogSizeLimitHelpText": "Taille maximale du fichier journal en Mo avant archivage. La valeur par défaut est de 1 Mo.",
|
||||
"DeleteSelected": "Supprimer la sélection",
|
||||
"LogSizeLimit": "Limite de taille du journal",
|
||||
"DeleteSelectedImportListExclusionsMessageText": "Êtes-vous sûr de vouloir supprimer les exclusions de la liste d'importation sélectionnée ?"
|
||||
}
|
||||
|
||||
@@ -207,5 +207,30 @@
|
||||
"ChangeCategory": "Verander categorie",
|
||||
"ChownGroup": "chown groep",
|
||||
"AutoTaggingSpecificationTag": "Tag",
|
||||
"AddDelayProfileError": "Mislukt om vertragingsprofiel toe te voegen, probeer het later nog eens."
|
||||
"AddDelayProfileError": "Mislukt om vertragingsprofiel toe te voegen, probeer het later nog eens.",
|
||||
"BypassDelayIfAboveCustomFormatScoreHelpText": "Schakel omleiding in als de release een score heeft die hoger is dan de geconfigureerde minimale aangepaste formaatscore",
|
||||
"ConnectionSettingsUrlBaseHelpText": "Voegt een voorvoegsel toe aan de {connectionName} url, zoals {url}",
|
||||
"CustomFormatsSpecificationRegularExpressionHelpText": "Aangepaste opmaak RegEx is hoofdletterongevoelig",
|
||||
"CustomFormatsSpecificationRegularExpression": "Reguliere expressie",
|
||||
"AutoTaggingRequiredHelpText": "Deze {implementationName} voorwaarde moet overeenkomen om de auto tagging regel toe te passen. Anders is een enkele {implementationName} voldoende.",
|
||||
"BlackholeWatchFolder": "Bekijk map",
|
||||
"CustomFormatsSpecificationFlag": "Vlag",
|
||||
"BypassDelayIfAboveCustomFormatScore": "Omzeilen indien boven aangepaste opmaak score",
|
||||
"BlocklistAndSearch": "Blokkeerlijst en zoeken",
|
||||
"ChangeCategoryHint": "Verandert download naar de 'Post-Import Categorie' van Downloadclient",
|
||||
"ChangeCategoryMultipleHint": "Wijzigt downloads naar de 'Post-Import Categorie' van Downloadclient",
|
||||
"ClearBlocklist": "Blokkeerlijst wissen",
|
||||
"Clone": "Kloon",
|
||||
"BlocklistAndSearchHint": "Een vervanger zoeken na het blokkeren",
|
||||
"BlocklistAndSearchMultipleHint": "Zoekopdrachten voor vervangers starten na het blokkeren van de lijst",
|
||||
"BlocklistMultipleOnlyHint": "Blocklist zonder te zoeken naar vervangers",
|
||||
"BlocklistOnly": "Alleen bloklijst",
|
||||
"BlocklistOnlyHint": "Blokkeer lijst zonder te zoeken naar een vervanger",
|
||||
"BlocklistFilterHasNoItems": "Het geselecteerde bloklijstfilter bevat geen items",
|
||||
"BypassDelayIfAboveCustomFormatScoreMinimumScoreHelpText": "Minimumscore aangepast formaat vereist om vertraging voor het voorkeursprotocol te omzeilen",
|
||||
"CountImportListsSelected": "{count} importeer lijst(en) geselecteerd",
|
||||
"CustomFormatJson": "Aangepast formaat JSON",
|
||||
"CustomFormatUnknownCondition": "Onbekende aangepaste formaatvoorwaarde '{implementation}'.",
|
||||
"ClickToChangeIndexerFlags": "Klik om indexeringsvlaggen te wijzigen",
|
||||
"CustomFormatsSettingsTriggerInfo": "Een Aangepast Formaat wordt toegepast op een uitgave of bestand als het overeenkomt met ten minste één van de verschillende condities die zijn gekozen."
|
||||
}
|
||||
|
||||
@@ -2102,5 +2102,7 @@
|
||||
"NotificationsTelegramSettingsMetadataLinks": "Links de Metadados",
|
||||
"NotificationsTelegramSettingsMetadataLinksHelpText": "Adicione links aos metadados da série ao enviar notificações",
|
||||
"DeleteSelected": "Excluir Selecionado",
|
||||
"DeleteSelectedImportListExclusionsMessageText": "Tem certeza de que deseja excluir as listas de importação selecionadas das exclusões?"
|
||||
"DeleteSelectedImportListExclusionsMessageText": "Tem certeza de que deseja excluir as listas de importação selecionadas das exclusões?",
|
||||
"LogSizeLimit": "Limite de Tamanho do Registro",
|
||||
"LogSizeLimitHelpText": "Tamanho máximo do arquivo de registro em MB antes do arquivamento. O padrão é 1 MB."
|
||||
}
|
||||
|
||||
@@ -452,7 +452,7 @@
|
||||
"NotificationsAppriseSettingsStatelessUrlsHelpText": "Bildirimin nereye gönderilmesi gerektiğini belirten, virgülle ayrılmış bir veya daha fazla URL. Kalıcı Depolama kullanılıyorsa boş bırakın.",
|
||||
"NotificationsDiscordSettingsOnManualInteractionFields": "Manuel Etkileşimlerde",
|
||||
"NotificationsEmbySettingsSendNotifications": "Bildirim Gönder",
|
||||
"NotificationsEmbySettingsSendNotificationsHelpText": "MediaBrowser'ın yapılandırılmış sağlayıcılara bildirim göndermesini sağlayın",
|
||||
"NotificationsEmbySettingsSendNotificationsHelpText": "Emby'nin yapılandırılmış sağlayıcılara bildirim göndermesini sağlayın. Jellyfin'de desteklenmiyor.",
|
||||
"NotificationsJoinSettingsDeviceIdsHelpText": "Kullanımdan kaldırıldı, bunun yerine Cihaz Adlarını kullanın. Bildirim göndermek istediğiniz Cihaz Kimliklerinin virgülle ayrılmış listesi. Ayarlanmadığı takdirde tüm cihazlar bildirim alacaktır.",
|
||||
"NotificationsMailgunSettingsApiKeyHelpText": "MailGun'dan oluşturulan API anahtarı",
|
||||
"NotificationsMailgunSettingsUseEuEndpointHelpText": "AB MailGun uç noktasını kullanmayı etkinleştirin",
|
||||
@@ -480,7 +480,7 @@
|
||||
"NotificationsEmailSettingsCcAddressHelpText": "E-posta CC alıcılarının virgülle ayrılmış listesi",
|
||||
"NotificationsEmailSettingsCcAddress": "CC Adres(ler)i",
|
||||
"NotificationsEmailSettingsRecipientAddress": "Alıcı Adres(ler)i",
|
||||
"NotificationsEmbySettingsUpdateLibraryHelpText": "İçe Aktarma, Yeniden Adlandırma veya Silme sırasında Kitaplık Güncellensin mi?",
|
||||
"NotificationsEmbySettingsUpdateLibraryHelpText": "İçe Aktarma, Yeniden Adlandırma veya Silme sırasında Kitaplığı Güncelleyin",
|
||||
"NotificationsGotifySettingsAppToken": "Uygulama Jetonu",
|
||||
"NotificationsJoinSettingsDeviceIds": "Cihaz Kimlikleri",
|
||||
"NotificationsJoinSettingsNotificationPriority": "Bildirim Önceliği",
|
||||
@@ -849,5 +849,9 @@
|
||||
"UnableToImportAutomatically": "Otomatikman İçe Aktarılamıyor",
|
||||
"Any": "Herhangi",
|
||||
"ShowTags": "Etiketleri göster",
|
||||
"ShowTagsHelpText": "Etiketleri posterin altında göster"
|
||||
"ShowTagsHelpText": "Etiketleri posterin altında göster",
|
||||
"DeleteSelected": "Seçileni Sil",
|
||||
"LogSizeLimit": "Log Boyutu Sınırı",
|
||||
"LogSizeLimitHelpText": "Arşivlemeden önce MB cinsinden maksimum log dosya boyutu. Varsayılan 1 MB'tır.",
|
||||
"ProgressBarProgress": "İlerleme Çubuğu %{progress} seviyesinde"
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ namespace NzbDrone.Core.Notifications.CustomScript
|
||||
environmentVariables.Add("Sonarr_Series_Tags", string.Join("|", GetTagLabels(series)));
|
||||
environmentVariables.Add("Sonarr_EpisodeFile_Ids", string.Join(",", renamedFiles.Select(e => e.EpisodeFile.Id)));
|
||||
environmentVariables.Add("Sonarr_EpisodeFile_RelativePaths", string.Join("|", renamedFiles.Select(e => e.EpisodeFile.RelativePath)));
|
||||
environmentVariables.Add("Sonarr_EpisodeFile_Paths", string.Join("|", renamedFiles.Select(e => e.EpisodeFile.Path)));
|
||||
environmentVariables.Add("Sonarr_EpisodeFile_Paths", string.Join("|", renamedFiles.Select(e => Path.Combine(series.Path, e.EpisodeFile.RelativePath))));
|
||||
environmentVariables.Add("Sonarr_EpisodeFile_PreviousRelativePaths", string.Join("|", renamedFiles.Select(e => e.PreviousRelativePath)));
|
||||
environmentVariables.Add("Sonarr_EpisodeFile_PreviousPaths", string.Join("|", renamedFiles.Select(e => e.PreviousPath)));
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ namespace NzbDrone.Core.Notifications.Webhook
|
||||
{
|
||||
Id = episodeFile.Id;
|
||||
RelativePath = episodeFile.RelativePath;
|
||||
Path = episodeFile.Path;
|
||||
Path = System.IO.Path.Combine(episodeFile.Series.Value.Path, episodeFile.RelativePath);
|
||||
Quality = episodeFile.Quality.Quality.Name;
|
||||
QualityVersion = episodeFile.Quality.Revision.Version;
|
||||
ReleaseGroup = episodeFile.ReleaseGroup;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using FluentValidation.Validators;
|
||||
using NzbDrone.Common.Disk;
|
||||
using NzbDrone.Common.Extensions;
|
||||
using NzbDrone.Core.RootFolders;
|
||||
|
||||
@@ -19,7 +20,7 @@ namespace NzbDrone.Core.Validation.Paths
|
||||
{
|
||||
context.MessageFormatter.AppendArgument("path", context.PropertyValue?.ToString());
|
||||
|
||||
return context.PropertyValue == null || _rootFolderService.All().Exists(r => r.Path.PathEquals(context.PropertyValue.ToString()));
|
||||
return context.PropertyValue == null || _rootFolderService.All().Exists(r => r.Path.IsPathValid(PathValidationType.CurrentOs) && r.Path.PathEquals(context.PropertyValue.ToString()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
10
src/Sonarr.Api.V3/CustomFormats/CustomFormatBulkResource.cs
Normal file
10
src/Sonarr.Api.V3/CustomFormats/CustomFormatBulkResource.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Sonarr.Api.V3.CustomFormats
|
||||
{
|
||||
public class CustomFormatBulkResource
|
||||
{
|
||||
public HashSet<int> Ids { get; set; } = new ();
|
||||
public bool? IncludeCustomFormatWhenRenaming { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -47,6 +47,13 @@ namespace Sonarr.Api.V3.CustomFormats
|
||||
return _formatService.GetById(id).ToResource(true);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[Produces("application/json")]
|
||||
public List<CustomFormatResource> GetAll()
|
||||
{
|
||||
return _formatService.All().ToResource(true);
|
||||
}
|
||||
|
||||
[RestPostById]
|
||||
[Consumes("application/json")]
|
||||
public ActionResult<CustomFormatResource> Create([FromBody] CustomFormatResource customFormatResource)
|
||||
@@ -71,11 +78,26 @@ namespace Sonarr.Api.V3.CustomFormats
|
||||
return Accepted(model.Id);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[HttpPut("bulk")]
|
||||
[Consumes("application/json")]
|
||||
[Produces("application/json")]
|
||||
public List<CustomFormatResource> GetAll()
|
||||
public virtual ActionResult<CustomFormatResource> Update([FromBody] CustomFormatBulkResource resource)
|
||||
{
|
||||
return _formatService.All().ToResource(true);
|
||||
if (!resource.Ids.Any())
|
||||
{
|
||||
throw new BadRequestException("ids must be provided");
|
||||
}
|
||||
|
||||
var customFormats = resource.Ids.Select(id => _formatService.GetById(id)).ToList();
|
||||
|
||||
customFormats.ForEach(existing =>
|
||||
{
|
||||
existing.IncludeCustomFormatWhenRenaming = resource.IncludeCustomFormatWhenRenaming ?? existing.IncludeCustomFormatWhenRenaming;
|
||||
});
|
||||
|
||||
_formatService.Update(customFormats);
|
||||
|
||||
return Accepted(customFormats.ConvertAll(cf => cf.ToResource(true)));
|
||||
}
|
||||
|
||||
[RestDeleteById]
|
||||
@@ -84,12 +106,21 @@ namespace Sonarr.Api.V3.CustomFormats
|
||||
_formatService.Delete(id);
|
||||
}
|
||||
|
||||
[HttpDelete("bulk")]
|
||||
[Consumes("application/json")]
|
||||
public virtual object DeleteFormats([FromBody] CustomFormatBulkResource resource)
|
||||
{
|
||||
_formatService.Delete(resource.Ids.ToList());
|
||||
|
||||
return new { };
|
||||
}
|
||||
|
||||
[HttpGet("schema")]
|
||||
public object GetTemplates()
|
||||
{
|
||||
var schema = _specifications.OrderBy(x => x.Order).Select(x => x.ToSchema()).ToList();
|
||||
|
||||
var presets = GetPresets();
|
||||
var presets = GetPresets().ToList();
|
||||
|
||||
foreach (var item in schema)
|
||||
{
|
||||
|
||||
@@ -59,6 +59,7 @@ namespace Sonarr.Api.V3.Series
|
||||
SeriesAncestorValidator seriesAncestorValidator,
|
||||
SystemFolderValidator systemFolderValidator,
|
||||
QualityProfileExistsValidator qualityProfileExistsValidator,
|
||||
RootFolderExistsValidator rootFolderExistsValidator,
|
||||
SeriesFolderAsRootFolderValidator seriesFolderAsRootFolderValidator)
|
||||
: base(signalRBroadcaster)
|
||||
{
|
||||
@@ -88,6 +89,7 @@ namespace Sonarr.Api.V3.Series
|
||||
PostValidator.RuleFor(s => s.Path).IsValidPath().When(s => s.RootFolderPath.IsNullOrWhiteSpace());
|
||||
PostValidator.RuleFor(s => s.RootFolderPath)
|
||||
.IsValidPath()
|
||||
.SetValidator(rootFolderExistsValidator)
|
||||
.SetValidator(seriesFolderAsRootFolderValidator)
|
||||
.When(s => s.Path.IsNullOrWhiteSpace());
|
||||
PostValidator.RuleFor(s => s.Title).NotEmpty();
|
||||
@@ -156,6 +158,7 @@ namespace Sonarr.Api.V3.Series
|
||||
|
||||
[RestPostById]
|
||||
[Consumes("application/json")]
|
||||
[Produces("application/json")]
|
||||
public ActionResult<SeriesResource> AddSeries([FromBody] SeriesResource seriesResource)
|
||||
{
|
||||
var series = _addSeriesService.AddSeries(seriesResource.ToModel());
|
||||
@@ -165,6 +168,7 @@ namespace Sonarr.Api.V3.Series
|
||||
|
||||
[RestPutById]
|
||||
[Consumes("application/json")]
|
||||
[Produces("application/json")]
|
||||
public ActionResult<SeriesResource> UpdateSeries([FromBody] SeriesResource seriesResource, [FromQuery] bool moveFiles = false)
|
||||
{
|
||||
var series = _seriesService.GetSeries(seriesResource.Id);
|
||||
@@ -175,12 +179,12 @@ namespace Sonarr.Api.V3.Series
|
||||
var destinationPath = seriesResource.Path;
|
||||
|
||||
_commandQueueManager.Push(new MoveSeriesCommand
|
||||
{
|
||||
SeriesId = series.Id,
|
||||
SourcePath = sourcePath,
|
||||
DestinationPath = destinationPath,
|
||||
Trigger = CommandTrigger.Manual
|
||||
});
|
||||
{
|
||||
SeriesId = series.Id,
|
||||
SourcePath = sourcePath,
|
||||
DestinationPath = destinationPath,
|
||||
Trigger = CommandTrigger.Manual
|
||||
});
|
||||
}
|
||||
|
||||
var model = seriesResource.ToModel(series);
|
||||
|
||||
Reference in New Issue
Block a user