import * as React from 'react'; import Sidebar from './Sidebar'; import ButtonBar from './ButtonBar'; import Button, { ButtonLevel, ButtonSize } from '../Button/Button'; import { _ } from '@joplin/lib/locale'; import bridge from '../../services/bridge'; import Setting, { AppType, SyncStartupOperation } from '@joplin/lib/models/Setting'; import control_PluginsStates from './controls/plugins/PluginsStates'; import EncryptionConfigScreen from '../EncryptionConfigScreen/EncryptionConfigScreen'; import { reg } from '@joplin/lib/registry'; const { connect } = require('react-redux'); const { themeStyle } = require('@joplin/lib/theme'); const pathUtils = require('@joplin/lib/path-utils'); import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry'; import * as shared from '@joplin/lib/components/shared/config/config-shared.js'; import ClipperConfigScreen from '../ClipperConfigScreen'; import restart from '../../services/restart'; import PluginService from '@joplin/lib/services/plugins/PluginService'; import { getDefaultPluginsInstallState, updateDefaultPluginsInstallState } from '@joplin/lib/services/plugins/defaultPlugins/defaultPluginsUtils'; import getDefaultPluginsInfo from '@joplin/lib/services/plugins/defaultPlugins/desktopDefaultPluginsInfo'; import JoplinCloudConfigScreen from '../JoplinCloudConfigScreen'; import ToggleAdvancedSettingsButton from './controls/ToggleAdvancedSettingsButton'; import shouldShowMissingPasswordWarning from '@joplin/lib/components/shared/config/shouldShowMissingPasswordWarning'; import MacOSMissingPasswordHelpLink from './controls/MissingPasswordHelpLink'; const { KeymapConfigScreen } = require('../KeymapConfig/KeymapConfigScreen'); const settingKeyToControl: any = { 'plugins.states': control_PluginsStates, }; class ConfigScreenComponent extends React.Component { private rowStyle_: any = null; public constructor(props: any) { super(props); shared.init(reg); this.state = { ...shared.defaultScreenState, selectedSectionName: 'general', screenName: '', changedSettingKeys: [], needRestart: false, }; this.rowStyle_ = { marginBottom: 10, }; this.sidebar_selectionChange = this.sidebar_selectionChange.bind(this); this.checkSyncConfig_ = this.checkSyncConfig_.bind(this); this.onCancelClick = this.onCancelClick.bind(this); this.onSaveClick = this.onSaveClick.bind(this); this.onApplyClick = this.onApplyClick.bind(this); this.renderLabel = this.renderLabel.bind(this); this.renderDescription = this.renderDescription.bind(this); this.renderHeader = this.renderHeader.bind(this); this.handleSettingButton = this.handleSettingButton.bind(this); } private async checkSyncConfig_() { await shared.checkSyncConfig(this, this.state.settings); } public UNSAFE_componentWillMount() { this.setState({ settings: this.props.settings }); } public componentDidMount() { if (this.props.defaultSection) { this.setState({ selectedSectionName: this.props.defaultSection }, () => { this.switchSection(this.props.defaultSection); }); } updateDefaultPluginsInstallState(getDefaultPluginsInstallState(PluginService.instance(), Object.keys(getDefaultPluginsInfo())), this); } private async handleSettingButton(key: string) { if (key === 'sync.clearLocalSyncStateButton') { if (!confirm('This cannot be undone. Do you want to continue?')) return; Setting.setValue('sync.startupOperation', SyncStartupOperation.ClearLocalSyncState); await Setting.saveAll(); await restart(); } else if (key === 'sync.clearLocalDataButton') { if (!confirm('This cannot be undone. Do you want to continue?')) return; Setting.setValue('sync.startupOperation', SyncStartupOperation.ClearLocalData); await Setting.saveAll(); await restart(); } else if (key === 'sync.openSyncWizard') { this.props.dispatch({ type: 'DIALOG_OPEN', name: 'syncWizard', }); } else { throw new Error(`Unhandled key: ${key}`); } } public sectionByName(name: string) { const sections = shared.settingsSections({ device: AppType.Desktop, settings: this.state.settings }); for (const section of sections) { if (section.name === name) return section; } throw new Error(`Invalid section name: ${name}`); } public screenFromName(screenName: string) { if (screenName === 'encryption') return ; if (screenName === 'server') return ; if (screenName === 'keymap') return ; if (screenName === 'joplinCloud') return ; throw new Error(`Invalid screen name: ${screenName}`); } public switchSection(name: string) { const section = this.sectionByName(name); let screenName = ''; if (section.isScreen) { screenName = section.name; if (this.hasChanges()) { const ok = confirm(_('This will open a new screen. Save your current changes?')); if (ok) shared.saveSettings(this); } } this.setState({ selectedSectionName: section.name, screenName: screenName }); } private sidebar_selectionChange(event: any) { this.switchSection(event.section.name); } public renderSectionDescription(section: any) { const description = Setting.sectionDescription(section.name); if (!description) return null; const theme = themeStyle(this.props.themeId); return (
{description}
); } public sectionToComponent(key: string, section: any, settings: any, selected: boolean) { const theme = themeStyle(this.props.themeId); const createSettingComponents = (advanced: boolean) => { const output = []; for (let i = 0; i < section.metadatas.length; i++) { const md = section.metadatas[i]; if (!!md.advanced !== advanced) continue; const settingComp = this.settingToComponent(md.key, settings[md.key]); output.push(settingComp); } return output; }; const settingComps = createSettingComponents(false); const advancedSettingComps = createSettingComponents(true); const sectionWidths: Record = { plugins: '100%', }; const sectionStyle: any = { marginTop: 20, marginBottom: 20, maxWidth: sectionWidths[section.name] ? sectionWidths[section.name] : 640, }; if (!selected) sectionStyle.display = 'none'; if (section.name === 'general') { sectionStyle.borderTopWidth = 0; } if (section.name === 'sync') { const syncTargetMd = SyncTargetRegistry.idToMetadata(settings['sync.target']); const statusStyle = { ...theme.textStyle, marginTop: 10 }; const warningStyle = { ...theme.textStyle, color: theme.colorWarn }; // Don't show the missing password warning if the user just changed the sync target (but hasn't // saved yet). const matchesSavedTarget = settings['sync.target'] === this.props.settings['sync.target']; if (matchesSavedTarget && shouldShowMissingPasswordWarning(settings['sync.target'], settings)) { settingComps.push(

{_('%s: Missing password.', _('Warning'))} {' '}

, ); } if (syncTargetMd.supportsConfigCheck) { const messages = shared.checkSyncConfigMessages(this); const statusComp = !messages.length ? null : (
{messages[0]} {messages.length >= 1 ?

{messages[1]}

: null}
); settingComps.push(
, ); } } let advancedSettingsButton = null; const advancedSettingsSectionStyle = { display: 'none' }; if (advancedSettingComps.length) { advancedSettingsButton = ( shared.advancedSettingsButton_click(this)} advancedSettingsVisible={this.state.showAdvancedSettings} /> ); advancedSettingsSectionStyle.display = this.state.showAdvancedSettings ? 'block' : 'none'; } return (
{this.renderSectionDescription(section)}
{settingComps}
{advancedSettingsButton}
{advancedSettingComps}
); } private labelStyle(themeId: number) { const theme = themeStyle(themeId); return { ...theme.textStyle, display: 'block', color: theme.color, fontSize: theme.fontSize * 1.083333, fontWeight: 500, marginBottom: theme.mainPadding / 2 }; } private descriptionStyle(themeId: number) { const theme = themeStyle(themeId); return { ...theme.textStyle, color: theme.colorFaded, fontStyle: 'italic', maxWidth: '70em', marginTop: 5 }; } private renderLabel(themeId: number, label: string) { const labelStyle = this.labelStyle(themeId); return (
); } private renderHeader(themeId: number, label: string, style: any = null) { const theme = themeStyle(themeId); const labelStyle = { ...theme.textStyle, display: 'block', color: theme.color, fontSize: theme.fontSize * 1.25, fontWeight: 500, marginBottom: theme.mainPadding, ...style }; return (
); } private renderDescription(themeId: number, description: string) { return description ?
{description}
: null; } public settingToComponent(key: string, value: any) { const theme = themeStyle(this.props.themeId); const output: any = null; const rowStyle = { marginBottom: theme.mainPadding * 1.5, }; const labelStyle = this.labelStyle(this.props.themeId); const subLabel = { ...labelStyle, display: 'block', opacity: 0.7, marginBottom: labelStyle.marginBottom }; const checkboxLabelStyle = { ...labelStyle, marginLeft: 8, display: 'inline', backgroundColor: 'transparent' }; const controlStyle = { display: 'inline-block', color: theme.color, fontFamily: theme.fontFamily, backgroundColor: theme.backgroundColor, }; const textInputBaseStyle = { ...controlStyle, fontFamily: theme.fontFamily, border: '1px solid', padding: '4px 6px', boxSizing: 'border-box', borderColor: theme.borderColor4, borderRadius: 3, paddingLeft: 6, paddingRight: 6, paddingTop: 4, paddingBottom: 4 }; const updateSettingValue = (key: string, value: any) => { const md = Setting.settingMetadata(key); if (md.needRestart) { this.setState({ needRestart: true }); } shared.updateSettingValue(this, key, value); if (md.autoSave) { shared.scheduleSaveSettings(this); } }; const md = Setting.settingMetadata(key); const descriptionText = Setting.keyDescription(key, AppType.Desktop); const descriptionComp = this.renderDescription(this.props.themeId, descriptionText); if (settingKeyToControl[key]) { const SettingComponent = settingKeyToControl[key]; const label = md.label ? this.renderLabel(this.props.themeId, md.label()) : null; return (
{label} {this.renderDescription(this.props.themeId, md.description ? md.description() : null)} { updateSettingValue(key, event.value); }} renderLabel={this.renderLabel} renderDescription={this.renderDescription} renderHeader={this.renderHeader} />
); } else if (md.isEnum) { const items = []; const settingOptions = md.options(); const array = Setting.enumOptionsToValueLabels(settingOptions, md.optionsOrder ? md.optionsOrder() : [], { valueKey: 'key', labelKey: 'label', }); for (let i = 0; i < array.length; i++) { const e = array[i]; items.push( , ); } const selectStyle = { ...controlStyle, paddingLeft: 6, paddingRight: 6, paddingTop: 4, paddingBottom: 4, borderColor: theme.borderColor4, borderRadius: 3 }; return (
{descriptionComp}
); } else if (md.type === Setting.TYPE_BOOL) { const onCheckboxClick = () => { updateSettingValue(key, !value); }; const checkboxSize = theme.fontSize * 1.1666666666666; // Hack: The {key+value.toString()} is needed as otherwise the checkbox doesn't update when the state changes. // There's probably a better way to do this but can't figure it out. return (
{ onCheckboxClick(); }} style={{ marginLeft: 0, width: checkboxSize, height: checkboxSize }} />
{descriptionComp}
); } else if (md.type === Setting.TYPE_STRING) { const inputStyle: any = { ...textInputBaseStyle, width: '50%', minWidth: '20em' }; const inputType = md.secure === true ? 'password' : 'text'; if (md.subType === 'file_path_and_args' || md.subType === 'file_path' || md.subType === 'directory_path') { inputStyle.marginBottom = subLabel.marginBottom; const splitCmd = (cmdString: string) => { // Normally not necessary but certain plugins found a way to // set the set the value to "undefined", leading to a crash. // This is now fixed at the model level but to be sure we // check here too, to handle any already existing data. // https://github.com/laurent22/joplin/issues/7621 if (!cmdString) cmdString = ''; const path = pathUtils.extractExecutablePath(cmdString); const args = cmdString.substr(path.length + 1); return [pathUtils.unquotePath(path), args]; }; const joinCmd = (cmdArray: string[]) => { if (!cmdArray[0] && !cmdArray[1]) return ''; let cmdString = pathUtils.quotePath(cmdArray[0]); if (!cmdString) cmdString = '""'; if (cmdArray[1]) cmdString += ` ${cmdArray[1]}`; return cmdString; }; const onPathChange = (event: any) => { if (md.subType === 'file_path_and_args') { const cmd = splitCmd(this.state.settings[key]); cmd[0] = event.target.value; updateSettingValue(key, joinCmd(cmd)); } else { updateSettingValue(key, event.target.value); } }; const onArgsChange = (event: any) => { const cmd = splitCmd(this.state.settings[key]); cmd[1] = event.target.value; updateSettingValue(key, joinCmd(cmd)); }; const browseButtonClick = async () => { if (md.subType === 'directory_path') { const paths = await bridge().showOpenDialog({ properties: ['openDirectory'], }); if (!paths || !paths.length) return; updateSettingValue(key, paths[0]); } else { const paths = await bridge().showOpenDialog(); if (!paths || !paths.length) return; if (md.subType === 'file_path') { updateSettingValue(key, paths[0]); } else { const cmd = splitCmd(this.state.settings[key]); cmd[0] = paths[0]; updateSettingValue(key, joinCmd(cmd)); } } }; const cmd = splitCmd(this.state.settings[key]); const path = md.subType === 'file_path_and_args' ? cmd[0] : this.state.settings[key]; const argComp = md.subType !== 'file_path_and_args' ? null : (
{_('Arguments:')}
{ onArgsChange(event); }} value={cmd[1]} spellCheck={false} />
{descriptionComp}
); return (
{_('Path:')}
{ onPathChange(event); }} value={path} spellCheck={false} />
{argComp}
); } else { const onTextChange = (event: any) => { updateSettingValue(key, event.target.value); }; return (
{ onTextChange(event); }} spellCheck={false} />
{descriptionComp}
); } } else if (md.type === Setting.TYPE_INT) { const onNumChange = (event: any) => { updateSettingValue(key, event.target.value); }; const label = [md.label()]; if (md.unitLabel) label.push(`(${md.unitLabel()})`); const inputStyle: any = { ...textInputBaseStyle }; return (
{ onNumChange(event); }} min={md.minimum} max={md.maximum} step={md.step} spellCheck={false} /> {descriptionComp}
); } else if (md.type === Setting.TYPE_BUTTON) { const labelComp = md.hideLabel ? null : (
); return (
{labelComp}
); } else { console.warn(`Type not implemented: ${key}`); } return output; } private restartMessage() { return _('The application must be restarted for these changes to take effect.'); } private async restartApp() { await Setting.saveAll(); await restart(); } private async checkNeedRestart() { if (this.state.needRestart) { const doItNow = await bridge().showConfirmMessageBox(this.restartMessage(), { buttons: [_('Do it now'), _('Later')], }); if (doItNow) await this.restartApp(); } } public async onApplyClick() { shared.saveSettings(this); await this.checkNeedRestart(); } public async onSaveClick() { shared.saveSettings(this); await this.checkNeedRestart(); this.props.dispatch({ type: 'NAV_BACK' }); } public onCancelClick() { this.props.dispatch({ type: 'NAV_BACK' }); } public hasChanges() { return !!this.state.changedSettingKeys.length; } public render() { const theme = themeStyle(this.props.themeId); const style = { ...this.props.style, overflow: 'hidden', display: 'flex', flexDirection: 'column', backgroundColor: theme.backgroundColor3, }; const settings = this.state.settings; const containerStyle = { overflow: 'auto', padding: theme.configScreenPadding, paddingTop: 0, display: 'flex', flex: 1, }; const hasChanges = this.hasChanges(); const settingComps = shared.settingsToComponents2(this, AppType.Desktop, settings, this.state.selectedSectionName); // screenComp is a custom config screen, such as the encryption config screen or keymap config screen. // These screens handle their own loading/saving of settings and have bespoke rendering. // When screenComp is null, it means we are viewing the regular settings. const screenComp = this.state.screenName ?
{this.screenFromName(this.state.screenName)}
: null; if (screenComp) containerStyle.display = 'none'; const sections = shared.settingsSections({ device: AppType.Desktop, settings }); const needRestartComp: any = this.state.needRestart ? ( ) : null; const rightStyle = { ...style, flex: 1 }; delete style.width; return (
{screenComp} {needRestartComp}
{settingComps}
); } } const mapStateToProps = (state: any) => { return { themeId: state.settings.theme, settings: state.settings, locale: state.settings.locale, }; }; export default connect(mapStateToProps)(ConfigScreenComponent);