1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-03-29 21:21:15 +02:00

Desktop: Resolves #591: Add support for multiple profiles (#6385)

This commit is contained in:
Laurent 2022-04-11 16:49:32 +01:00 committed by GitHub
parent 58bf93a112
commit 6458ad0540
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 919 additions and 154 deletions

View File

@ -148,6 +148,9 @@ packages/app-desktop/checkForUpdates.js.map
packages/app-desktop/commands/copyDevCommand.d.ts packages/app-desktop/commands/copyDevCommand.d.ts
packages/app-desktop/commands/copyDevCommand.js packages/app-desktop/commands/copyDevCommand.js
packages/app-desktop/commands/copyDevCommand.js.map packages/app-desktop/commands/copyDevCommand.js.map
packages/app-desktop/commands/editProfileConfig.d.ts
packages/app-desktop/commands/editProfileConfig.js
packages/app-desktop/commands/editProfileConfig.js.map
packages/app-desktop/commands/exportFolders.d.ts packages/app-desktop/commands/exportFolders.d.ts
packages/app-desktop/commands/exportFolders.js packages/app-desktop/commands/exportFolders.js
packages/app-desktop/commands/exportFolders.js.map packages/app-desktop/commands/exportFolders.js.map
@ -175,6 +178,18 @@ packages/app-desktop/commands/startExternalEditing.js.map
packages/app-desktop/commands/stopExternalEditing.d.ts packages/app-desktop/commands/stopExternalEditing.d.ts
packages/app-desktop/commands/stopExternalEditing.js packages/app-desktop/commands/stopExternalEditing.js
packages/app-desktop/commands/stopExternalEditing.js.map packages/app-desktop/commands/stopExternalEditing.js.map
packages/app-desktop/commands/switchProfile.d.ts
packages/app-desktop/commands/switchProfile.js
packages/app-desktop/commands/switchProfile.js.map
packages/app-desktop/commands/switchProfile1.d.ts
packages/app-desktop/commands/switchProfile1.js
packages/app-desktop/commands/switchProfile1.js.map
packages/app-desktop/commands/switchProfile2.d.ts
packages/app-desktop/commands/switchProfile2.js
packages/app-desktop/commands/switchProfile2.js.map
packages/app-desktop/commands/switchProfile3.d.ts
packages/app-desktop/commands/switchProfile3.js
packages/app-desktop/commands/switchProfile3.js.map
packages/app-desktop/commands/toggleExternalEditing.d.ts packages/app-desktop/commands/toggleExternalEditing.d.ts
packages/app-desktop/commands/toggleExternalEditing.js packages/app-desktop/commands/toggleExternalEditing.js
packages/app-desktop/commands/toggleExternalEditing.js.map packages/app-desktop/commands/toggleExternalEditing.js.map
@ -259,6 +274,9 @@ packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js.map
packages/app-desktop/gui/MainScreen/MainScreen.d.ts packages/app-desktop/gui/MainScreen/MainScreen.d.ts
packages/app-desktop/gui/MainScreen/MainScreen.js packages/app-desktop/gui/MainScreen/MainScreen.js
packages/app-desktop/gui/MainScreen/MainScreen.js.map packages/app-desktop/gui/MainScreen/MainScreen.js.map
packages/app-desktop/gui/MainScreen/commands/addProfile.d.ts
packages/app-desktop/gui/MainScreen/commands/addProfile.js
packages/app-desktop/gui/MainScreen/commands/addProfile.js.map
packages/app-desktop/gui/MainScreen/commands/commandPalette.d.ts packages/app-desktop/gui/MainScreen/commands/commandPalette.d.ts
packages/app-desktop/gui/MainScreen/commands/commandPalette.js packages/app-desktop/gui/MainScreen/commands/commandPalette.js
packages/app-desktop/gui/MainScreen/commands/commandPalette.js.map packages/app-desktop/gui/MainScreen/commands/commandPalette.js.map
@ -1246,6 +1264,9 @@ packages/lib/services/DecryptionWorker.js.map
packages/lib/services/ExternalEditWatcher.d.ts packages/lib/services/ExternalEditWatcher.d.ts
packages/lib/services/ExternalEditWatcher.js packages/lib/services/ExternalEditWatcher.js
packages/lib/services/ExternalEditWatcher.js.map packages/lib/services/ExternalEditWatcher.js.map
packages/lib/services/ExternalEditWatcher/utils.d.ts
packages/lib/services/ExternalEditWatcher/utils.js
packages/lib/services/ExternalEditWatcher/utils.js.map
packages/lib/services/ItemChangeUtils.d.ts packages/lib/services/ItemChangeUtils.d.ts
packages/lib/services/ItemChangeUtils.js packages/lib/services/ItemChangeUtils.js
packages/lib/services/ItemChangeUtils.js.map packages/lib/services/ItemChangeUtils.js.map
@ -1570,6 +1591,24 @@ packages/lib/services/plugins/utils/validatePluginVersion.js.map
packages/lib/services/plugins/utils/validatePluginVersion.test.d.ts packages/lib/services/plugins/utils/validatePluginVersion.test.d.ts
packages/lib/services/plugins/utils/validatePluginVersion.test.js packages/lib/services/plugins/utils/validatePluginVersion.test.js
packages/lib/services/plugins/utils/validatePluginVersion.test.js.map packages/lib/services/plugins/utils/validatePluginVersion.test.js.map
packages/lib/services/profileConfig/index.d.ts
packages/lib/services/profileConfig/index.js
packages/lib/services/profileConfig/index.js.map
packages/lib/services/profileConfig/index.test.d.ts
packages/lib/services/profileConfig/index.test.js
packages/lib/services/profileConfig/index.test.js.map
packages/lib/services/profileConfig/initProfile.d.ts
packages/lib/services/profileConfig/initProfile.js
packages/lib/services/profileConfig/initProfile.js.map
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.d.ts
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.js
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.js.map
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.d.ts
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.js
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.js.map
packages/lib/services/profileConfig/types.d.ts
packages/lib/services/profileConfig/types.js
packages/lib/services/profileConfig/types.js.map
packages/lib/services/rest/Api.d.ts packages/lib/services/rest/Api.d.ts
packages/lib/services/rest/Api.js packages/lib/services/rest/Api.js
packages/lib/services/rest/Api.js.map packages/lib/services/rest/Api.js.map

39
.gitignore vendored
View File

@ -138,6 +138,9 @@ packages/app-desktop/checkForUpdates.js.map
packages/app-desktop/commands/copyDevCommand.d.ts packages/app-desktop/commands/copyDevCommand.d.ts
packages/app-desktop/commands/copyDevCommand.js packages/app-desktop/commands/copyDevCommand.js
packages/app-desktop/commands/copyDevCommand.js.map packages/app-desktop/commands/copyDevCommand.js.map
packages/app-desktop/commands/editProfileConfig.d.ts
packages/app-desktop/commands/editProfileConfig.js
packages/app-desktop/commands/editProfileConfig.js.map
packages/app-desktop/commands/exportFolders.d.ts packages/app-desktop/commands/exportFolders.d.ts
packages/app-desktop/commands/exportFolders.js packages/app-desktop/commands/exportFolders.js
packages/app-desktop/commands/exportFolders.js.map packages/app-desktop/commands/exportFolders.js.map
@ -165,6 +168,18 @@ packages/app-desktop/commands/startExternalEditing.js.map
packages/app-desktop/commands/stopExternalEditing.d.ts packages/app-desktop/commands/stopExternalEditing.d.ts
packages/app-desktop/commands/stopExternalEditing.js packages/app-desktop/commands/stopExternalEditing.js
packages/app-desktop/commands/stopExternalEditing.js.map packages/app-desktop/commands/stopExternalEditing.js.map
packages/app-desktop/commands/switchProfile.d.ts
packages/app-desktop/commands/switchProfile.js
packages/app-desktop/commands/switchProfile.js.map
packages/app-desktop/commands/switchProfile1.d.ts
packages/app-desktop/commands/switchProfile1.js
packages/app-desktop/commands/switchProfile1.js.map
packages/app-desktop/commands/switchProfile2.d.ts
packages/app-desktop/commands/switchProfile2.js
packages/app-desktop/commands/switchProfile2.js.map
packages/app-desktop/commands/switchProfile3.d.ts
packages/app-desktop/commands/switchProfile3.js
packages/app-desktop/commands/switchProfile3.js.map
packages/app-desktop/commands/toggleExternalEditing.d.ts packages/app-desktop/commands/toggleExternalEditing.d.ts
packages/app-desktop/commands/toggleExternalEditing.js packages/app-desktop/commands/toggleExternalEditing.js
packages/app-desktop/commands/toggleExternalEditing.js.map packages/app-desktop/commands/toggleExternalEditing.js.map
@ -249,6 +264,9 @@ packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js.map
packages/app-desktop/gui/MainScreen/MainScreen.d.ts packages/app-desktop/gui/MainScreen/MainScreen.d.ts
packages/app-desktop/gui/MainScreen/MainScreen.js packages/app-desktop/gui/MainScreen/MainScreen.js
packages/app-desktop/gui/MainScreen/MainScreen.js.map packages/app-desktop/gui/MainScreen/MainScreen.js.map
packages/app-desktop/gui/MainScreen/commands/addProfile.d.ts
packages/app-desktop/gui/MainScreen/commands/addProfile.js
packages/app-desktop/gui/MainScreen/commands/addProfile.js.map
packages/app-desktop/gui/MainScreen/commands/commandPalette.d.ts packages/app-desktop/gui/MainScreen/commands/commandPalette.d.ts
packages/app-desktop/gui/MainScreen/commands/commandPalette.js packages/app-desktop/gui/MainScreen/commands/commandPalette.js
packages/app-desktop/gui/MainScreen/commands/commandPalette.js.map packages/app-desktop/gui/MainScreen/commands/commandPalette.js.map
@ -1236,6 +1254,9 @@ packages/lib/services/DecryptionWorker.js.map
packages/lib/services/ExternalEditWatcher.d.ts packages/lib/services/ExternalEditWatcher.d.ts
packages/lib/services/ExternalEditWatcher.js packages/lib/services/ExternalEditWatcher.js
packages/lib/services/ExternalEditWatcher.js.map packages/lib/services/ExternalEditWatcher.js.map
packages/lib/services/ExternalEditWatcher/utils.d.ts
packages/lib/services/ExternalEditWatcher/utils.js
packages/lib/services/ExternalEditWatcher/utils.js.map
packages/lib/services/ItemChangeUtils.d.ts packages/lib/services/ItemChangeUtils.d.ts
packages/lib/services/ItemChangeUtils.js packages/lib/services/ItemChangeUtils.js
packages/lib/services/ItemChangeUtils.js.map packages/lib/services/ItemChangeUtils.js.map
@ -1560,6 +1581,24 @@ packages/lib/services/plugins/utils/validatePluginVersion.js.map
packages/lib/services/plugins/utils/validatePluginVersion.test.d.ts packages/lib/services/plugins/utils/validatePluginVersion.test.d.ts
packages/lib/services/plugins/utils/validatePluginVersion.test.js packages/lib/services/plugins/utils/validatePluginVersion.test.js
packages/lib/services/plugins/utils/validatePluginVersion.test.js.map packages/lib/services/plugins/utils/validatePluginVersion.test.js.map
packages/lib/services/profileConfig/index.d.ts
packages/lib/services/profileConfig/index.js
packages/lib/services/profileConfig/index.js.map
packages/lib/services/profileConfig/index.test.d.ts
packages/lib/services/profileConfig/index.test.js
packages/lib/services/profileConfig/index.test.js.map
packages/lib/services/profileConfig/initProfile.d.ts
packages/lib/services/profileConfig/initProfile.js
packages/lib/services/profileConfig/initProfile.js.map
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.d.ts
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.js
packages/lib/services/profileConfig/mergeGlobalAndLocalSettings.js.map
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.d.ts
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.js
packages/lib/services/profileConfig/splitGlobalAndLocalSettings.js.map
packages/lib/services/profileConfig/types.d.ts
packages/lib/services/profileConfig/types.js
packages/lib/services/profileConfig/types.js.map
packages/lib/services/rest/Api.d.ts packages/lib/services/rest/Api.d.ts
packages/lib/services/rest/Api.js packages/lib/services/rest/Api.js
packages/lib/services/rest/Api.js.map packages/lib/services/rest/Api.js.map

View File

@ -0,0 +1,19 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import Setting from '../../lib/models/Setting';
import { openFileWithExternalEditor } from '../../lib/services/ExternalEditWatcher/utils';
import bridge from '../services/bridge';
import { _ } from '@joplin/lib/locale';
export const declaration: CommandDeclaration = {
name: 'editProfileConfig',
label: () => _('Edit profile configuration...'),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext) => {
await openFileWithExternalEditor(`${Setting.value('rootProfileDir')}/profiles.json`, bridge());
},
enabledCondition: 'hasMultiProfiles',
};
};

View File

@ -1,5 +1,6 @@
// AUTO-GENERATED using `gulp buildCommandIndex` // AUTO-GENERATED using `gulp buildCommandIndex`
import * as copyDevCommand from './copyDevCommand'; import * as copyDevCommand from './copyDevCommand';
import * as editProfileConfig from './editProfileConfig';
import * as exportFolders from './exportFolders'; import * as exportFolders from './exportFolders';
import * as exportNotes from './exportNotes'; import * as exportNotes from './exportNotes';
import * as focusElement from './focusElement'; import * as focusElement from './focusElement';
@ -8,11 +9,16 @@ import * as replaceMisspelling from './replaceMisspelling';
import * as restoreNoteRevision from './restoreNoteRevision'; import * as restoreNoteRevision from './restoreNoteRevision';
import * as startExternalEditing from './startExternalEditing'; import * as startExternalEditing from './startExternalEditing';
import * as stopExternalEditing from './stopExternalEditing'; import * as stopExternalEditing from './stopExternalEditing';
import * as switchProfile from './switchProfile';
import * as switchProfile1 from './switchProfile1';
import * as switchProfile2 from './switchProfile2';
import * as switchProfile3 from './switchProfile3';
import * as toggleExternalEditing from './toggleExternalEditing'; import * as toggleExternalEditing from './toggleExternalEditing';
import * as toggleSafeMode from './toggleSafeMode'; import * as toggleSafeMode from './toggleSafeMode';
const index:any[] = [ const index:any[] = [
copyDevCommand, copyDevCommand,
editProfileConfig,
exportFolders, exportFolders,
exportNotes, exportNotes,
focusElement, focusElement,
@ -21,6 +27,10 @@ const index:any[] = [
restoreNoteRevision, restoreNoteRevision,
startExternalEditing, startExternalEditing,
stopExternalEditing, stopExternalEditing,
switchProfile,
switchProfile1,
switchProfile2,
switchProfile3,
toggleExternalEditing, toggleExternalEditing,
toggleSafeMode, toggleSafeMode,
]; ];

View File

@ -0,0 +1,26 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import Setting from '@joplin/lib/models/Setting';
import { saveProfileConfig } from '@joplin/lib/services/profileConfig';
import { ProfileConfig } from '@joplin/lib/services/profileConfig/types';
import bridge from '../services/bridge';
export const declaration: CommandDeclaration = {
name: 'switchProfile',
};
export const runtime = (): CommandRuntime => {
return {
execute: async (context: CommandContext, profileIndex: number) => {
const currentConfig = context.state.profileConfig;
if (currentConfig.currentProfile === profileIndex) return;
const newConfig: ProfileConfig = {
...currentConfig,
currentProfile: profileIndex,
};
await saveProfileConfig(`${Setting.value('rootProfileDir')}/profiles.json`, newConfig);
bridge().restart();
},
};
};

View File

@ -0,0 +1,15 @@
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
export const declaration: CommandDeclaration = {
name: 'switchProfile1',
label: () => _('Switch to profile %d', 1),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext) => {
await CommandService.instance().execute('switchProfile', 0);
},
};
};

View File

@ -0,0 +1,15 @@
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
export const declaration: CommandDeclaration = {
name: 'switchProfile2',
label: () => _('Switch to profile %d', 2),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext) => {
await CommandService.instance().execute('switchProfile', 1);
},
};
};

View File

@ -0,0 +1,15 @@
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
export const declaration: CommandDeclaration = {
name: 'switchProfile3',
label: () => _('Switch to profile %d', 3),
};
export const runtime = (): CommandRuntime => {
return {
execute: async (_context: CommandContext) => {
await CommandService.instance().execute('switchProfile', 2);
},
};
};

View File

@ -0,0 +1,34 @@
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
import { _ } from '@joplin/lib/locale';
import { createNewProfile, saveProfileConfig } from '@joplin/lib/services/profileConfig';
import Setting from '@joplin/lib/models/Setting';
import bridge from '../../../services/bridge';
export const declaration: CommandDeclaration = {
name: 'addProfile',
label: () => _('Create new profile...'),
};
export const runtime = (comp: any): CommandRuntime => {
return {
execute: async (context: CommandContext) => {
comp.setState({
promptOptions: {
label: _('Profile name:'),
buttons: ['create', 'cancel'],
value: '',
onClose: async (answer: string) => {
if (answer) {
const newConfig = await createNewProfile(context.state.profileConfig, answer);
newConfig.currentProfile = newConfig.profiles.length - 1;
await saveProfileConfig(`${Setting.value('rootProfileDir')}/profiles.json`, newConfig);
bridge().restart();
}
comp.setState({ promptOptions: null });
},
},
});
},
};
};

View File

@ -1,4 +1,5 @@
// AUTO-GENERATED using `gulp buildCommandIndex` // AUTO-GENERATED using `gulp buildCommandIndex`
import * as addProfile from './addProfile';
import * as commandPalette from './commandPalette'; import * as commandPalette from './commandPalette';
import * as editAlarm from './editAlarm'; import * as editAlarm from './editAlarm';
import * as exportPdf from './exportPdf'; import * as exportPdf from './exportPdf';
@ -38,6 +39,7 @@ import * as toggleSideBar from './toggleSideBar';
import * as toggleVisiblePanes from './toggleVisiblePanes'; import * as toggleVisiblePanes from './toggleVisiblePanes';
const index:any[] = [ const index:any[] = [
addProfile,
commandPalette, commandPalette,
editAlarm, editAlarm,
exportPdf, exportPdf,

View File

@ -1,4 +1,4 @@
import { useEffect, useState, useRef, useCallback } from 'react'; import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
import { AppState } from '../app.reducer'; import { AppState } from '../app.reducer';
import InteropService from '@joplin/lib/services/interop/InteropService'; import InteropService from '@joplin/lib/services/interop/InteropService';
import { stateUtils } from '@joplin/lib/reducer'; import { stateUtils } from '@joplin/lib/reducer';
@ -18,9 +18,9 @@ import menuCommandNames from './menuCommandNames';
import stateToWhenClauseContext from '../services/commands/stateToWhenClauseContext'; import stateToWhenClauseContext from '../services/commands/stateToWhenClauseContext';
import bridge from '../services/bridge'; import bridge from '../services/bridge';
import checkForUpdates from '../checkForUpdates'; import checkForUpdates from '../checkForUpdates';
const { connect } = require('react-redux'); const { connect } = require('react-redux');
import { reg } from '@joplin/lib/registry'; import { reg } from '@joplin/lib/registry';
import { ProfileConfig } from '../../lib/services/profileConfig/types';
const packageInfo = require('../packageInfo.js'); const packageInfo = require('../packageInfo.js');
const { clipboard } = require('electron'); const { clipboard } = require('electron');
const Menu = bridge().Menu; const Menu = bridge().Menu;
@ -39,7 +39,7 @@ function pluginMenuItemsCommandNames(menuItems: MenuItem[]): string[] {
return output; return output;
} }
function pluginCommandNames(plugins: PluginStates): string[] { function getPluginCommandNames(plugins: PluginStates): string[] {
let output: string[] = []; let output: string[] = [];
for (const view of pluginUtils.viewsByType(plugins, 'menu')) { for (const view of pluginUtils.viewsByType(plugins, 'menu')) {
@ -70,6 +70,42 @@ function createPluginMenuTree(label: string, menuItems: MenuItem[], onMenuItemCl
return output; return output;
} }
const useSwitchProfileMenuItems = (profileConfig: ProfileConfig, menuItemDic: any) => {
return useMemo(() => {
const switchProfileMenuItems: any[] = [];
for (let i = 0; i < profileConfig.profiles.length; i++) {
const profile = profileConfig.profiles[i];
let menuItem: any = {};
const profileNum = i + 1;
if (menuItemDic[`switchProfile${profileNum}`]) {
menuItem = { ...menuItemDic[`switchProfile${profileNum}`] };
} else {
menuItem = {
label: profile.name,
click: () => {
void CommandService.instance().execute('switchProfile', i);
},
};
}
menuItem.label = profile.name;
menuItem.type = 'checkbox';
menuItem.checked = profileConfig.currentProfile === i;
switchProfileMenuItems.push(menuItem);
}
switchProfileMenuItems.push({ type: 'separator' });
switchProfileMenuItems.push(menuItemDic.addProfile);
switchProfileMenuItems.push(menuItemDic.editProfileConfig);
return switchProfileMenuItems;
}, [profileConfig, menuItemDic]);
};
interface Props { interface Props {
dispatch: Function; dispatch: Function;
menuItemProps: any; menuItemProps: any;
@ -90,6 +126,7 @@ interface Props {
plugins: PluginStates; plugins: PluginStates;
customCss: string; customCss: string;
locale: string; locale: string;
profileConfig: ProfileConfig;
} }
const commandNames: string[] = menuCommandNames(); const commandNames: string[] = menuCommandNames();
@ -241,6 +278,18 @@ function useMenu(props: Props) {
const onImportModuleClickRef = useRef(null); const onImportModuleClickRef = useRef(null);
onImportModuleClickRef.current = onImportModuleClick; onImportModuleClickRef.current = onImportModuleClick;
const pluginCommandNames = useMemo(() => props.pluginMenuItems.map((view: any) => view.commandName), [props.pluginMenuItems]);
const menuItemDic = useMemo(() => {
return menuUtils.commandsToMenuItems(
commandNames.concat(pluginCommandNames),
(commandName: string) => onMenuItemClickRef.current(commandName),
props.locale
);
}, [commandNames, pluginCommandNames, props.locale]);
const switchProfileMenuItems: any[] = useSwitchProfileMenuItems(props.profileConfig, menuItemDic);
useEffect(() => { useEffect(() => {
let timeoutId: any = null; let timeoutId: any = null;
@ -249,13 +298,6 @@ function useMenu(props: Props) {
const keymapService = KeymapService.instance(); const keymapService = KeymapService.instance();
const pluginCommandNames = props.pluginMenuItems.map((view: any) => view.commandName);
const menuItemDic = menuUtils.commandsToMenuItems(
commandNames.concat(pluginCommandNames),
(commandName: string) => onMenuItemClickRef.current(commandName),
props.locale
);
const quitMenuItem = { const quitMenuItem = {
label: _('Quit'), label: _('Quit'),
accelerator: keymapService.getAccelerator('quit'), accelerator: keymapService.getAccelerator('quit'),
@ -385,6 +427,10 @@ function useMenu(props: Props) {
const newFolderItem = menuItemDic.newFolder; const newFolderItem = menuItemDic.newFolder;
const newSubFolderItem = menuItemDic.newSubFolder; const newSubFolderItem = menuItemDic.newSubFolder;
const printItem = menuItemDic.print; const printItem = menuItemDic.print;
const switchProfileItem = {
label: _('Switch profile'),
submenu: switchProfileMenuItems,
};
let toolsItems: any[] = []; let toolsItems: any[] = [];
@ -499,6 +545,8 @@ function useMenu(props: Props) {
platforms: ['darwin'], platforms: ['darwin'],
}, },
shim.isMac() ? noItem : switchProfileItem,
shim.isMac() ? { shim.isMac() ? {
label: _('Hide %s', 'Joplin'), label: _('Hide %s', 'Joplin'),
platforms: ['darwin'], platforms: ['darwin'],
@ -545,6 +593,7 @@ function useMenu(props: Props) {
type: 'separator', type: 'separator',
}, },
printItem, printItem,
switchProfileItem,
], ],
}; };
@ -848,7 +897,21 @@ function useMenu(props: Props) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
timeoutId = null; timeoutId = null;
}; };
}, [props.routeName, props.pluginMenuItems, props.pluginMenus, keymapLastChangeTime, modulesLastChangeTime, props['spellChecker.language'], props['spellChecker.enabled'], props.plugins, props.customCss, props.locale]); }, [
props.routeName,
props.pluginMenuItems,
props.pluginMenus,
keymapLastChangeTime,
modulesLastChangeTime,
props['spellChecker.language'],
props['spellChecker.enabled'],
props.plugins,
props.customCss,
props.locale,
props.profileConfig,
switchProfileMenuItems,
menuItemDic,
]);
useMenuStates(menu, props); useMenuStates(menu, props);
@ -889,7 +952,7 @@ const mapStateToProps = (state: AppState) => {
const whenClauseContext = stateToWhenClauseContext(state); const whenClauseContext = stateToWhenClauseContext(state);
return { return {
menuItemProps: menuUtils.commandsToMenuItemProps(commandNames.concat(pluginCommandNames(state.pluginService.plugins)), whenClauseContext), menuItemProps: menuUtils.commandsToMenuItemProps(commandNames.concat(getPluginCommandNames(state.pluginService.plugins)), whenClauseContext),
locale: state.settings.locale, locale: state.settings.locale,
routeName: state.route.routeName, routeName: state.route.routeName,
selectedFolderId: state.selectedFolderId, selectedFolderId: state.selectedFolderId,
@ -907,6 +970,7 @@ const mapStateToProps = (state: AppState) => {
['spellChecker.enabled']: state.settings['spellChecker.enabled'], ['spellChecker.enabled']: state.settings['spellChecker.enabled'],
plugins: state.pluginService.plugins, plugins: state.pluginService.plugins,
customCss: state.customCss, customCss: state.customCss,
profileConfig: state.profileConfig,
}; };
}; };

View File

@ -231,6 +231,13 @@ class PromptDialog extends React.Component {
} }
const buttonComps = []; const buttonComps = [];
if (buttonTypes.indexOf('create') >= 0) {
buttonComps.push(
<button key="create" disabled={!this.state.answer} style={styles.button} onClick={() => onClose(true, 'create')}>
{_('Create')}
</button>
);
}
if (buttonTypes.indexOf('ok') >= 0) { if (buttonTypes.indexOf('ok') >= 0) {
buttonComps.push( buttonComps.push(
<button key="ok" disabled={!this.state.answer} style={styles.button} onClick={() => onClose(true, 'ok')}> <button key="ok" disabled={!this.state.answer} style={styles.button} onClick={() => onClose(true, 'ok')}>

View File

@ -60,5 +60,10 @@ export default function() {
'gotoAnything', 'gotoAnything',
'commandPalette', 'commandPalette',
'openMasterPasswordDialog', 'openMasterPasswordDialog',
'addProfile',
'editProfileConfig',
'switchProfile1',
'switchProfile2',
'switchProfile3',
]; ];
} }

View File

@ -55,6 +55,8 @@ import SyncTargetNone from './SyncTargetNone';
import { setRSA } from './services/e2ee/ppk'; import { setRSA } from './services/e2ee/ppk';
import RSA from './services/e2ee/RSA.node'; import RSA from './services/e2ee/RSA.node';
import Resource from './models/Resource'; import Resource from './models/Resource';
import { ProfileConfig } from './services/profileConfig/types';
import initProfile from './services/profileConfig/initProfile';
const appLogger: LoggerWrapper = Logger.create('App'); const appLogger: LoggerWrapper = Logger.create('App');
@ -70,6 +72,7 @@ export default class BaseApplication {
private eventEmitter_: any; private eventEmitter_: any;
private scheduleAutoAddResourcesIID_: any = null; private scheduleAutoAddResourcesIID_: any = null;
private database_: any = null; private database_: any = null;
private profileConfig_: ProfileConfig = null;
protected showStackTraces_: boolean = false; protected showStackTraces_: boolean = false;
protected showPromptString_: boolean = false; protected showPromptString_: boolean = false;
@ -646,6 +649,12 @@ export default class BaseApplication {
public initRedux() { public initRedux() {
this.store_ = createStore(this.reducer, applyMiddleware(this.generalMiddlewareFn() as any)); this.store_ = createStore(this.reducer, applyMiddleware(this.generalMiddlewareFn() as any));
setStore(this.store_); setStore(this.store_);
this.store_.dispatch({
type: 'PROFILE_CONFIG_SET',
value: this.profileConfig_,
});
BaseModel.dispatch = this.store().dispatch; BaseModel.dispatch = this.store().dispatch;
FoldersScreenUtils.dispatch = this.store().dispatch; FoldersScreenUtils.dispatch = this.store().dispatch;
// reg.dispatch = this.store().dispatch; // reg.dispatch = this.store().dispatch;
@ -714,14 +723,16 @@ export default class BaseApplication {
// https://immerjs.github.io/immer/docs/freezing // https://immerjs.github.io/immer/docs/freezing
setAutoFreeze(initArgs.env === 'dev'); setAutoFreeze(initArgs.env === 'dev');
const profileDir = this.determineProfileDir(initArgs); const rootProfileDir = this.determineProfileDir(initArgs);
const { profileDir, profileConfig, isSubProfile } = await initProfile(rootProfileDir);
this.profileConfig_ = profileConfig;
const resourceDirName = 'resources'; const resourceDirName = 'resources';
const resourceDir = `${profileDir}/${resourceDirName}`; const resourceDir = `${profileDir}/${resourceDirName}`;
const tempDir = `${profileDir}/tmp`; const tempDir = `${profileDir}/tmp`;
const cacheDir = `${profileDir}/cache`; const cacheDir = `${profileDir}/cache`;
Setting.setConstant('env', initArgs.env); Setting.setConstant('env', initArgs.env);
Setting.setConstant('profileDir', profileDir);
Setting.setConstant('resourceDirName', resourceDirName); Setting.setConstant('resourceDirName', resourceDirName);
Setting.setConstant('resourceDir', resourceDir); Setting.setConstant('resourceDir', resourceDir);
Setting.setConstant('tempDir', tempDir); Setting.setConstant('tempDir', tempDir);
@ -778,6 +789,7 @@ export default class BaseApplication {
appLogger.info(`Profile directory: ${profileDir}`); appLogger.info(`Profile directory: ${profileDir}`);
appLogger.info(`Root profile directory: ${rootProfileDir}`);
this.database_ = new JoplinDatabase(new DatabaseDriverNode()); this.database_ = new JoplinDatabase(new DatabaseDriverNode());
this.database_.setLogExcludedQueryTypes(['SELECT']); this.database_.setLogExcludedQueryTypes(['SELECT']);
@ -838,6 +850,7 @@ export default class BaseApplication {
} }
if ('welcomeDisabled' in initArgs) Setting.setValue('welcome.enabled', !initArgs.welcomeDisabled); if ('welcomeDisabled' in initArgs) Setting.setValue('welcome.enabled', !initArgs.welcomeDisabled);
if (isSubProfile) Setting.setValue('welcome.enabled', false);
if (!Setting.value('api.token')) { if (!Setting.value('api.token')) {
void EncryptionService.instance() void EncryptionService.instance()

View File

@ -1,12 +1,28 @@
import Setting, { SettingSectionSource } from '../models/Setting'; import Setting, { SettingSectionSource, SettingStorage } from '../models/Setting';
import { setupDatabaseAndSynchronizer, switchClient, expectThrow, expectNotThrow, msleep } from '../testing/test-utils'; import { setupDatabaseAndSynchronizer, switchClient, expectThrow, expectNotThrow, msleep } from '../testing/test-utils';
import * as fs from 'fs-extra'; import { readFile, stat, mkdirp, writeFile, pathExists, readdir } from 'fs-extra';
import Logger from '../Logger'; import Logger from '../Logger';
import { defaultProfileConfig } from '../services/profileConfig/types';
import { createNewProfile, saveProfileConfig } from '../services/profileConfig';
import initProfile from '../services/profileConfig/initProfile';
async function loadSettingsFromFile(): Promise<any> { async function loadSettingsFromFile(): Promise<any> {
return JSON.parse(await fs.readFile(Setting.settingFilePath, 'utf8')); return JSON.parse(await readFile(Setting.settingFilePath, 'utf8'));
} }
const switchToSubProfileSettings = async () => {
await Setting.reset();
const rootProfileDir = Setting.value('profileDir');
const profileConfigPath = `${rootProfileDir}/profiles.json`;
let profileConfig = defaultProfileConfig();
profileConfig = createNewProfile(profileConfig, 'Sub-profile');
profileConfig.currentProfile = 1;
await saveProfileConfig(profileConfigPath, profileConfig);
const { profileDir } = await initProfile(rootProfileDir);
await mkdirp(profileDir);
await Setting.load();
};
describe('models/Setting', function() { describe('models/Setting', function() {
beforeEach(async (done) => { beforeEach(async (done) => {
@ -180,19 +196,19 @@ describe('models/Setting', function() {
{ {
// Double-check that timestamp is indeed changed when the content is // Double-check that timestamp is indeed changed when the content is
// changed. // changed.
const beforeStat = await fs.stat(Setting.settingFilePath); const beforeStat = await stat(Setting.settingFilePath);
await msleep(1001); await msleep(1001);
Setting.setValue('sync.mobileWifiOnly', false); Setting.setValue('sync.mobileWifiOnly', false);
await Setting.saveAll(); await Setting.saveAll();
const afterStat = await fs.stat(Setting.settingFilePath); const afterStat = await stat(Setting.settingFilePath);
expect(afterStat.mtime.getTime()).toBeGreaterThan(beforeStat.mtime.getTime()); expect(afterStat.mtime.getTime()).toBeGreaterThan(beforeStat.mtime.getTime());
} }
{ {
const beforeStat = await fs.stat(Setting.settingFilePath); const beforeStat = await stat(Setting.settingFilePath);
await msleep(1001); await msleep(1001);
Setting.setValue('sync.mobileWifiOnly', false); Setting.setValue('sync.mobileWifiOnly', false);
const afterStat = await fs.stat(Setting.settingFilePath); const afterStat = await stat(Setting.settingFilePath);
await Setting.saveAll(); await Setting.saveAll();
expect(afterStat.mtime.getTime()).toBe(beforeStat.mtime.getTime()); expect(afterStat.mtime.getTime()).toBe(beforeStat.mtime.getTime());
} }
@ -200,7 +216,7 @@ describe('models/Setting', function() {
it('should handle invalid JSON', (async () => { it('should handle invalid JSON', (async () => {
const badContent = '{ oopsIforgotTheQuotes: true}'; const badContent = '{ oopsIforgotTheQuotes: true}';
await fs.writeFile(Setting.settingFilePath, badContent, 'utf8'); await writeFile(Setting.settingFilePath, badContent, 'utf8');
await Setting.reset(); await Setting.reset();
Logger.globalLogger.enabled = false; Logger.globalLogger.enabled = false;
@ -208,12 +224,12 @@ describe('models/Setting', function() {
Logger.globalLogger.enabled = true; Logger.globalLogger.enabled = true;
// Invalid JSON file has been moved to .bak file // Invalid JSON file has been moved to .bak file
expect(await fs.pathExists(Setting.settingFilePath)).toBe(false); expect(await pathExists(Setting.settingFilePath)).toBe(false);
const files = await fs.readdir(Setting.value('profileDir')); const files = await readdir(Setting.value('profileDir'));
expect(files.length).toBe(1); expect(files.length).toBe(1);
expect(files[0].endsWith('.bak')).toBe(true); expect(files[0].endsWith('.bak')).toBe(true);
expect(await fs.readFile(`${Setting.value('profileDir')}/${files[0]}`, 'utf8')).toBe(badContent); expect(await readFile(`${Setting.value('profileDir')}/${files[0]}`, 'utf8')).toBe(badContent);
})); }));
it('should allow applying default migrations', (async () => { it('should allow applying default migrations', (async () => {
@ -256,4 +272,67 @@ describe('models/Setting', function() {
expect(Setting.value('style.editor.contentMaxWidth')).toBe(600); // Changed expect(Setting.value('style.editor.contentMaxWidth')).toBe(600); // Changed
})); }));
it('should load sub-profile settings', async () => {
await Setting.reset();
Setting.setValue('locale', 'fr_FR'); // Global setting
Setting.setValue('theme', Setting.THEME_DARK); // Global setting
Setting.setValue('sync.target', 9); // Local setting
await Setting.saveAll();
await switchToSubProfileSettings();
expect(Setting.value('locale')).toBe('fr_FR'); // Should come from the root profile
expect(Setting.value('theme')).toBe(Setting.THEME_DARK); // Should come from the root profile
expect(Setting.value('sync.target')).toBe(0); // Should come from the local profile
// Also check that the special loadOne() function works as expected
expect((await Setting.loadOne('locale')).value).toBe('fr_FR');
expect((await Setting.loadOne('theme')).value).toBe(Setting.THEME_DARK);
expect((await Setting.loadOne('sync.target')).value).toBe(undefined);
});
it('should save sub-profile settings', async () => {
await Setting.reset();
Setting.setValue('locale', 'fr_FR'); // Global setting
Setting.setValue('theme', Setting.THEME_DARK); // Global setting
await Setting.saveAll();
await switchToSubProfileSettings();
Setting.setValue('locale', 'en_GB'); // Should be saved to global
Setting.setValue('sync.target', 8); // Should be saved to local
await Setting.saveAll();
await Setting.reset();
await Setting.load();
expect(Setting.value('locale')).toBe('en_GB');
expect(Setting.value('theme')).toBe(Setting.THEME_DARK);
expect(Setting.value('sync.target')).toBe(8);
// Double-check that actual file content is correct
const globalSettings = JSON.parse(await readFile(`${Setting.value('rootProfileDir')}/settings-1.json`, 'utf8'));
const localSettings = JSON.parse(await readFile(`${Setting.value('profileDir')}/settings-1.json`, 'utf8'));
expect(globalSettings).toEqual({
'$schema': 'https://joplinapp.org/schema/settings.json',
locale: 'en_GB',
theme: 2,
});
expect(localSettings).toEqual({
'$schema': 'https://joplinapp.org/schema/settings.json',
'sync.target': 8,
});
});
it('all global settings should be saved to file', async () => {
for (const [k, v] of Object.entries(Setting.metadata())) {
if (v.isGlobal && v.storage !== SettingStorage.File) throw new Error(`Setting "${k}" is global but storage is not "file"`);
}
});
}); });

View File

@ -7,6 +7,8 @@ import SyncTargetRegistry from '../SyncTargetRegistry';
import time from '../time'; import time from '../time';
import FileHandler, { SettingValues } from './settings/FileHandler'; import FileHandler, { SettingValues } from './settings/FileHandler';
import Logger from '../Logger'; import Logger from '../Logger';
import mergeGlobalAndLocalSettings from '../services/profileConfig/mergeGlobalAndLocalSettings';
import splitGlobalAndLocalSettings from '../services/profileConfig/splitGlobalAndLocalSettings';
const { sprintf } = require('sprintf-js'); const { sprintf } = require('sprintf-js');
const ObjectUtils = require('../ObjectUtils'); const ObjectUtils = require('../ObjectUtils');
const { toTitleCase } = require('../string-utils.js'); const { toTitleCase } = require('../string-utils.js');
@ -59,6 +61,18 @@ export interface SettingItem {
autoSave?: boolean; autoSave?: boolean;
storage?: SettingStorage; storage?: SettingStorage;
hideLabel?: boolean; hideLabel?: boolean;
// In a multi-profile context, all settings are by default local - they take
// their value from the current profile. This flag can be set to specify
// that the setting is global and that its value should come from the root
// profile. This flag only applies to sub-profiles.
//
// At the moment, all global settings must be saved to file (have the
// storage attribute set to "file") because it's simpler to load the root
// profile settings.json than load the whole SQLite database. This
// restriction is not an issue normally since all settings that are
// considered global are also the user-facing ones.
isGlobal?: boolean;
} }
interface SettingItems { interface SettingItems {
@ -112,6 +126,7 @@ export interface Constants {
resourceDirName: string; resourceDirName: string;
resourceDir: string; resourceDir: string;
profileDir: string; profileDir: string;
rootProfileDir: string;
tempDir: string; tempDir: string;
pluginDataDir: string; pluginDataDir: string;
cacheDir: string; cacheDir: string;
@ -119,6 +134,7 @@ export interface Constants {
flagOpenDevTools: boolean; flagOpenDevTools: boolean;
syncVersion: number; syncVersion: number;
startupDevPlugins: string[]; startupDevPlugins: string[];
isSubProfile: boolean;
} }
interface SettingSections { interface SettingSections {
@ -243,6 +259,7 @@ class Setting extends BaseModel {
resourceDirName: '', resourceDirName: '',
resourceDir: '', resourceDir: '',
profileDir: '', profileDir: '',
rootProfileDir: '',
tempDir: '', tempDir: '',
pluginDataDir: '', pluginDataDir: '',
cacheDir: '', cacheDir: '',
@ -250,6 +267,7 @@ class Setting extends BaseModel {
flagOpenDevTools: false, flagOpenDevTools: false,
syncVersion: 3, syncVersion: 3,
startupDevPlugins: [], startupDevPlugins: [],
isSubProfile: false,
}; };
public static autoSaveEnabled = true; public static autoSaveEnabled = true;
@ -264,6 +282,7 @@ class Setting extends BaseModel {
private static customSections_: SettingSections = {}; private static customSections_: SettingSections = {};
private static changedKeys_: string[] = []; private static changedKeys_: string[] = [];
private static fileHandler_: FileHandler = null; private static fileHandler_: FileHandler = null;
private static rootFileHandler_: FileHandler = null;
private static settingFilename_: string = 'settings.json'; private static settingFilename_: string = 'settings.json';
static tableName() { static tableName() {
@ -291,6 +310,10 @@ class Setting extends BaseModel {
return `${this.value('profileDir')}/${this.settingFilename_}`; return `${this.value('profileDir')}/${this.settingFilename_}`;
} }
public static get rootSettingFilePath(): string {
return `${this.value('rootProfileDir')}/${this.settingFilename_}`;
}
public static get settingFilename(): string { public static get settingFilename(): string {
return this.settingFilename_; return this.settingFilename_;
} }
@ -306,6 +329,13 @@ class Setting extends BaseModel {
return this.fileHandler_; return this.fileHandler_;
} }
public static get rootFileHandler(): FileHandler {
if (!this.rootFileHandler_) {
this.rootFileHandler_ = new FileHandler(this.rootSettingFilePath);
}
return this.rootFileHandler_;
}
static keychainService() { static keychainService() {
if (!this.keychainService_) throw new Error('keychainService has not been set!!'); if (!this.keychainService_) throw new Error('keychainService has not been set!!');
return this.keychainService_; return this.keychainService_;
@ -359,6 +389,7 @@ class Setting extends BaseModel {
public: false, public: false,
appTypes: [AppType.Desktop], appTypes: [AppType.Desktop],
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
'sync.openSyncWizard': { 'sync.openSyncWizard': {
@ -669,6 +700,7 @@ class Setting extends BaseModel {
}; };
}, },
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
'sync.3.auth': { value: '', type: SettingItemType.String, public: false }, 'sync.3.auth': { value: '', type: SettingItemType.String, public: false },
@ -687,7 +719,7 @@ class Setting extends BaseModel {
'sync.9.context': { value: '', type: SettingItemType.String, public: false }, 'sync.9.context': { value: '', type: SettingItemType.String, public: false },
'sync.10.context': { value: '', type: SettingItemType.String, public: false }, 'sync.10.context': { value: '', type: SettingItemType.String, public: false },
'sync.maxConcurrentConnections': { value: 5, type: SettingItemType.Int, storage: SettingStorage.File, public: true, advanced: true, section: 'sync', label: () => _('Max concurrent connections'), minimum: 1, maximum: 20, step: 1 }, 'sync.maxConcurrentConnections': { value: 5, type: SettingItemType.Int, storage: SettingStorage.File, isGlobal: true, public: true, advanced: true, section: 'sync', label: () => _('Max concurrent connections'), minimum: 1, maximum: 20, step: 1 },
// The active folder ID is guaranteed to be valid as long as there's at least one // The active folder ID is guaranteed to be valid as long as there's at least one
// existing folder, so it is a good default in contexts where there's no currently // existing folder, so it is a good default in contexts where there's no currently
@ -695,7 +727,7 @@ class Setting extends BaseModel {
// to the last folder that was selected. // to the last folder that was selected.
activeFolderId: { value: '', type: SettingItemType.String, public: false }, activeFolderId: { value: '', type: SettingItemType.String, public: false },
richTextBannerDismissed: { value: false, type: SettingItemType.Bool, public: false }, richTextBannerDismissed: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, public: false },
firstStart: { value: true, type: SettingItemType.Bool, public: false }, firstStart: { value: true, type: SettingItemType.Bool, public: false },
locale: { locale: {
@ -708,6 +740,7 @@ class Setting extends BaseModel {
return ObjectUtils.sortByValue(supportedLocalesToLanguages({ includeStats: true })); return ObjectUtils.sortByValue(supportedLocalesToLanguages({ includeStats: true }));
}, },
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
dateFormat: { dateFormat: {
value: Setting.DATE_FORMAT_1, value: Setting.DATE_FORMAT_1,
@ -730,6 +763,7 @@ class Setting extends BaseModel {
return options; return options;
}, },
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
timeFormat: { timeFormat: {
value: Setting.TIME_FORMAT_1, value: Setting.TIME_FORMAT_1,
@ -746,6 +780,7 @@ class Setting extends BaseModel {
return options; return options;
}, },
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
theme: { theme: {
@ -761,6 +796,7 @@ class Setting extends BaseModel {
section: 'appearance', section: 'appearance',
options: () => themeOptions(), options: () => themeOptions(),
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
themeAutoDetect: { themeAutoDetect: {
@ -771,6 +807,7 @@ class Setting extends BaseModel {
public: true, public: true,
label: () => _('Automatically switch theme to match system theme'), label: () => _('Automatically switch theme to match system theme'),
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
preferredLightTheme: { preferredLightTheme: {
@ -786,6 +823,7 @@ class Setting extends BaseModel {
section: 'appearance', section: 'appearance',
options: () => themeOptions(), options: () => themeOptions(),
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
preferredDarkTheme: { preferredDarkTheme: {
@ -801,6 +839,7 @@ class Setting extends BaseModel {
section: 'appearance', section: 'appearance',
options: () => themeOptions(), options: () => themeOptions(),
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
notificationPermission: { notificationPermission: {
@ -809,7 +848,7 @@ class Setting extends BaseModel {
public: false, public: false,
}, },
showNoteCounts: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, public: false, advanced: true, appTypes: [AppType.Desktop], label: () => _('Show note counts') }, showNoteCounts: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, public: false, advanced: true, appTypes: [AppType.Desktop], label: () => _('Show note counts') },
layoutButtonSequence: { layoutButtonSequence: {
value: Setting.LAYOUT_ALL, value: Setting.LAYOUT_ALL,
@ -824,9 +863,10 @@ class Setting extends BaseModel {
[Setting.LAYOUT_VIEWER_SPLIT]: _('%s / %s', _('Viewer'), _('Split View')), [Setting.LAYOUT_VIEWER_SPLIT]: _('%s / %s', _('Viewer'), _('Split View')),
}), }),
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
uncompletedTodosOnTop: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, appTypes: [AppType.Cli], label: () => _('Uncompleted to-dos on top') }, uncompletedTodosOnTop: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'note', public: true, appTypes: [AppType.Cli], label: () => _('Uncompleted to-dos on top') },
showCompletedTodos: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, appTypes: [AppType.Cli], label: () => _('Show completed to-dos') }, showCompletedTodos: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'note', public: true, appTypes: [AppType.Cli], label: () => _('Show completed to-dos') },
'notes.sortOrder.field': { 'notes.sortOrder.field': {
value: 'user_updated_time', value: 'user_updated_time',
type: SettingItemType.String, type: SettingItemType.String,
@ -845,6 +885,7 @@ class Setting extends BaseModel {
return options; return options;
}, },
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
'editor.autoMatchingBraces': { 'editor.autoMatchingBraces': {
value: true, value: true,
@ -854,8 +895,9 @@ class Setting extends BaseModel {
appTypes: [AppType.Desktop], appTypes: [AppType.Desktop],
label: () => _('Auto-pair braces, parenthesis, quotations, etc.'), label: () => _('Auto-pair braces, parenthesis, quotations, etc.'),
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
'notes.sortOrder.reverse': { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] }, 'notes.sortOrder.reverse': { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'note', public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] },
// NOTE: A setting whose name starts with 'notes.sortOrder' is special, // NOTE: A setting whose name starts with 'notes.sortOrder' is special,
// which implies changing the setting automatically triggers the reflesh of notes. // which implies changing the setting automatically triggers the reflesh of notes.
// See lib/BaseApplication.ts/generalMiddleware() for details. // See lib/BaseApplication.ts/generalMiddleware() for details.
@ -868,6 +910,7 @@ class Setting extends BaseModel {
label: () => _('Show sort order buttons'), label: () => _('Show sort order buttons'),
// description: () => _('If true, sort order buttons (field + reverse) for notes are shown at the top of Note List.'), // description: () => _('If true, sort order buttons (field + reverse) for notes are shown at the top of Note List.'),
appTypes: [AppType.Desktop], appTypes: [AppType.Desktop],
isGlobal: true,
}, },
'notes.perFieldReversalEnabled': { 'notes.perFieldReversalEnabled': {
value: true, value: true,
@ -931,8 +974,8 @@ class Setting extends BaseModel {
}, },
storage: SettingStorage.File, storage: SettingStorage.File,
}, },
'folders.sortOrder.reverse': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] }, 'folders.sortOrder.reverse': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, public: true, label: () => _('Reverse sort order'), appTypes: [AppType.Cli] },
trackLocation: { value: true, type: SettingItemType.Bool, section: 'note', storage: SettingStorage.File, public: true, label: () => _('Save geo-location with notes') }, trackLocation: { value: true, type: SettingItemType.Bool, section: 'note', storage: SettingStorage.File, isGlobal: true, public: true, label: () => _('Save geo-location with notes') },
// 2020-10-29: For now disable the beta editor due to // 2020-10-29: For now disable the beta editor due to
// underlying bugs in the TextInput component which we cannot // underlying bugs in the TextInput component which we cannot
@ -947,6 +990,8 @@ class Setting extends BaseModel {
appTypes: [AppType.Mobile], appTypes: [AppType.Mobile],
label: () => 'Opt-in to the editor beta', label: () => 'Opt-in to the editor beta',
description: () => 'This beta adds list continuation and syntax highlighting. If you find bugs, please report them in the Discourse forum.', description: () => 'This beta adds list continuation and syntax highlighting. If you find bugs, please report them in the Discourse forum.',
storage: SettingStorage.File,
isGlobal: true,
}, },
newTodoFocus: { newTodoFocus: {
@ -964,6 +1009,7 @@ class Setting extends BaseModel {
}; };
}, },
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
newNoteFocus: { newNoteFocus: {
value: 'body', value: 'body',
@ -980,6 +1026,7 @@ class Setting extends BaseModel {
}; };
}, },
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
'plugins.states': { 'plugins.states': {
@ -1005,31 +1052,31 @@ class Setting extends BaseModel {
}, },
// Deprecated - use markdown.plugin.* // Deprecated - use markdown.plugin.*
'markdown.softbreaks': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, public: false, appTypes: [AppType.Mobile, AppType.Desktop] }, 'markdown.softbreaks': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, public: false, appTypes: [AppType.Mobile, AppType.Desktop] },
'markdown.typographer': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, public: false, appTypes: [AppType.Mobile, AppType.Desktop] }, 'markdown.typographer': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, public: false, appTypes: [AppType.Mobile, AppType.Desktop] },
// Deprecated // Deprecated
'markdown.plugin.softbreaks': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable soft breaks')}${wysiwygYes}` }, 'markdown.plugin.softbreaks': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable soft breaks')}${wysiwygYes}` },
'markdown.plugin.typographer': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable typographer support')}${wysiwygYes}` }, 'markdown.plugin.typographer': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable typographer support')}${wysiwygYes}` },
'markdown.plugin.linkify': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Linkify')}${wysiwygYes}` }, 'markdown.plugin.linkify': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Linkify')}${wysiwygYes}` },
'markdown.plugin.katex': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable math expressions')}${wysiwygYes}` }, 'markdown.plugin.katex': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable math expressions')}${wysiwygYes}` },
'markdown.plugin.fountain': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Fountain syntax support')}${wysiwygYes}` }, 'markdown.plugin.fountain': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Fountain syntax support')}${wysiwygYes}` },
'markdown.plugin.mermaid': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Mermaid diagrams support')}${wysiwygYes}` }, 'markdown.plugin.mermaid': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Mermaid diagrams support')}${wysiwygYes}` },
'markdown.plugin.audioPlayer': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable audio player')}${wysiwygNo}` }, 'markdown.plugin.audioPlayer': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable audio player')}${wysiwygNo}` },
'markdown.plugin.videoPlayer': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable video player')}${wysiwygNo}` }, 'markdown.plugin.videoPlayer': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable video player')}${wysiwygNo}` },
'markdown.plugin.pdfViewer': { storage: SettingStorage.File, value: !mobilePlatform, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Desktop], label: () => `${_('Enable PDF viewer')}${wysiwygNo}` }, 'markdown.plugin.pdfViewer': { storage: SettingStorage.File, isGlobal: true, value: !mobilePlatform, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Desktop], label: () => `${_('Enable PDF viewer')}${wysiwygNo}` },
'markdown.plugin.mark': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ==mark== syntax')}${wysiwygYes}` }, 'markdown.plugin.mark': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ==mark== syntax')}${wysiwygYes}` },
'markdown.plugin.footnote': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable footnotes')}${wysiwygNo}` }, 'markdown.plugin.footnote': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable footnotes')}${wysiwygNo}` },
'markdown.plugin.toc': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable table of contents extension')}${wysiwygNo}` }, 'markdown.plugin.toc': { storage: SettingStorage.File, isGlobal: true, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable table of contents extension')}${wysiwygNo}` },
'markdown.plugin.sub': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ~sub~ syntax')}${wysiwygYes}` }, 'markdown.plugin.sub': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ~sub~ syntax')}${wysiwygYes}` },
'markdown.plugin.sup': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ^sup^ syntax')}${wysiwygYes}` }, 'markdown.plugin.sup': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ^sup^ syntax')}${wysiwygYes}` },
'markdown.plugin.deflist': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable deflist syntax')}${wysiwygNo}` }, 'markdown.plugin.deflist': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable deflist syntax')}${wysiwygNo}` },
'markdown.plugin.abbr': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable abbreviation syntax')}${wysiwygNo}` }, 'markdown.plugin.abbr': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable abbreviation syntax')}${wysiwygNo}` },
'markdown.plugin.emoji': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable markdown emoji')}${wysiwygNo}` }, 'markdown.plugin.emoji': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable markdown emoji')}${wysiwygNo}` },
'markdown.plugin.insert': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ++insert++ syntax')}${wysiwygYes}` }, 'markdown.plugin.insert': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable ++insert++ syntax')}${wysiwygYes}` },
'markdown.plugin.multitable': { storage: SettingStorage.File, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable multimarkdown table extension')}${wysiwygNo}` }, 'markdown.plugin.multitable': { storage: SettingStorage.File, isGlobal: true, value: false, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable multimarkdown table extension')}${wysiwygNo}` },
// Tray icon (called AppIndicator) doesn't work in Ubuntu // Tray icon (called AppIndicator) doesn't work in Ubuntu
// http://www.webupd8.org/2017/04/fix-appindicator-not-working-for.html // http://www.webupd8.org/2017/04/fix-appindicator-not-working-for.html
@ -1046,9 +1093,10 @@ class Setting extends BaseModel {
return platform === 'linux' ? _('Note: Does not work in all desktop environments.') : _('This will allow Joplin to run in the background. It is recommended to enable this setting so that your notes are constantly being synchronised, thus reducing the number of conflicts.'); return platform === 'linux' ? _('Note: Does not work in all desktop environments.') : _('This will allow Joplin to run in the background. It is recommended to enable this setting so that your notes are constantly being synchronised, thus reducing the number of conflicts.');
}, },
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
startMinimized: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'application', public: true, appTypes: [AppType.Desktop], label: () => _('Start application minimised in the tray icon') }, startMinimized: { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'application', public: true, appTypes: [AppType.Desktop], label: () => _('Start application minimised in the tray icon') },
collapsedFolderIds: { value: [], type: SettingItemType.Array, public: false }, collapsedFolderIds: { value: [], type: SettingItemType.Array, public: false },
@ -1072,9 +1120,9 @@ class Setting extends BaseModel {
}, },
// Deprecated in favour of windowContentZoomFactor // Deprecated in favour of windowContentZoomFactor
'style.zoom': { value: 100, type: SettingItemType.Int, public: false, storage: SettingStorage.File, appTypes: [AppType.Desktop], section: 'appearance', label: () => '', minimum: 50, maximum: 500, step: 10 }, 'style.zoom': { value: 100, type: SettingItemType.Int, public: false, storage: SettingStorage.File, isGlobal: true, appTypes: [AppType.Desktop], section: 'appearance', label: () => '', minimum: 50, maximum: 500, step: 10 },
'style.editor.fontSize': { value: 15, type: SettingItemType.Int, public: true, storage: SettingStorage.File, appTypes: [AppType.Desktop], section: 'appearance', label: () => _('Editor font size'), minimum: 4, maximum: 50, step: 1 }, 'style.editor.fontSize': { value: 15, type: SettingItemType.Int, public: true, storage: SettingStorage.File, isGlobal: true, appTypes: [AppType.Desktop], section: 'appearance', label: () => _('Editor font size'), minimum: 4, maximum: 50, step: 1 },
'style.editor.fontFamily': 'style.editor.fontFamily':
(mobilePlatform) ? (mobilePlatform) ?
({ ({
@ -1101,6 +1149,7 @@ class Setting extends BaseModel {
}; };
}, },
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}) : { }) : {
value: '', value: '',
type: SettingItemType.String, type: SettingItemType.String,
@ -1111,6 +1160,7 @@ class Setting extends BaseModel {
description: () => description: () =>
_('Used for most text in the markdown editor. If not found, a generic proportional (variable width) font is used.'), _('Used for most text in the markdown editor. If not found, a generic proportional (variable width) font is used.'),
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
'style.editor.monospaceFontFamily': { 'style.editor.monospaceFontFamily': {
value: '', value: '',
@ -1122,9 +1172,10 @@ class Setting extends BaseModel {
description: () => description: () =>
_('Used where a fixed width font is needed to lay out text legibly (e.g. tables, checkboxes, code). If not found, a generic monospace (fixed width) font is used.'), _('Used where a fixed width font is needed to lay out text legibly (e.g. tables, checkboxes, code). If not found, a generic monospace (fixed width) font is used.'),
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
'style.editor.contentMaxWidth': { value: 0, type: SettingItemType.Int, public: true, storage: SettingStorage.File, appTypes: [AppType.Desktop], section: 'appearance', label: () => _('Editor maximum width'), description: () => _('Set it to 0 to make it take the complete available space. Recommended width is 600.') }, 'style.editor.contentMaxWidth': { value: 0, type: SettingItemType.Int, public: true, storage: SettingStorage.File, isGlobal: true,appTypes: [AppType.Desktop], section: 'appearance', label: () => _('Editor maximum width'), description: () => _('Set it to 0 to make it take the complete available space. Recommended width is 600.') },
'ui.layout': { value: {}, type: SettingItemType.Object, storage: SettingStorage.File, public: false, appTypes: [AppType.Desktop] }, 'ui.layout': { value: {}, type: SettingItemType.Object, storage: SettingStorage.File, public: false, appTypes: [AppType.Desktop] },
@ -1149,6 +1200,8 @@ class Setting extends BaseModel {
label: () => _('Custom stylesheet for rendered Markdown'), label: () => _('Custom stylesheet for rendered Markdown'),
section: 'appearance', section: 'appearance',
advanced: true, advanced: true,
storage: SettingStorage.File,
isGlobal: true,
}, },
'style.customCss.joplinApp': { 'style.customCss.joplinApp': {
value: null, value: null,
@ -1167,6 +1220,8 @@ class Setting extends BaseModel {
section: 'appearance', section: 'appearance',
advanced: true, advanced: true,
description: () => 'CSS file support is provided for your convenience, but they are advanced settings, and styles you define may break from one version to the next. If you want to use them, please know that it might require regular development work from you to keep them working. The Joplin team cannot make a commitment to keep the application HTML structure stable.', description: () => 'CSS file support is provided for your convenience, but they are advanced settings, and styles you define may break from one version to the next. If you want to use them, please know that it might require regular development work from you to keep them working. The Joplin team cannot make a commitment to keep the application HTML structure stable.',
storage: SettingStorage.File,
isGlobal: true,
}, },
'sync.clearLocalSyncStateButton': { 'sync.clearLocalSyncStateButton': {
@ -1192,9 +1247,9 @@ class Setting extends BaseModel {
}, },
autoUpdateEnabled: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'application', public: platform !== 'linux', appTypes: [AppType.Desktop], label: () => _('Automatically check for updates') }, autoUpdateEnabled: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, section: 'application', public: platform !== 'linux', appTypes: [AppType.Desktop], label: () => _('Automatically check for updates') },
'autoUpdate.includePreReleases': { value: false, type: SettingItemType.Bool, section: 'application', storage: SettingStorage.File, public: true, appTypes: [AppType.Desktop], label: () => _('Get pre-releases when checking for updates'), description: () => _('See the pre-release page for more details: %s', 'https://joplinapp.org/prereleases') }, 'autoUpdate.includePreReleases': { value: false, type: SettingItemType.Bool, section: 'application', storage: SettingStorage.File, isGlobal: true, public: true, appTypes: [AppType.Desktop], label: () => _('Get pre-releases when checking for updates'), description: () => _('See the pre-release page for more details: %s', 'https://joplinapp.org/prereleases') },
'clipperServer.autoStart': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, public: false }, 'clipperServer.autoStart': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, isGlobal: true, public: false },
'sync.interval': { 'sync.interval': {
value: 300, value: 300,
type: SettingItemType.Int, type: SettingItemType.Int,
@ -1214,6 +1269,7 @@ class Setting extends BaseModel {
}; };
}, },
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
'sync.mobileWifiOnly': { 'sync.mobileWifiOnly': {
value: false, value: false,
@ -1223,12 +1279,13 @@ class Setting extends BaseModel {
label: () => _('Synchronise only over WiFi connection'), label: () => _('Synchronise only over WiFi connection'),
storage: SettingStorage.File, storage: SettingStorage.File,
appTypes: [AppType.Mobile], appTypes: [AppType.Mobile],
isGlobal: true,
}, },
noteVisiblePanes: { value: ['editor', 'viewer'], type: SettingItemType.Array, storage: SettingStorage.File, public: false, appTypes: [AppType.Desktop] }, noteVisiblePanes: { value: ['editor', 'viewer'], type: SettingItemType.Array, storage: SettingStorage.File, isGlobal: true, public: false, appTypes: [AppType.Desktop] },
tagHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: [AppType.Desktop] }, tagHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: [AppType.Desktop] },
folderHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: [AppType.Desktop] }, folderHeaderIsExpanded: { value: true, type: SettingItemType.Bool, public: false, appTypes: [AppType.Desktop] },
editor: { value: '', type: SettingItemType.String, subType: 'file_path_and_args', storage: SettingStorage.File, public: true, appTypes: [AppType.Cli, AppType.Desktop], label: () => _('Text editor command'), description: () => _('The editor command (may include arguments) that will be used to open a note. If none is provided it will try to auto-detect the default editor.') }, editor: { value: '', type: SettingItemType.String, subType: 'file_path_and_args', storage: SettingStorage.File, isGlobal: true, public: true, appTypes: [AppType.Cli, AppType.Desktop], label: () => _('Text editor command'), description: () => _('The editor command (may include arguments) that will be used to open a note. If none is provided it will try to auto-detect the default editor.') },
'export.pdfPageSize': { value: 'A4', type: SettingItemType.String, advanced: true, storage: SettingStorage.File, isEnum: true, public: true, appTypes: [AppType.Desktop], label: () => _('Page size for PDF export'), options: () => { 'export.pdfPageSize': { value: 'A4', type: SettingItemType.String, advanced: true, storage: SettingStorage.File, isGlobal: true, isEnum: true, public: true, appTypes: [AppType.Desktop], label: () => _('Page size for PDF export'), options: () => {
return { return {
'A4': _('A4'), 'A4': _('A4'),
'Letter': _('Letter'), 'Letter': _('Letter'),
@ -1238,7 +1295,7 @@ class Setting extends BaseModel {
'Legal': _('Legal'), 'Legal': _('Legal'),
}; };
} }, } },
'export.pdfPageOrientation': { value: 'portrait', type: SettingItemType.String, storage: SettingStorage.File, advanced: true, isEnum: true, public: true, appTypes: [AppType.Desktop], label: () => _('Page orientation for PDF export'), options: () => { 'export.pdfPageOrientation': { value: 'portrait', type: SettingItemType.String, storage: SettingStorage.File, isGlobal: true, advanced: true, isEnum: true, public: true, appTypes: [AppType.Desktop], label: () => _('Page orientation for PDF export'), options: () => {
return { return {
'portrait': _('Portrait'), 'portrait': _('Portrait'),
'landscape': _('Landscape'), 'landscape': _('Landscape'),
@ -1261,6 +1318,7 @@ class Setting extends BaseModel {
return output; return output;
}, },
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
'editor.spellcheckBeta': { 'editor.spellcheckBeta': {
@ -1270,6 +1328,8 @@ class Setting extends BaseModel {
appTypes: [AppType.Desktop], appTypes: [AppType.Desktop],
label: () => 'Enable spell checking in Markdown editor? (WARNING BETA feature)', label: () => 'Enable spell checking in Markdown editor? (WARNING BETA feature)',
description: () => 'Spell checker in the Markdown editor was previously unstable (cursor location was not stable, sometimes edits would not be saved or reflected in the viewer, etc.) however it appears to be more reliable now. If you notice any issue, please report it on GitHub or the Joplin Forum (Help -> Joplin Forum)', description: () => 'Spell checker in the Markdown editor was previously unstable (cursor location was not stable, sometimes edits would not be saved or reflected in the viewer, etc.) however it appears to be more reliable now. If you notice any issue, please report it on GitHub or the Joplin Forum (Help -> Joplin Forum)',
storage: SettingStorage.File,
isGlobal: true,
}, },
'net.customCertificates': { 'net.customCertificates': {
@ -1324,8 +1384,8 @@ class Setting extends BaseModel {
storage: SettingStorage.File, storage: SettingStorage.File,
}, },
'api.token': { value: null, type: SettingItemType.String, public: false, storage: SettingStorage.File }, 'api.token': { value: null, type: SettingItemType.String, public: false, storage: SettingStorage.File, isGlobal: true },
'api.port': { value: null, type: SettingItemType.Int, storage: SettingStorage.File, public: true, appTypes: [AppType.Cli], description: () => _('Specify the port that should be used by the API server. If not set, a default will be used.') }, 'api.port': { value: null, type: SettingItemType.Int, storage: SettingStorage.File, isGlobal: true, public: true, appTypes: [AppType.Cli], description: () => _('Specify the port that should be used by the API server. If not set, a default will be used.') },
'resourceService.lastProcessedChangeId': { value: 0, type: SettingItemType.Int, public: false }, 'resourceService.lastProcessedChangeId': { value: 0, type: SettingItemType.Int, public: false },
'searchEngine.lastProcessedChangeId': { value: 0, type: SettingItemType.Int, public: false }, 'searchEngine.lastProcessedChangeId': { value: 0, type: SettingItemType.Int, public: false },
@ -1357,8 +1417,8 @@ class Setting extends BaseModel {
'camera.type': { value: 0, type: SettingItemType.Int, public: false, appTypes: [AppType.Mobile] }, 'camera.type': { value: 0, type: SettingItemType.Int, public: false, appTypes: [AppType.Mobile] },
'camera.ratio': { value: '4:3', type: SettingItemType.String, public: false, appTypes: [AppType.Mobile] }, 'camera.ratio': { value: '4:3', type: SettingItemType.String, public: false, appTypes: [AppType.Mobile] },
'spellChecker.enabled': { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, public: false }, 'spellChecker.enabled': { value: true, type: SettingItemType.Bool, isGlobal: true, storage: SettingStorage.File, public: false },
'spellChecker.language': { value: '', type: SettingItemType.String, storage: SettingStorage.File, public: false }, 'spellChecker.language': { value: '', type: SettingItemType.String, isGlobal: true, storage: SettingStorage.File, public: false },
windowContentZoomFactor: { windowContentZoomFactor: {
value: 100, value: 100,
@ -1369,6 +1429,7 @@ class Setting extends BaseModel {
maximum: 300, maximum: 300,
step: 10, step: 10,
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
'layout.folderList.factor': { 'layout.folderList.factor': {
@ -1384,6 +1445,7 @@ class Setting extends BaseModel {
'Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.' + 'Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.' +
'Restart app to see changes.'), 'Restart app to see changes.'),
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
'layout.noteList.factor': { 'layout.noteList.factor': {
value: 1, value: 1,
@ -1398,6 +1460,7 @@ class Setting extends BaseModel {
'Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.' + 'Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.' +
'Restart app to see changes.'), 'Restart app to see changes.'),
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
'layout.note.factor': { 'layout.note.factor': {
value: 2, value: 2,
@ -1412,6 +1475,7 @@ class Setting extends BaseModel {
'Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.' + 'Thus an item with a factor of 2 will take twice as much space as an item with a factor of 1.' +
'Restart app to see changes.'), 'Restart app to see changes.'),
storage: SettingStorage.File, storage: SettingStorage.File,
isGlobal: true,
}, },
'syncInfoCache': { 'syncInfoCache': {
@ -1452,9 +1516,17 @@ class Setting extends BaseModel {
this.metadata_ = Object.assign(this.metadata_, this.customMetadata_); this.metadata_ = Object.assign(this.metadata_, this.customMetadata_);
if (this.value('env') === Env.Dev) this.validateMetadata(this.metadata_);
return this.metadata_; return this.metadata_;
} }
private static validateMetadata(md: SettingItems) {
for (const [k, v] of Object.entries(md)) {
if (v.isGlobal && v.storage !== SettingStorage.File) throw new Error(`Setting "${k}" is global but storage is not "file"`);
}
}
public static skipDefaultMigrations() { public static skipDefaultMigrations() {
logger.info('Skipping all default migrations...'); logger.info('Skipping all default migrations...');
@ -1594,10 +1666,17 @@ class Setting extends BaseModel {
// Low-level method to load a setting directly from the database. Should not be used in most cases. // Low-level method to load a setting directly from the database. Should not be used in most cases.
public static async loadOne(key: string): Promise<CacheItem | null> { public static async loadOne(key: string): Promise<CacheItem | null> {
if (this.keyStorage(key) === SettingStorage.File) { if (this.keyStorage(key) === SettingStorage.File) {
const fromFile = await this.fileHandler.load(); let fileSettings = await this.fileHandler.load();
const md = this.settingMetadata(key);
if (md.isGlobal) {
const rootFileSettings = await this.rootFileHandler.load();
fileSettings = mergeGlobalAndLocalSettings(rootFileSettings, fileSettings);
}
return { return {
key, key,
value: fromFile[key], value: fileSettings[key],
}; };
} }
@ -1664,11 +1743,17 @@ class Setting extends BaseModel {
const itemsFromFile: CacheItem[] = []; const itemsFromFile: CacheItem[] = [];
if (this.canUseFileStorage()) { if (this.canUseFileStorage()) {
const fromFile = await this.fileHandler.load(); let fileSettings = await this.fileHandler.load();
for (const k of Object.keys(fromFile)) {
if (this.value('isSubProfile')) {
const rootFileSettings = await this.rootFileHandler.load();
fileSettings = mergeGlobalAndLocalSettings(rootFileSettings, fileSettings);
}
for (const k of Object.keys(fileSettings)) {
itemsFromFile.push({ itemsFromFile.push({
key: k, key: k,
value: fromFile[k], value: fileSettings[k],
}); });
} }
} }
@ -2018,7 +2103,15 @@ class Setting extends BaseModel {
await BaseModel.db().transactionExecBatch(queries); await BaseModel.db().transactionExecBatch(queries);
if (this.canUseFileStorage()) await this.fileHandler.save(valuesForFile); if (this.canUseFileStorage()) {
if (this.value('isSubProfile')) {
const { globalSettings, localSettings } = splitGlobalAndLocalSettings(valuesForFile);
await this.rootFileHandler.save(globalSettings);
await this.fileHandler.save(localSettings);
} else {
await this.fileHandler.save(valuesForFile);
}
}
logger.debug('Settings have been saved.'); logger.debug('Settings have been saved.');
} }

View File

@ -37,6 +37,7 @@
"@joplin/renderer": "~2.7", "@joplin/renderer": "~2.7",
"@joplin/turndown": "^4.0.61", "@joplin/turndown": "^4.0.61",
"@joplin/turndown-plugin-gfm": "^1.0.43", "@joplin/turndown-plugin-gfm": "^1.0.43",
"@types/nanoid": "^3.0.0",
"async-mutex": "^0.1.3", "async-mutex": "^0.1.3",
"base-64": "^0.1.0", "base-64": "^0.1.0",
"base64-stream": "^1.0.0", "base64-stream": "^1.0.0",

View File

@ -5,6 +5,7 @@ import Note from './models/Note';
import Folder from './models/Folder'; import Folder from './models/Folder';
import BaseModel from './BaseModel'; import BaseModel from './BaseModel';
import { Store } from 'redux'; import { Store } from 'redux';
import { ProfileConfig } from './services/profileConfig/types';
const ArrayUtils = require('./ArrayUtils.js'); const ArrayUtils = require('./ArrayUtils.js');
const { ALL_NOTES_FILTER_ID } = require('./reserved-ids'); const { ALL_NOTES_FILTER_ID } = require('./reserved-ids');
const { createSelectorCreator, defaultMemoize } = require('reselect'); const { createSelectorCreator, defaultMemoize } = require('reselect');
@ -92,6 +93,7 @@ export interface State {
isInsertingNotes: boolean; isInsertingNotes: boolean;
hasEncryptedItems: boolean; hasEncryptedItems: boolean;
needApiAuth: boolean; needApiAuth: boolean;
profileConfig: ProfileConfig;
// Extra reducer keys go here: // Extra reducer keys go here:
pluginService: PluginServiceState; pluginService: PluginServiceState;
@ -162,6 +164,7 @@ export const defaultState: State = {
isInsertingNotes: false, isInsertingNotes: false,
hasEncryptedItems: false, hasEncryptedItems: false,
needApiAuth: false, needApiAuth: false,
profileConfig: null,
pluginService: pluginServiceDefaultState, pluginService: pluginServiceDefaultState,
shareService: shareServiceDefaultState, shareService: shareServiceDefaultState,
@ -1138,6 +1141,10 @@ const reducer = produce((draft: Draft<State> = defaultState, action: any) => {
draft.needApiAuth = action.value; draft.needApiAuth = action.value;
break; break;
case 'PROFILE_CONFIG_SET':
draft.profileConfig = action.value;
break;
} }
} catch (error) { } catch (error) {
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`; error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;

View File

@ -1,14 +1,12 @@
import Logger from '../Logger'; import Logger from '../Logger';
import Setting from '../models/Setting'; import Setting from '../models/Setting';
import shim from '../shim'; import shim from '../shim';
import { fileExtension, basename, toSystemSlashes } from '../path-utils'; import { basename, toSystemSlashes } from '../path-utils';
import time from '../time'; import time from '../time';
import { NoteEntity } from './database/types'; import { NoteEntity } from './database/types';
import Note from '../models/Note'; import Note from '../models/Note';
import { openFileWithExternalEditor } from './ExternalEditWatcher/utils';
const EventEmitter = require('events'); const EventEmitter = require('events');
const { splitCommandString } = require('../string-utils');
const spawn = require('child_process').spawn;
const chokidar = require('chokidar'); const chokidar = require('chokidar');
const { ErrorNotFound } = require('./rest/utils/errors'); const { ErrorNotFound } = require('./rest/utils/errors');
@ -213,68 +211,6 @@ export default class ExternalEditWatcher {
return false; return false;
} }
textEditorCommand() {
const editorCommand = Setting.value('editor');
if (!editorCommand) return null;
const s = splitCommandString(editorCommand, { handleEscape: false });
const path = s.splice(0, 1);
if (!path.length) throw new Error(`Invalid editor command: ${editorCommand}`);
return {
path: path[0],
args: s,
};
}
async spawnCommand(path: string, args: string[], options: any) {
return new Promise((resolve, reject) => {
// App bundles need to be opened using the `open` command.
// Additional args can be specified after --args, and the
// -n flag is needed to ensure that the app is always launched
// with the arguments. Without it, if the app is already opened,
// it will just bring it to the foreground without opening the file.
// So the full command is:
//
// open -n /path/to/editor.app --args -app-flag -bla /path/to/file.md
//
if (shim.isMac() && fileExtension(path) === 'app') {
args = args.slice();
args.splice(0, 0, '--args');
args.splice(0, 0, path);
args.splice(0, 0, '-n');
path = 'open';
}
const wrapError = (error: any) => {
if (!error) return error;
const msg = error.message ? [error.message] : [];
msg.push(`Command was: "${path}" ${args.join(' ')}`);
error.message = msg.join('\n\n');
return error;
};
try {
const subProcess = spawn(path, args, options);
const iid = shim.setInterval(() => {
if (subProcess && subProcess.pid) {
this.logger().debug(`Started editor with PID ${subProcess.pid}`);
shim.clearInterval(iid);
resolve(null);
}
}, 100);
subProcess.on('error', (error: any) => {
shim.clearInterval(iid);
reject(wrapError(error));
});
} catch (error) {
throw wrapError(error);
}
});
}
async openAndWatch(note: NoteEntity) { async openAndWatch(note: NoteEntity) {
if (!note || !note.id) { if (!note || !note.id) {
this.logger().warn('ExternalEditWatcher: Cannot open note: ', note); this.logger().warn('ExternalEditWatcher: Cannot open note: ', note);
@ -285,13 +221,7 @@ export default class ExternalEditWatcher {
if (!filePath) return; if (!filePath) return;
this.watch(filePath); this.watch(filePath);
const cmd = this.textEditorCommand(); await openFileWithExternalEditor(filePath, this.bridge_());
if (!cmd) {
this.bridge_().openExternal(`file://${filePath}`);
} else {
cmd.args.push(filePath);
await this.spawnCommand(cmd.path, cmd.args, { detached: true });
}
this.dispatch({ this.dispatch({
type: 'NOTE_FILE_WATCHER_ADD', type: 'NOTE_FILE_WATCHER_ADD',

View File

@ -0,0 +1,82 @@
/* eslint-disable import/prefer-default-export */
const { splitCommandString } = require('../../string-utils');
import { spawn } from 'child_process';
import Logger from '../../Logger';
import Setting from '../../models/Setting';
import { fileExtension } from '../../path-utils';
import shim from '../../shim';
const logger = Logger.create('ExternalEditWatcher/utils');
const spawnCommand = async (path: string, args: string[], options: any) => {
return new Promise((resolve, reject) => {
// App bundles need to be opened using the `open` command.
// Additional args can be specified after --args, and the
// -n flag is needed to ensure that the app is always launched
// with the arguments. Without it, if the app is already opened,
// it will just bring it to the foreground without opening the file.
// So the full command is:
//
// open -n /path/to/editor.app --args -app-flag -bla /path/to/file.md
//
if (shim.isMac() && fileExtension(path) === 'app') {
args = args.slice();
args.splice(0, 0, '--args');
args.splice(0, 0, path);
args.splice(0, 0, '-n');
path = 'open';
}
const wrapError = (error: any) => {
if (!error) return error;
const msg = error.message ? [error.message] : [];
msg.push(`Command was: "${path}" ${args.join(' ')}`);
error.message = msg.join('\n\n');
return error;
};
try {
const subProcess = spawn(path, args, options);
const iid = shim.setInterval(() => {
if (subProcess && subProcess.pid) {
logger.debug(`Started editor with PID ${subProcess.pid}`);
shim.clearInterval(iid);
resolve(null);
}
}, 100);
subProcess.on('error', (error: any) => {
shim.clearInterval(iid);
reject(wrapError(error));
});
} catch (error) {
throw wrapError(error);
}
});
};
const textEditorCommand = () => {
const editorCommand = Setting.value('editor');
if (!editorCommand) return null;
const s = splitCommandString(editorCommand, { handleEscape: false });
const path = s.splice(0, 1);
if (!path.length) throw new Error(`Invalid editor command: ${editorCommand}`);
return {
path: path[0],
args: s,
};
};
export const openFileWithExternalEditor = async (filePath: string, bridge: any) => {
const cmd = textEditorCommand();
if (!cmd) {
bridge.openExternal(`file://${filePath}`);
} else {
cmd.args.push(filePath);
await spawnCommand(cmd.path, cmd.args, { detached: true });
}
};

View File

@ -55,6 +55,9 @@ const defaultKeymapItems = {
{ accelerator: 'Option+Cmd+A', command: 'editor.sortSelectedLines' }, { accelerator: 'Option+Cmd+A', command: 'editor.sortSelectedLines' },
{ accelerator: 'Option+Up', command: 'editor.swapLineUp' }, { accelerator: 'Option+Up', command: 'editor.swapLineUp' },
{ accelerator: 'Option+Down', command: 'editor.swapLineDown' }, { accelerator: 'Option+Down', command: 'editor.swapLineDown' },
{ accelerator: 'Option+Cmd+1', command: 'switchProfile1' },
{ accelerator: 'Option+Cmd+2', command: 'switchProfile2' },
{ accelerator: 'Option+Cmd+3', command: 'switchProfile3' },
], ],
default: [ default: [
{ accelerator: 'Ctrl+N', command: 'newNote' }, { accelerator: 'Ctrl+N', command: 'newNote' },
@ -97,6 +100,9 @@ const defaultKeymapItems = {
{ accelerator: 'Ctrl+Alt+S', command: 'editor.sortSelectedLines' }, { accelerator: 'Ctrl+Alt+S', command: 'editor.sortSelectedLines' },
{ accelerator: 'Alt+Up', command: 'editor.swapLineUp' }, { accelerator: 'Alt+Up', command: 'editor.swapLineUp' },
{ accelerator: 'Alt+Down', command: 'editor.swapLineDown' }, { accelerator: 'Alt+Down', command: 'editor.swapLineDown' },
{ accelerator: 'Ctrl+Alt+1', command: 'switchProfile1' },
{ accelerator: 'Ctrl+Alt+2', command: 'switchProfile2' },
{ accelerator: 'Ctrl+Alt+3', command: 'switchProfile3' },
], ],
}; };

View File

@ -6,13 +6,15 @@ import propsHaveChanged from './propsHaveChanged';
const { createSelectorCreator, defaultMemoize } = require('reselect'); const { createSelectorCreator, defaultMemoize } = require('reselect');
const { createCachedSelector } = require('re-reselect'); const { createCachedSelector } = require('re-reselect');
interface MenuItem { export interface MenuItem {
id: string; id?: string;
label: string; label?: string;
click: Function; click?: Function;
role?: any; role?: any;
type?: string;
accelerator?: string; accelerator?: string;
enabled: boolean; checked?: boolean;
enabled?: boolean;
} }
interface MenuItems { interface MenuItems {

View File

@ -30,6 +30,7 @@ export interface WhenClauseContext {
folderIsShared: boolean; folderIsShared: boolean;
folderIsShareRoot: boolean; folderIsShareRoot: boolean;
joplinServerConnected: boolean; joplinServerConnected: boolean;
hasMultiProfiles: boolean;
} }
export default function stateToWhenClauseContext(state: State, options: WhenClauseContextOptions = null): WhenClauseContext { export default function stateToWhenClauseContext(state: State, options: WhenClauseContextOptions = null): WhenClauseContext {
@ -82,5 +83,7 @@ export default function stateToWhenClauseContext(state: State, options: WhenClau
folderIsShared: commandFolder ? !!commandFolder.share_id : false, folderIsShared: commandFolder ? !!commandFolder.share_id : false,
joplinServerConnected: [9, 10].includes(state.settings['sync.target']), joplinServerConnected: [9, 10].includes(state.settings['sync.target']),
hasMultiProfiles: state.profileConfig && state.profileConfig.profiles.length > 1,
}; };
} }

View File

@ -0,0 +1,85 @@
import { writeFile } from 'fs-extra';
import { createNewProfile, getProfileFullPath, loadProfileConfig, saveProfileConfig } from '.';
import { tempFilePath } from '../../testing/test-utils';
import { defaultProfile, defaultProfileConfig, ProfileConfig } from './types';
describe('profileConfig/index', () => {
it('should load a default profile config', async () => {
const filePath = tempFilePath('json');
const config = await loadProfileConfig(filePath);
expect(config).toEqual(defaultProfileConfig());
});
it('should load a profile config', async () => {
const filePath = tempFilePath('json');
const config = {
profiles: [
{
name: 'Testing',
path: '.',
},
],
};
await writeFile(filePath, JSON.stringify(config), 'utf8');
const loadedConfig = await loadProfileConfig(filePath);
const expected: ProfileConfig = {
version: 1,
currentProfile: 0,
profiles: [
{
name: 'Testing',
path: '.',
},
],
};
expect(loadedConfig).toEqual(expected);
});
it('should load a save a config', async () => {
const filePath = tempFilePath('json');
const config = defaultProfileConfig();
await saveProfileConfig(filePath, config);
const loadedConfig = await loadProfileConfig(filePath);
expect(config).toEqual(loadedConfig);
});
it('should get a profile full path', async () => {
const profile1 = {
...defaultProfile(),
path: 'profile-abcd',
};
const profile2 = {
...defaultProfile(),
path: '.',
};
const profile3 = {
...defaultProfile(),
path: 'profiles/pro/',
};
expect(getProfileFullPath(profile1, '/test/root')).toBe('/test/root/profile-abcd');
expect(getProfileFullPath(profile2, '/test/root')).toBe('/test/root');
expect(getProfileFullPath(profile3, '/test/root')).toBe('/test/root/profiles/pro');
});
it('should create a new profile', async () => {
let config = defaultProfileConfig();
config = createNewProfile(config, 'new profile 1');
config = createNewProfile(config, 'new profile 2');
expect(config.profiles.length).toBe(3);
expect(config.profiles[1].name).toBe('new profile 1');
expect(config.profiles[2].name).toBe('new profile 2');
expect(config.profiles[1].path).not.toBe(config.profiles[2].path);
});
});

View File

@ -0,0 +1,64 @@
import { rtrimSlashes, trimSlashes } from '../../path-utils';
import shim from '../../shim';
import { defaultProfile, defaultProfileConfig, Profile, ProfileConfig } from './types';
import { customAlphabet } from 'nanoid/non-secure';
export const loadProfileConfig = async (profileConfigPath: string): Promise<ProfileConfig> => {
if (!(await shim.fsDriver().exists(profileConfigPath))) {
return defaultProfileConfig();
}
try {
const configContent = await shim.fsDriver().readFile(profileConfigPath, 'utf8');
const parsed = JSON.parse(configContent) as ProfileConfig;
if (!parsed.profiles || !parsed.profiles.length) throw new Error(`Profile config should contain at least one profile: ${profileConfigPath}`);
const output: ProfileConfig = {
...defaultProfileConfig(),
...parsed,
};
for (let i = 0; i < output.profiles.length; i++) {
output.profiles[i] = {
...defaultProfile(),
...output.profiles[i],
};
}
if (output.currentProfile < 0 || output.currentProfile >= output.profiles.length) throw new Error(`Profile index out of range: ${output.currentProfile}`);
return output;
} catch (error) {
error.message = `Could not parse profile configuration: ${profileConfigPath}: ${error.message}`;
throw error;
}
};
export const saveProfileConfig = async (profileConfigPath: string, config: ProfileConfig) => {
await shim.fsDriver().writeFile(profileConfigPath, JSON.stringify(config, null, '\t'), 'utf8');
};
export const getCurrentProfile = (config: ProfileConfig): Profile => {
return { ...config.profiles[config.currentProfile] };
};
export const getProfileFullPath = (profile: Profile, rootProfilePath: string): string => {
let p = trimSlashes(profile.path);
if (p === '.') p = '';
return rtrimSlashes(`${rtrimSlashes(rootProfilePath)}/${p}`);
};
const profileIdGenerator = customAlphabet('0123456789abcdefghijklmnopqrstuvwxyz', 8);
export const createNewProfile = (config: ProfileConfig, profileName: string) => {
const newConfig = {
...config,
profiles: config.profiles.slice(),
};
newConfig.profiles.push({
name: profileName,
path: `profile-${profileIdGenerator()}`,
});
return newConfig;
};

View File

@ -0,0 +1,16 @@
import { getCurrentProfile, getProfileFullPath, loadProfileConfig } from '.';
import Setting from '../../models/Setting';
export default async (rootProfileDir: string) => {
const profileConfig = await loadProfileConfig(`${rootProfileDir}/profiles.json`);
const profileDir = getProfileFullPath(getCurrentProfile(profileConfig), rootProfileDir);
const isSubProfile = profileConfig.currentProfile !== 0;
Setting.setConstant('isSubProfile', isSubProfile);
Setting.setConstant('rootProfileDir', rootProfileDir);
Setting.setConstant('profileDir', profileDir);
return {
profileConfig,
profileDir,
isSubProfile,
};
};

View File

@ -0,0 +1,22 @@
import Setting from '../../models/Setting';
export default (rootSettings: Record<string, any>, subProfileSettings: Record<string, any>) => {
const output: Record<string, any> = { ...subProfileSettings };
for (const k of Object.keys(output)) {
const md = Setting.settingMetadata(k);
if (md.isGlobal) {
delete output[k];
if (k in rootSettings) output[k] = rootSettings[k];
}
}
for (const k of Object.keys(rootSettings)) {
const md = Setting.settingMetadata(k);
if (md.isGlobal) {
output[k] = rootSettings[k];
}
}
return output;
};

View File

@ -0,0 +1,19 @@
import Setting from '../../models/Setting';
import { SettingValues } from '../../models/settings/FileHandler';
export default (settings: SettingValues) => {
const globalSettings: SettingValues = {};
const localSettings: SettingValues = {};
for (const [k, v] of Object.entries(settings)) {
const md = Setting.settingMetadata(k);
if (md.isGlobal) {
globalSettings[k] = v;
} else {
localSettings[k] = v;
}
}
return { globalSettings, localSettings };
};

View File

@ -0,0 +1,27 @@
export interface Profile {
name: string;
path: string;
}
export interface ProfileConfig {
version: number;
currentProfile: number;
profiles: Profile[];
}
export const defaultProfile = (): Profile => {
return {
name: 'Default',
path: '.',
};
};
export const defaultProfileConfig = (): ProfileConfig => {
return {
version: 1,
currentProfile: 0,
profiles: [defaultProfile()],
};
};
export type ProfileSwitchClickHandler = (profileIndex: number)=> void;

View File

@ -106,6 +106,7 @@ const supportDir = `${oldTestDir}/support`;
// various space-in-path issues. // various space-in-path issues.
const dataDir = `${oldTestDir}/test data/${suiteName_}`; const dataDir = `${oldTestDir}/test data/${suiteName_}`;
const profileDir = `${dataDir}/profile`; const profileDir = `${dataDir}/profile`;
const rootProfileDir = profileDir;
fs.mkdirpSync(logDir); fs.mkdirpSync(logDir);
fs.mkdirpSync(baseTempDir); fs.mkdirpSync(baseTempDir);
@ -185,6 +186,7 @@ Setting.setConstant('tempDir', baseTempDir);
Setting.setConstant('cacheDir', baseTempDir); Setting.setConstant('cacheDir', baseTempDir);
Setting.setConstant('pluginDataDir', `${profileDir}/profile/plugin-data`); Setting.setConstant('pluginDataDir', `${profileDir}/profile/plugin-data`);
Setting.setConstant('profileDir', profileDir); Setting.setConstant('profileDir', profileDir);
Setting.setConstant('rootProfileDir', rootProfileDir);
Setting.setConstant('env', 'dev'); Setting.setConstant('env', 'dev');
BaseService.logger_ = logger; BaseService.logger_ = logger;
@ -271,6 +273,8 @@ async function switchClient(id: number, options: any = null) {
await Setting.reset(); await Setting.reset();
Setting.settingFilename = `settings-${id}.json`; Setting.settingFilename = `settings-${id}.json`;
Setting.setConstant('profileDir', rootProfileDir);
Setting.setConstant('rootProfileDir', rootProfileDir);
Setting.setConstant('resourceDirName', resourceDirName(id)); Setting.setConstant('resourceDirName', resourceDirName(id));
Setting.setConstant('resourceDir', resourceDir(id)); Setting.setConstant('resourceDir', resourceDir(id));
Setting.setConstant('pluginDir', pluginDir(id)); Setting.setConstant('pluginDir', pluginDir(id));
@ -330,6 +334,9 @@ async function setupDatabase(id: number = null, options: any = null) {
// running. // running.
await Setting.reset(); await Setting.reset();
Setting.setConstant('profileDir', rootProfileDir);
Setting.setConstant('rootProfileDir', rootProfileDir);
if (databases_[id]) { if (databases_[id]) {
BaseModel.setDb(databases_[id]); BaseModel.setDb(databases_[id]);
await clearDatabase(id); await clearDatabase(id);

View File

@ -1,5 +1,5 @@
const createUuidV4 = require('uuid/v4'); const createUuidV4 = require('uuid/v4');
const { customAlphabet } = require('nanoid/non-secure'); import { customAlphabet } from 'nanoid/non-secure';
// https://zelark.github.io/nano-id-cc/ // https://zelark.github.io/nano-id-cc/
// https://security.stackexchange.com/a/41749/1873 // https://security.stackexchange.com/a/41749/1873

View File

@ -3118,6 +3118,7 @@ __metadata:
"@types/fs-extra": ^9.0.6 "@types/fs-extra": ^9.0.6
"@types/jest": ^26.0.15 "@types/jest": ^26.0.15
"@types/js-yaml": ^4.0.2 "@types/js-yaml": ^4.0.2
"@types/nanoid": ^3.0.0
"@types/node": ^14.14.6 "@types/node": ^14.14.6
"@types/node-rsa": ^1.1.1 "@types/node-rsa": ^1.1.1
"@types/react": ^17.0.20 "@types/react": ^17.0.20
@ -5576,6 +5577,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@types/nanoid@npm:^3.0.0":
version: 3.0.0
resolution: "@types/nanoid@npm:3.0.0"
dependencies:
nanoid: "*"
checksum: 6e84d71ce2b8a2e23b20a018249a2e6a1c36b4ea0f45ff0265e8db514010ccf13141d87d6892f35b8e50ca5832d1d04ddc27062b5e69f91db2d5b3c5321c8c56
languageName: node
linkType: hard
"@types/node-fetch@npm:1.6.9": "@types/node-fetch@npm:1.6.9":
version: 1.6.9 version: 1.6.9
resolution: "@types/node-fetch@npm:1.6.9" resolution: "@types/node-fetch@npm:1.6.9"
@ -21819,6 +21829,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"nanoid@npm:*":
version: 3.3.2
resolution: "nanoid@npm:3.3.2"
bin:
nanoid: bin/nanoid.cjs
checksum: 376717f0685251fad77850bd84c6b8d57837c71eeb1c05be7c742140cc1835a5a2953562add05166d6dbc8fb65f3fdffa356213037b967a470e1691dc3e7b9cc
languageName: node
linkType: hard
"nanoid@npm:^2.1.1": "nanoid@npm:^2.1.1":
version: 2.1.11 version: 2.1.11
resolution: "nanoid@npm:2.1.11" resolution: "nanoid@npm:2.1.11"