You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-12-26 23:38:08 +02:00
Compare commits
23 Commits
multi_prof
...
desktop_re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db7a20a71e | ||
|
|
500af7b2c1 | ||
|
|
2b13a3589a | ||
|
|
7e1ee40333 | ||
|
|
343b81ad09 | ||
|
|
39efc88059 | ||
|
|
558e55090f | ||
|
|
ff066baa26 | ||
|
|
e5313a9719 | ||
|
|
376019b540 | ||
|
|
c28979e620 | ||
|
|
7e9c7a5954 | ||
|
|
55db877f85 | ||
|
|
f24750f7b4 | ||
|
|
cfd5416b73 | ||
|
|
ea2418d018 | ||
|
|
c94a98b841 | ||
|
|
4fd19d6970 | ||
|
|
6f249c3008 | ||
|
|
0374505212 | ||
|
|
21706fa00a | ||
|
|
74273cd570 | ||
|
|
6458ad0540 |
@@ -148,6 +148,9 @@ packages/app-desktop/checkForUpdates.js.map
|
||||
packages/app-desktop/commands/copyDevCommand.d.ts
|
||||
packages/app-desktop/commands/copyDevCommand.js
|
||||
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.js
|
||||
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.js
|
||||
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.js
|
||||
packages/app-desktop/commands/toggleExternalEditing.js.map
|
||||
@@ -217,6 +232,9 @@ packages/app-desktop/gui/Dialog.js.map
|
||||
packages/app-desktop/gui/DialogButtonRow.d.ts
|
||||
packages/app-desktop/gui/DialogButtonRow.js
|
||||
packages/app-desktop/gui/DialogButtonRow.js.map
|
||||
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.d.ts
|
||||
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
|
||||
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js.map
|
||||
packages/app-desktop/gui/DialogTitle.d.ts
|
||||
packages/app-desktop/gui/DialogTitle.js
|
||||
packages/app-desktop/gui/DialogTitle.js.map
|
||||
@@ -259,6 +277,9 @@ packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js.map
|
||||
packages/app-desktop/gui/MainScreen/MainScreen.d.ts
|
||||
packages/app-desktop/gui/MainScreen/MainScreen.js
|
||||
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.js
|
||||
packages/app-desktop/gui/MainScreen/commands/commandPalette.js.map
|
||||
@@ -535,12 +556,18 @@ packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js.map
|
||||
packages/app-desktop/gui/NoteList/NoteList.d.ts
|
||||
packages/app-desktop/gui/NoteList/NoteList.js
|
||||
packages/app-desktop/gui/NoteList/NoteList.js.map
|
||||
packages/app-desktop/gui/NoteList/NoteList2.d.ts
|
||||
packages/app-desktop/gui/NoteList/NoteList2.js
|
||||
packages/app-desktop/gui/NoteList/NoteList2.js.map
|
||||
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.d.ts
|
||||
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
|
||||
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js.map
|
||||
packages/app-desktop/gui/NoteList/commands/index.d.ts
|
||||
packages/app-desktop/gui/NoteList/commands/index.js
|
||||
packages/app-desktop/gui/NoteList/commands/index.js.map
|
||||
packages/app-desktop/gui/NoteList/types.d.ts
|
||||
packages/app-desktop/gui/NoteList/types.js
|
||||
packages/app-desktop/gui/NoteList/types.js.map
|
||||
packages/app-desktop/gui/NoteListControls/NoteListControls.d.ts
|
||||
packages/app-desktop/gui/NoteListControls/NoteListControls.js
|
||||
packages/app-desktop/gui/NoteListControls/NoteListControls.js.map
|
||||
@@ -1042,6 +1069,9 @@ packages/lib/database.js.map
|
||||
packages/lib/debug/DebugService.d.ts
|
||||
packages/lib/debug/DebugService.js
|
||||
packages/lib/debug/DebugService.js.map
|
||||
packages/lib/dom.d.ts
|
||||
packages/lib/dom.js
|
||||
packages/lib/dom.js.map
|
||||
packages/lib/dummy.test.d.ts
|
||||
packages/lib/dummy.test.js
|
||||
packages/lib/dummy.test.js.map
|
||||
@@ -1246,6 +1276,9 @@ packages/lib/services/DecryptionWorker.js.map
|
||||
packages/lib/services/ExternalEditWatcher.d.ts
|
||||
packages/lib/services/ExternalEditWatcher.js
|
||||
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.js
|
||||
packages/lib/services/ItemChangeUtils.js.map
|
||||
@@ -1570,6 +1603,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.js
|
||||
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.js
|
||||
packages/lib/services/rest/Api.js.map
|
||||
|
||||
2
.github/workflows/github-actions-main.yml
vendored
2
.github/workflows/github-actions-main.yml
vendored
@@ -6,7 +6,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
# Removed windows-2016 for now - discontinued by GitHub
|
||||
os: [macos-latest, ubuntu-latest]
|
||||
os: [macos-latest, ubuntu-latest, windows-2019]
|
||||
steps:
|
||||
|
||||
# Silence apt-get update errors (for example when a module doesn't
|
||||
|
||||
51
.gitignore
vendored
51
.gitignore
vendored
@@ -138,6 +138,9 @@ packages/app-desktop/checkForUpdates.js.map
|
||||
packages/app-desktop/commands/copyDevCommand.d.ts
|
||||
packages/app-desktop/commands/copyDevCommand.js
|
||||
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.js
|
||||
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.js
|
||||
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.js
|
||||
packages/app-desktop/commands/toggleExternalEditing.js.map
|
||||
@@ -207,6 +222,9 @@ packages/app-desktop/gui/Dialog.js.map
|
||||
packages/app-desktop/gui/DialogButtonRow.d.ts
|
||||
packages/app-desktop/gui/DialogButtonRow.js
|
||||
packages/app-desktop/gui/DialogButtonRow.js.map
|
||||
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.d.ts
|
||||
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js
|
||||
packages/app-desktop/gui/DialogButtonRow/useKeyboardHandler.js.map
|
||||
packages/app-desktop/gui/DialogTitle.d.ts
|
||||
packages/app-desktop/gui/DialogTitle.js
|
||||
packages/app-desktop/gui/DialogTitle.js.map
|
||||
@@ -249,6 +267,9 @@ packages/app-desktop/gui/KeymapConfig/utils/useKeymap.js.map
|
||||
packages/app-desktop/gui/MainScreen/MainScreen.d.ts
|
||||
packages/app-desktop/gui/MainScreen/MainScreen.js
|
||||
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.js
|
||||
packages/app-desktop/gui/MainScreen/commands/commandPalette.js.map
|
||||
@@ -525,12 +546,18 @@ packages/app-desktop/gui/NoteEditor/utils/useWindowCommandHandler.js.map
|
||||
packages/app-desktop/gui/NoteList/NoteList.d.ts
|
||||
packages/app-desktop/gui/NoteList/NoteList.js
|
||||
packages/app-desktop/gui/NoteList/NoteList.js.map
|
||||
packages/app-desktop/gui/NoteList/NoteList2.d.ts
|
||||
packages/app-desktop/gui/NoteList/NoteList2.js
|
||||
packages/app-desktop/gui/NoteList/NoteList2.js.map
|
||||
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.d.ts
|
||||
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js
|
||||
packages/app-desktop/gui/NoteList/commands/focusElementNoteList.js.map
|
||||
packages/app-desktop/gui/NoteList/commands/index.d.ts
|
||||
packages/app-desktop/gui/NoteList/commands/index.js
|
||||
packages/app-desktop/gui/NoteList/commands/index.js.map
|
||||
packages/app-desktop/gui/NoteList/types.d.ts
|
||||
packages/app-desktop/gui/NoteList/types.js
|
||||
packages/app-desktop/gui/NoteList/types.js.map
|
||||
packages/app-desktop/gui/NoteListControls/NoteListControls.d.ts
|
||||
packages/app-desktop/gui/NoteListControls/NoteListControls.js
|
||||
packages/app-desktop/gui/NoteListControls/NoteListControls.js.map
|
||||
@@ -1032,6 +1059,9 @@ packages/lib/database.js.map
|
||||
packages/lib/debug/DebugService.d.ts
|
||||
packages/lib/debug/DebugService.js
|
||||
packages/lib/debug/DebugService.js.map
|
||||
packages/lib/dom.d.ts
|
||||
packages/lib/dom.js
|
||||
packages/lib/dom.js.map
|
||||
packages/lib/dummy.test.d.ts
|
||||
packages/lib/dummy.test.js
|
||||
packages/lib/dummy.test.js.map
|
||||
@@ -1236,6 +1266,9 @@ packages/lib/services/DecryptionWorker.js.map
|
||||
packages/lib/services/ExternalEditWatcher.d.ts
|
||||
packages/lib/services/ExternalEditWatcher.js
|
||||
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.js
|
||||
packages/lib/services/ItemChangeUtils.js.map
|
||||
@@ -1560,6 +1593,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.js
|
||||
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.js
|
||||
packages/lib/services/rest/Api.js.map
|
||||
|
||||
@@ -45,7 +45,7 @@ while [ "$NUM" -lt 400 ]; do
|
||||
echo "config keychain.supported 0" >> "$CMD_FILE"
|
||||
echo "config sync.target 10" >> "$CMD_FILE"
|
||||
echo "config sync.10.username $USER_EMAIL" >> "$CMD_FILE"
|
||||
echo "config sync.10.password hunter1hunter2hunter3" >> "$CMD_FILE"
|
||||
echo "config sync.10.password 111111" >> "$CMD_FILE"
|
||||
echo "sync" >> "$CMD_FILE"
|
||||
|
||||
yarn start --profile "$PROFILE_DIR" batch "$CMD_FILE"
|
||||
|
||||
@@ -33,14 +33,14 @@
|
||||
],
|
||||
"owner": "Laurent Cozic"
|
||||
},
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.0",
|
||||
"bin": "./main.js",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@joplin/lib": "~2.7",
|
||||
"@joplin/renderer": "~2.7",
|
||||
"@joplin/lib": "~2.8",
|
||||
"@joplin/renderer": "~2.8",
|
||||
"aws-sdk": "^2.588.0",
|
||||
"chalk": "^4.1.0",
|
||||
"compare-version": "^0.1.2",
|
||||
@@ -67,7 +67,7 @@
|
||||
"yargs-parser": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@joplin/tools": "~2.7",
|
||||
"@joplin/tools": "~2.8",
|
||||
"@types/fs-extra": "^9.0.6",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/node": "^14.14.6",
|
||||
|
||||
@@ -44,7 +44,7 @@ const processUser = async (userNum: number) => {
|
||||
|
||||
try {
|
||||
const userEmail = `user${userNum}@example.com`;
|
||||
const userPassword = 'hunter1hunter2hunter3';
|
||||
const userPassword = '111111';
|
||||
const commandFile = `${tempDir}/populateDatabase-${userNum}.txt`;
|
||||
const profileDir = `${homedir()}/.config/joplindev-populate/joplindev-testing-${userNum}`;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 2,
|
||||
"name": "Joplin Web Clipper [DEV]",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.0",
|
||||
"description": "Capture and save web pages and screenshots from your browser to Joplin.",
|
||||
"homepage_url": "https://joplinapp.org",
|
||||
"content_security_policy": "script-src 'self'; object-src 'self'",
|
||||
|
||||
@@ -554,11 +554,17 @@ class Application extends BaseApplication {
|
||||
// setTimeout(() => {
|
||||
// this.dispatch({
|
||||
// type: 'DIALOG_OPEN',
|
||||
// name: 'editFolder',
|
||||
// props: { folderId: '3d90f7da26b947dc9c8c6c65e86cd231' },
|
||||
// name: 'syncWizard',
|
||||
// });
|
||||
// }, 2000);
|
||||
|
||||
// setTimeout(() => {
|
||||
// this.dispatch({
|
||||
// type: 'DIALOG_OPEN',
|
||||
// name: 'editFolder',
|
||||
// });
|
||||
// }, 3000);
|
||||
|
||||
// setTimeout(() => {
|
||||
// this.dispatch({
|
||||
// type: 'NAV_GO',
|
||||
|
||||
19
packages/app-desktop/commands/editProfileConfig.ts
Normal file
19
packages/app-desktop/commands/editProfileConfig.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import Setting from '@joplin/lib/models/Setting';
|
||||
import { openFileWithExternalEditor } from '@joplin/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',
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
// AUTO-GENERATED using `gulp buildCommandIndex`
|
||||
import * as copyDevCommand from './copyDevCommand';
|
||||
import * as editProfileConfig from './editProfileConfig';
|
||||
import * as exportFolders from './exportFolders';
|
||||
import * as exportNotes from './exportNotes';
|
||||
import * as focusElement from './focusElement';
|
||||
@@ -8,11 +9,16 @@ import * as replaceMisspelling from './replaceMisspelling';
|
||||
import * as restoreNoteRevision from './restoreNoteRevision';
|
||||
import * as startExternalEditing from './startExternalEditing';
|
||||
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 toggleSafeMode from './toggleSafeMode';
|
||||
|
||||
const index:any[] = [
|
||||
copyDevCommand,
|
||||
editProfileConfig,
|
||||
exportFolders,
|
||||
exportNotes,
|
||||
focusElement,
|
||||
@@ -21,6 +27,10 @@ const index:any[] = [
|
||||
restoreNoteRevision,
|
||||
startExternalEditing,
|
||||
stopExternalEditing,
|
||||
switchProfile,
|
||||
switchProfile1,
|
||||
switchProfile2,
|
||||
switchProfile3,
|
||||
toggleExternalEditing,
|
||||
toggleSafeMode,
|
||||
];
|
||||
|
||||
@@ -1,19 +1,12 @@
|
||||
import CommandService, { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { AppState } from '../app.reducer';
|
||||
import bridge from '../services/bridge';
|
||||
import { isInsideContainer } from '@joplin/lib/dom';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'replaceMisspelling',
|
||||
};
|
||||
|
||||
function isInsideContainer(node: any, className: string): boolean {
|
||||
while (node) {
|
||||
if (node.classList && node.classList.contains(className)) return true;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, suggestion: string) => {
|
||||
|
||||
26
packages/app-desktop/commands/switchProfile.ts
Normal file
26
packages/app-desktop/commands/switchProfile.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
};
|
||||
15
packages/app-desktop/commands/switchProfile1.ts
Normal file
15
packages/app-desktop/commands/switchProfile1.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
};
|
||||
15
packages/app-desktop/commands/switchProfile2.ts
Normal file
15
packages/app-desktop/commands/switchProfile2.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
};
|
||||
15
packages/app-desktop/commands/switchProfile3.ts
Normal file
15
packages/app-desktop/commands/switchProfile3.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import bridge from '../../services/bridge';
|
||||
import Setting, { AppType, SyncStartupOperation } from '@joplin/lib/models/Setting';
|
||||
import control_PluginsStates from './controls/plugins/PluginsStates';
|
||||
import EncryptionConfigScreen from '../EncryptionConfigScreen/EncryptionConfigScreen';
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
const { connect } = require('react-redux');
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
const pathUtils = require('@joplin/lib/path-utils');
|
||||
@@ -26,7 +27,7 @@ class ConfigScreenComponent extends React.Component<any, any> {
|
||||
constructor(props: any) {
|
||||
super(props);
|
||||
|
||||
shared.init(this);
|
||||
shared.init(this, reg);
|
||||
|
||||
this.state = {
|
||||
selectedSectionName: 'general',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useEffect, useCallback } from 'react';
|
||||
import styled from 'styled-components';
|
||||
|
||||
const DialogModalLayer = styled.div`
|
||||
@@ -33,20 +32,6 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function Dialog(props: Props) {
|
||||
const onWindowKeydown = useCallback((event: any) => {
|
||||
if (event.key === 'Escape') {
|
||||
if (props.onClose) props.onClose();
|
||||
}
|
||||
}, [props.onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', onWindowKeydown);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onWindowKeydown);
|
||||
};
|
||||
}, [onWindowKeydown]);
|
||||
|
||||
return (
|
||||
<DialogModalLayer className={props.className}>
|
||||
<DialogRoot>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
const React = require('react');
|
||||
import { useMemo } from 'react';
|
||||
const { _ } = require('@joplin/lib/locale');
|
||||
const { themeStyle } = require('@joplin/lib/theme');
|
||||
import * as React from 'react';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
import useKeyboardHandler from './DialogButtonRow/useKeyboardHandler';
|
||||
|
||||
export interface ButtonSpec {
|
||||
name: string;
|
||||
@@ -37,32 +38,26 @@ export default function DialogButtonRow(props: Props) {
|
||||
};
|
||||
}, [theme.buttonStyle]);
|
||||
|
||||
const okButton_click = () => {
|
||||
if (props.onClick) props.onClick({ buttonName: 'ok' });
|
||||
};
|
||||
const onOkButtonClick = useCallback(() => {
|
||||
if (props.onClick && !props.okButtonDisabled) props.onClick({ buttonName: 'ok' });
|
||||
}, [props.onClick, props.okButtonDisabled]);
|
||||
|
||||
const cancelButton_click = () => {
|
||||
if (props.onClick) props.onClick({ buttonName: 'cancel' });
|
||||
};
|
||||
const onCancelButtonClick = useCallback(() => {
|
||||
if (props.onClick && !props.cancelButtonDisabled) props.onClick({ buttonName: 'cancel' });
|
||||
}, [props.onClick, props.cancelButtonDisabled]);
|
||||
|
||||
const customButton_click = (event: ClickEvent) => {
|
||||
const onCustomButtonClick = useCallback((event: ClickEvent) => {
|
||||
if (props.onClick) props.onClick(event);
|
||||
};
|
||||
}, [props.onClick]);
|
||||
|
||||
const onKeyDown = (event: any) => {
|
||||
if (event.keyCode === 13) {
|
||||
okButton_click();
|
||||
} else if (event.keyCode === 27) {
|
||||
cancelButton_click();
|
||||
}
|
||||
};
|
||||
const onKeyDown = useKeyboardHandler({ onOkButtonClick, onCancelButtonClick });
|
||||
|
||||
const buttonComps = [];
|
||||
|
||||
if (props.customButtons) {
|
||||
for (const b of props.customButtons) {
|
||||
buttonComps.push(
|
||||
<button key={b.name} style={buttonStyle} onClick={() => customButton_click({ buttonName: b.name })} onKeyDown={onKeyDown}>
|
||||
<button key={b.name} style={buttonStyle} onClick={() => onCustomButtonClick({ buttonName: b.name })} onKeyDown={onKeyDown}>
|
||||
{b.label}
|
||||
</button>
|
||||
);
|
||||
@@ -71,7 +66,7 @@ export default function DialogButtonRow(props: Props) {
|
||||
|
||||
if (props.okButtonShow !== false) {
|
||||
buttonComps.push(
|
||||
<button disabled={props.okButtonDisabled} key="ok" style={buttonStyle} onClick={okButton_click} ref={props.okButtonRef} onKeyDown={onKeyDown}>
|
||||
<button disabled={props.okButtonDisabled} key="ok" style={buttonStyle} onClick={onOkButtonClick} ref={props.okButtonRef} onKeyDown={onKeyDown}>
|
||||
{props.okButtonLabel ? props.okButtonLabel : _('OK')}
|
||||
</button>
|
||||
);
|
||||
@@ -79,7 +74,7 @@ export default function DialogButtonRow(props: Props) {
|
||||
|
||||
if (props.cancelButtonShow !== false) {
|
||||
buttonComps.push(
|
||||
<button disabled={props.cancelButtonDisabled} key="cancel" style={Object.assign({}, buttonStyle)} onClick={cancelButton_click}>
|
||||
<button disabled={props.cancelButtonDisabled} key="cancel" style={Object.assign({}, buttonStyle)} onClick={onCancelButtonClick}>
|
||||
{props.cancelButtonLabel ? props.cancelButtonLabel : _('Cancel')}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { isInsideContainer } from '@joplin/lib/dom';
|
||||
|
||||
interface Props {
|
||||
onOkButtonClick: Function;
|
||||
onCancelButtonClick: Function;
|
||||
}
|
||||
|
||||
const globalKeydownHandlers: string[] = [];
|
||||
|
||||
export default (props: Props) => {
|
||||
const [elementId] = useState(`${Math.round(Math.random() * 10000000)}`);
|
||||
const globalKeydownHandlersRef = useRef(globalKeydownHandlers);
|
||||
|
||||
useEffect(() => {
|
||||
globalKeydownHandlersRef.current.push(elementId);
|
||||
return () => {
|
||||
const idx = globalKeydownHandlersRef.current.findIndex(e => e === elementId);
|
||||
globalKeydownHandlersRef.current.splice(idx, 1);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const isTopDialog = () => {
|
||||
const ln = globalKeydownHandlersRef.current.length;
|
||||
return ln && globalKeydownHandlersRef.current[ln - 1] === elementId;
|
||||
};
|
||||
|
||||
const isInSubModal = (targetElement: any) => {
|
||||
// If we are inside a sub-modal within the dialog, we shouldn't handle
|
||||
// global key events. It can be for example the emoji picker. In general
|
||||
// it's difficult to know whether an element is a modal or not, so we'll
|
||||
// have to add special cases here. Normally there shouldn't be many of
|
||||
// these.
|
||||
if (isInsideContainer(targetElement, 'emoji-picker')) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const onKeyDown = useCallback((event: any) => {
|
||||
// Early exit if it's neither ENTER nor ESCAPE, because isInSubModal
|
||||
// function can be costly.
|
||||
if (event.keyCode !== 13 && event.keyCode !== 27) return;
|
||||
|
||||
if (!isTopDialog() || isInSubModal(event.target)) return;
|
||||
|
||||
if (event.keyCode === 13) {
|
||||
if (event.target.nodeName !== 'TEXTAREA') {
|
||||
props.onOkButtonClick();
|
||||
}
|
||||
} else if (event.keyCode === 27) {
|
||||
props.onCancelButtonClick();
|
||||
}
|
||||
}, [props.onOkButtonClick, props.onCancelButtonClick]);
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
};
|
||||
}, [onKeyDown]);
|
||||
|
||||
return onKeyDown;
|
||||
};
|
||||
@@ -137,7 +137,7 @@ const EncryptionConfigScreen = (props: Props) => {
|
||||
return (
|
||||
<td style={theme.textStyle}>
|
||||
<input type="password" style={passwordStyle} value={password} onChange={event => onInputPasswordChange(mk, event.target.value)} />{' '}
|
||||
<button style={theme.buttonStyle} onClick={() => onSavePasswordClick(mk, props.passwords)}>
|
||||
<button style={theme.buttonStyle} onClick={() => onSavePasswordClick(mk, { ...props.passwords, ...inputPasswords })}>
|
||||
{_('Save')}
|
||||
</button>
|
||||
</td>
|
||||
@@ -268,7 +268,7 @@ const EncryptionConfigScreen = (props: Props) => {
|
||||
const buttonTitle = CommandService.instance().label('openMasterPasswordDialog');
|
||||
|
||||
const needPasswordMessage = !needMasterPassword ? null : (
|
||||
<p className="needpassword">{_('Your master password is needed to decrypt some of your data.')}<br/>{_('Please click on "%s" to proceed', buttonTitle)}</p>
|
||||
<p className="needpassword">{_('Your password is needed to decrypt some of your data.')}<br/>{_('Please click on "%s" to proceed, or set the passwords in the "%s" list below.', buttonTitle, _('Encryption keys'))}</p>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
.manage-password-section > .status {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.manage-password-section > .needpassword {
|
||||
|
||||
@@ -37,7 +37,7 @@ import { localSyncInfoFromState } from '@joplin/lib/services/synchronizer/syncIn
|
||||
import { parseCallbackUrl } from '@joplin/lib/callbackUrlUtils';
|
||||
import ElectronAppWrapper from '../../ElectronAppWrapper';
|
||||
import { showMissingMasterKeyMessage } from '@joplin/lib/services/e2ee/utils';
|
||||
import { MasterKeyEntity } from '../../../lib/services/e2ee/types';
|
||||
import { MasterKeyEntity } from '@joplin/lib/services/e2ee/types';
|
||||
import commands from './commands/index';
|
||||
import invitationRespond from '../../services/share/invitationRespond';
|
||||
const { connect } = require('react-redux');
|
||||
|
||||
34
packages/app-desktop/gui/MainScreen/commands/addProfile.ts
Normal file
34
packages/app-desktop/gui/MainScreen/commands/addProfile.ts
Normal 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 });
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,5 @@
|
||||
// AUTO-GENERATED using `gulp buildCommandIndex`
|
||||
import * as addProfile from './addProfile';
|
||||
import * as commandPalette from './commandPalette';
|
||||
import * as editAlarm from './editAlarm';
|
||||
import * as exportPdf from './exportPdf';
|
||||
@@ -38,6 +39,7 @@ import * as toggleSideBar from './toggleSideBar';
|
||||
import * as toggleVisiblePanes from './toggleVisiblePanes';
|
||||
|
||||
const index:any[] = [
|
||||
addProfile,
|
||||
commandPalette,
|
||||
editAlarm,
|
||||
exportPdf,
|
||||
|
||||
@@ -34,6 +34,12 @@ export default function(props: Props) {
|
||||
const [updatingPassword, setUpdatingPassword] = useState(false);
|
||||
const [mode, setMode] = useState<Mode>(Mode.Set);
|
||||
|
||||
const showCurrentPassword = useMemo(() => {
|
||||
if ([MasterPasswordStatus.NotSet, MasterPasswordStatus.Invalid].includes(status)) return false;
|
||||
if (mode === Mode.Reset) return false;
|
||||
return true;
|
||||
}, [status]);
|
||||
|
||||
const onClose = useCallback(() => {
|
||||
props.dispatch({
|
||||
type: 'DIALOG_CLOSE',
|
||||
@@ -63,7 +69,7 @@ export default function(props: Props) {
|
||||
setUpdatingPassword(true);
|
||||
try {
|
||||
if (mode === Mode.Set) {
|
||||
await updateMasterPassword(currentPassword, password1);
|
||||
await updateMasterPassword(showCurrentPassword ? currentPassword : null, password1);
|
||||
} else if (mode === Mode.Reset) {
|
||||
await resetMasterPassword(EncryptionService.instance(), KvStore.instance(), ShareService.instance(), password1);
|
||||
} else {
|
||||
@@ -115,7 +121,7 @@ export default function(props: Props) {
|
||||
}, [password1, password2, updatingPassword, needToRepeatPassword]);
|
||||
|
||||
useEffect(() => {
|
||||
setShowPasswordForm(status === MasterPasswordStatus.NotSet);
|
||||
setShowPasswordForm([MasterPasswordStatus.NotSet, MasterPasswordStatus.Invalid].includes(status));
|
||||
}, [status]);
|
||||
|
||||
useAsyncEffect(async (event: AsyncEffectEvent) => {
|
||||
@@ -131,8 +137,7 @@ export default function(props: Props) {
|
||||
|
||||
function renderPasswordForm() {
|
||||
const renderCurrentPassword = () => {
|
||||
if (status === MasterPasswordStatus.NotSet) return null;
|
||||
if (mode === Mode.Reset) return null;
|
||||
if (!showCurrentPassword) return null;
|
||||
|
||||
// If the master password is in the keychain we preload it into the
|
||||
// field and allow displaying it. That way if the user has forgotten
|
||||
|
||||
@@ -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 InteropService from '@joplin/lib/services/interop/InteropService';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
@@ -18,9 +18,9 @@ import menuCommandNames from './menuCommandNames';
|
||||
import stateToWhenClauseContext from '../services/commands/stateToWhenClauseContext';
|
||||
import bridge from '../services/bridge';
|
||||
import checkForUpdates from '../checkForUpdates';
|
||||
|
||||
const { connect } = require('react-redux');
|
||||
import { reg } from '@joplin/lib/registry';
|
||||
import { ProfileConfig } from '@joplin/lib/services/profileConfig/types';
|
||||
const packageInfo = require('../packageInfo.js');
|
||||
const { clipboard } = require('electron');
|
||||
const Menu = bridge().Menu;
|
||||
@@ -39,7 +39,7 @@ function pluginMenuItemsCommandNames(menuItems: MenuItem[]): string[] {
|
||||
return output;
|
||||
}
|
||||
|
||||
function pluginCommandNames(plugins: PluginStates): string[] {
|
||||
function getPluginCommandNames(plugins: PluginStates): string[] {
|
||||
let output: string[] = [];
|
||||
|
||||
for (const view of pluginUtils.viewsByType(plugins, 'menu')) {
|
||||
@@ -70,6 +70,42 @@ function createPluginMenuTree(label: string, menuItems: MenuItem[], onMenuItemCl
|
||||
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 {
|
||||
dispatch: Function;
|
||||
menuItemProps: any;
|
||||
@@ -90,6 +126,7 @@ interface Props {
|
||||
plugins: PluginStates;
|
||||
customCss: string;
|
||||
locale: string;
|
||||
profileConfig: ProfileConfig;
|
||||
}
|
||||
|
||||
const commandNames: string[] = menuCommandNames();
|
||||
@@ -174,6 +211,12 @@ function useMenu(props: Props) {
|
||||
const [keymapLastChangeTime, setKeymapLastChangeTime] = useState(Date.now());
|
||||
const [modulesLastChangeTime, setModulesLastChangeTime] = useState(Date.now());
|
||||
|
||||
// We use a ref here because the plugin state can change frequently when
|
||||
// switching note since any plugin view might be rendered again. However we
|
||||
// need this plugin state only in a click handler when exporting notes, and
|
||||
// for that a ref is sufficient.
|
||||
const pluginsRef = useRef(props.plugins);
|
||||
|
||||
const onMenuItemClick = useCallback((commandName: string) => {
|
||||
void CommandService.instance().execute(commandName);
|
||||
}, []);
|
||||
@@ -241,6 +284,18 @@ function useMenu(props: Props) {
|
||||
const onImportModuleClickRef = useRef(null);
|
||||
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(() => {
|
||||
let timeoutId: any = null;
|
||||
|
||||
@@ -249,13 +304,6 @@ function useMenu(props: Props) {
|
||||
|
||||
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 = {
|
||||
label: _('Quit'),
|
||||
accelerator: keymapService.getAccelerator('quit'),
|
||||
@@ -329,7 +377,7 @@ function useMenu(props: Props) {
|
||||
(action: any) => props.dispatch(action),
|
||||
module,
|
||||
{
|
||||
plugins: props.plugins,
|
||||
plugins: pluginsRef.current,
|
||||
customCss: props.customCss,
|
||||
}
|
||||
);
|
||||
@@ -385,6 +433,10 @@ function useMenu(props: Props) {
|
||||
const newFolderItem = menuItemDic.newFolder;
|
||||
const newSubFolderItem = menuItemDic.newSubFolder;
|
||||
const printItem = menuItemDic.print;
|
||||
const switchProfileItem = {
|
||||
label: _('Switch profile'),
|
||||
submenu: switchProfileMenuItems,
|
||||
};
|
||||
|
||||
let toolsItems: any[] = [];
|
||||
|
||||
@@ -499,6 +551,8 @@ function useMenu(props: Props) {
|
||||
platforms: ['darwin'],
|
||||
},
|
||||
|
||||
shim.isMac() ? noItem : switchProfileItem,
|
||||
|
||||
shim.isMac() ? {
|
||||
label: _('Hide %s', 'Joplin'),
|
||||
platforms: ['darwin'],
|
||||
@@ -545,6 +599,7 @@ function useMenu(props: Props) {
|
||||
type: 'separator',
|
||||
},
|
||||
printItem,
|
||||
switchProfileItem,
|
||||
],
|
||||
};
|
||||
|
||||
@@ -848,7 +903,20 @@ function useMenu(props: Props) {
|
||||
clearTimeout(timeoutId);
|
||||
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.customCss,
|
||||
props.locale,
|
||||
props.profileConfig,
|
||||
switchProfileMenuItems,
|
||||
menuItemDic,
|
||||
]);
|
||||
|
||||
useMenuStates(menu, props);
|
||||
|
||||
@@ -889,7 +957,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
const whenClauseContext = stateToWhenClauseContext(state);
|
||||
|
||||
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,
|
||||
routeName: state.route.routeName,
|
||||
selectedFolderId: state.selectedFolderId,
|
||||
@@ -907,6 +975,7 @@ const mapStateToProps = (state: AppState) => {
|
||||
['spellChecker.enabled']: state.settings['spellChecker.enabled'],
|
||||
plugins: state.pluginService.plugins,
|
||||
customCss: state.customCss,
|
||||
profileConfig: state.profileConfig,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useMemo, useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { AppState } from '../../app.reducer';
|
||||
import eventManager from '@joplin/lib/eventManager';
|
||||
import NoteListUtils from '../utils/NoteListUtils';
|
||||
@@ -11,12 +13,12 @@ import CommandService from '@joplin/lib/services/CommandService';
|
||||
import shim from '@joplin/lib/shim';
|
||||
import styled from 'styled-components';
|
||||
import { themeStyle } from '@joplin/lib/theme';
|
||||
const React = require('react');
|
||||
|
||||
const { ItemList } = require('../ItemList.min.js');
|
||||
const { connect } = require('react-redux');
|
||||
import Note from '@joplin/lib/models/Note';
|
||||
import Folder from '@joplin/lib/models/Folder';
|
||||
import { Props } from './types';
|
||||
import usePrevious from '../hooks/usePrevious';
|
||||
|
||||
const commands = [
|
||||
require('./commands/focusElementNoteList'),
|
||||
@@ -29,50 +31,48 @@ const StyledRoot = styled.div`
|
||||
border-right: 1px solid ${(props: any) => props.theme.dividerColor};
|
||||
`;
|
||||
|
||||
class NoteListComponent extends React.Component {
|
||||
constructor() {
|
||||
super();
|
||||
const itemAnchorRefs_: any = {
|
||||
current: {},
|
||||
};
|
||||
|
||||
CommandService.instance().componentRegisterCommands(this, commands);
|
||||
export const itemAnchorRef = (itemId: string) => {
|
||||
if (itemAnchorRefs_.current[itemId] && itemAnchorRefs_.current[itemId].current) return itemAnchorRefs_.current[itemId].current;
|
||||
return null;
|
||||
};
|
||||
|
||||
this.itemHeight = 34;
|
||||
const NoteListComponent = (props: Props) => {
|
||||
const [dragOverTargetNoteIndex, setDragOverTargetNoteIndex] = useState(null);
|
||||
const [width, setWidth] = useState(0);
|
||||
const [, setHeight] = useState(0);
|
||||
|
||||
this.state = {
|
||||
dragOverTargetNoteIndex: null,
|
||||
width: 0,
|
||||
height: 0,
|
||||
useEffect(() => {
|
||||
itemAnchorRefs_.current = {};
|
||||
CommandService.instance().registerCommands(commands);
|
||||
|
||||
return () => {
|
||||
itemAnchorRefs_.current = {};
|
||||
CommandService.instance().unregisterCommands(commands);
|
||||
};
|
||||
}, []);
|
||||
|
||||
this.noteListRef = React.createRef();
|
||||
this.itemListRef = React.createRef();
|
||||
this.itemAnchorRefs_ = {};
|
||||
const itemHeight = 34;
|
||||
|
||||
this.renderItem = this.renderItem.bind(this);
|
||||
this.onKeyDown = this.onKeyDown.bind(this);
|
||||
this.noteItem_titleClick = this.noteItem_titleClick.bind(this);
|
||||
this.noteItem_noteDragOver = this.noteItem_noteDragOver.bind(this);
|
||||
this.noteItem_noteDrop = this.noteItem_noteDrop.bind(this);
|
||||
this.noteItem_checkboxClick = this.noteItem_checkboxClick.bind(this);
|
||||
this.noteItem_dragStart = this.noteItem_dragStart.bind(this);
|
||||
this.onGlobalDrop_ = this.onGlobalDrop_.bind(this);
|
||||
this.registerGlobalDragEndEvent_ = this.registerGlobalDragEndEvent_.bind(this);
|
||||
this.unregisterGlobalDragEndEvent_ = this.unregisterGlobalDragEndEvent_.bind(this);
|
||||
this.itemContextMenu = this.itemContextMenu.bind(this);
|
||||
this.resizableLayout_resize = this.resizableLayout_resize.bind(this);
|
||||
}
|
||||
const focusItemIID_ = useRef<any>(null);
|
||||
const noteListRef = useRef(null);
|
||||
const itemListRef = useRef(null);
|
||||
|
||||
style() {
|
||||
if (this.styleCache_ && this.styleCache_[this.props.themeId]) return this.styleCache_[this.props.themeId];
|
||||
let globalDragEndEventRegistered_ = false;
|
||||
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
const style = useMemo(() => {
|
||||
const theme = themeStyle(props.themeId);
|
||||
|
||||
const style = {
|
||||
return {
|
||||
root: {
|
||||
backgroundColor: theme.backgroundColor,
|
||||
},
|
||||
listItem: {
|
||||
maxWidth: '100%',
|
||||
height: this.itemHeight,
|
||||
height: itemHeight,
|
||||
boxSizing: 'border-box',
|
||||
display: 'flex',
|
||||
alignItems: 'stretch',
|
||||
@@ -99,76 +99,71 @@ class NoteListComponent extends React.Component {
|
||||
textDecoration: 'line-through',
|
||||
},
|
||||
};
|
||||
}, [props.themeId, itemHeight]);
|
||||
|
||||
this.styleCache_ = {};
|
||||
this.styleCache_[this.props.themeId] = style;
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
itemContextMenu(event: any) {
|
||||
const itemContextMenu = useCallback((event: any) => {
|
||||
const currentItemId = event.currentTarget.getAttribute('data-id');
|
||||
if (!currentItemId) return;
|
||||
|
||||
let noteIds = [];
|
||||
if (this.props.selectedNoteIds.indexOf(currentItemId) < 0) {
|
||||
if (props.selectedNoteIds.indexOf(currentItemId) < 0) {
|
||||
noteIds = [currentItemId];
|
||||
} else {
|
||||
noteIds = this.props.selectedNoteIds;
|
||||
noteIds = props.selectedNoteIds;
|
||||
}
|
||||
|
||||
if (!noteIds.length) return;
|
||||
|
||||
const menu = NoteListUtils.makeContextMenu(noteIds, {
|
||||
notes: this.props.notes,
|
||||
dispatch: this.props.dispatch,
|
||||
watchedNoteFiles: this.props.watchedNoteFiles,
|
||||
plugins: this.props.plugins,
|
||||
inConflictFolder: this.props.selectedFolderId === Folder.conflictFolderId(),
|
||||
customCss: this.props.customCss,
|
||||
notes: props.notes,
|
||||
dispatch: props.dispatch,
|
||||
watchedNoteFiles: props.watchedNoteFiles,
|
||||
plugins: props.plugins,
|
||||
inConflictFolder: props.selectedFolderId === Folder.conflictFolderId(),
|
||||
customCss: props.customCss,
|
||||
});
|
||||
|
||||
menu.popup(bridge().window());
|
||||
}
|
||||
}, [props.selectedNoteIds, props.notes, props.dispatch, props.watchedNoteFiles,props.plugins, props.selectedFolderId, props.customCss]);
|
||||
|
||||
onGlobalDrop_() {
|
||||
this.unregisterGlobalDragEndEvent_();
|
||||
this.setState({ dragOverTargetNoteIndex: null });
|
||||
}
|
||||
const onGlobalDrop_ = () => {
|
||||
unregisterGlobalDragEndEvent_();
|
||||
setDragOverTargetNoteIndex(null);
|
||||
};
|
||||
|
||||
registerGlobalDragEndEvent_() {
|
||||
if (this.globalDragEndEventRegistered_) return;
|
||||
this.globalDragEndEventRegistered_ = true;
|
||||
document.addEventListener('dragend', this.onGlobalDrop_);
|
||||
}
|
||||
const registerGlobalDragEndEvent_ = () => {
|
||||
if (globalDragEndEventRegistered_) return;
|
||||
globalDragEndEventRegistered_ = true;
|
||||
document.addEventListener('dragend', onGlobalDrop_);
|
||||
};
|
||||
|
||||
unregisterGlobalDragEndEvent_() {
|
||||
this.globalDragEndEventRegistered_ = false;
|
||||
document.removeEventListener('dragend', this.onGlobalDrop_);
|
||||
}
|
||||
const unregisterGlobalDragEndEvent_ = () => {
|
||||
globalDragEndEventRegistered_ = false;
|
||||
document.removeEventListener('dragend', onGlobalDrop_);
|
||||
};
|
||||
|
||||
dragTargetNoteIndex_(event: any) {
|
||||
return Math.abs(Math.round((event.clientY - this.itemListRef.current.offsetTop() + this.itemListRef.current.offsetScroll()) / this.itemHeight));
|
||||
}
|
||||
const dragTargetNoteIndex_ = (event: any) => {
|
||||
return Math.abs(Math.round((event.clientY - itemListRef.current.offsetTop() + itemListRef.current.offsetScroll()) / itemHeight));
|
||||
};
|
||||
|
||||
noteItem_noteDragOver(event: any) {
|
||||
if (this.props.notesParentType !== 'Folder') return;
|
||||
const noteItem_noteDragOver = (event: any) => {
|
||||
if (props.notesParentType !== 'Folder') return;
|
||||
|
||||
const dt = event.dataTransfer;
|
||||
|
||||
if (dt.types.indexOf('text/x-jop-note-ids') >= 0) {
|
||||
event.preventDefault();
|
||||
const newIndex = this.dragTargetNoteIndex_(event);
|
||||
if (this.state.dragOverTargetNoteIndex === newIndex) return;
|
||||
this.registerGlobalDragEndEvent_();
|
||||
this.setState({ dragOverTargetNoteIndex: newIndex });
|
||||
const newIndex = dragTargetNoteIndex_(event);
|
||||
if (dragOverTargetNoteIndex === newIndex) return;
|
||||
registerGlobalDragEndEvent_();
|
||||
setDragOverTargetNoteIndex(newIndex);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async noteItem_noteDrop(event: any) {
|
||||
if (this.props.notesParentType !== 'Folder') return;
|
||||
const noteItem_noteDrop = async (event: any) => {
|
||||
if (props.notesParentType !== 'Folder') return;
|
||||
|
||||
if (this.props.noteSortOrder !== 'order') {
|
||||
if (props.noteSortOrder !== 'order') {
|
||||
const doIt = await bridge().showConfirmMessageBox(_('To manually sort the notes, the sort order must be changed to "%s" in the menu "%s" > "%s"', _('Custom order'), _('View'), _('Sort notes by')), {
|
||||
buttons: [_('Do it now'), _('Cancel')],
|
||||
});
|
||||
@@ -181,17 +176,17 @@ class NoteListComponent extends React.Component {
|
||||
// TODO: check that parent type is folder
|
||||
|
||||
const dt = event.dataTransfer;
|
||||
this.unregisterGlobalDragEndEvent_();
|
||||
this.setState({ dragOverTargetNoteIndex: null });
|
||||
unregisterGlobalDragEndEvent_();
|
||||
setDragOverTargetNoteIndex(null);
|
||||
|
||||
const targetNoteIndex = this.dragTargetNoteIndex_(event);
|
||||
const targetNoteIndex = dragTargetNoteIndex_(event);
|
||||
const noteIds = JSON.parse(dt.getData('text/x-jop-note-ids'));
|
||||
|
||||
void Note.insertNotesAt(this.props.selectedFolderId, noteIds, targetNoteIndex);
|
||||
}
|
||||
void Note.insertNotesAt(props.selectedFolderId, noteIds, targetNoteIndex);
|
||||
};
|
||||
|
||||
|
||||
async noteItem_checkboxClick(event: any, item: any) {
|
||||
const noteItem_checkboxClick = async (event: any, item: any) => {
|
||||
const checked = event.target.checked;
|
||||
const newNote = {
|
||||
id: item.id,
|
||||
@@ -199,37 +194,37 @@ class NoteListComponent extends React.Component {
|
||||
};
|
||||
await Note.save(newNote, { userSideValidation: true });
|
||||
eventManager.emit('todoToggle', { noteId: item.id, note: newNote });
|
||||
}
|
||||
};
|
||||
|
||||
async noteItem_titleClick(event: any, item: any) {
|
||||
const noteItem_titleClick = async (event: any, item: any) => {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault();
|
||||
this.props.dispatch({
|
||||
props.dispatch({
|
||||
type: 'NOTE_SELECT_TOGGLE',
|
||||
id: item.id,
|
||||
});
|
||||
} else if (event.shiftKey) {
|
||||
event.preventDefault();
|
||||
this.props.dispatch({
|
||||
props.dispatch({
|
||||
type: 'NOTE_SELECT_EXTEND',
|
||||
id: item.id,
|
||||
});
|
||||
} else {
|
||||
this.props.dispatch({
|
||||
props.dispatch({
|
||||
type: 'NOTE_SELECT',
|
||||
id: item.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
noteItem_dragStart(event: any) {
|
||||
const noteItem_dragStart = (event: any) => {
|
||||
let noteIds = [];
|
||||
|
||||
// Here there is two cases:
|
||||
// - If multiple notes are selected, we drag the group
|
||||
// - If only one note is selected, we drag the note that was clicked on (which might be different from the currently selected note)
|
||||
if (this.props.selectedNoteIds.length >= 2) {
|
||||
noteIds = this.props.selectedNoteIds;
|
||||
if (props.selectedNoteIds.length >= 2) {
|
||||
noteIds = props.selectedNoteIds;
|
||||
} else {
|
||||
const clickedNoteId = event.currentTarget.getAttribute('data-id');
|
||||
if (clickedNoteId) noteIds.push(clickedNoteId);
|
||||
@@ -240,61 +235,66 @@ class NoteListComponent extends React.Component {
|
||||
event.dataTransfer.setDragImage(new Image(), 1, 1);
|
||||
event.dataTransfer.clearData();
|
||||
event.dataTransfer.setData('text/x-jop-note-ids', JSON.stringify(noteIds));
|
||||
}
|
||||
};
|
||||
|
||||
renderItem(item: any, index: number) {
|
||||
const renderItem = useCallback((item: any, index: number) => {
|
||||
const highlightedWords = () => {
|
||||
if (this.props.notesParentType === 'Search') {
|
||||
const query = BaseModel.byId(this.props.searches, this.props.selectedSearchId);
|
||||
if (props.notesParentType === 'Search') {
|
||||
const query = BaseModel.byId(props.searches, props.selectedSearchId);
|
||||
if (query) {
|
||||
return this.props.highlightedWords;
|
||||
return props.highlightedWords;
|
||||
}
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
if (!this.itemAnchorRefs_[item.id]) this.itemAnchorRefs_[item.id] = React.createRef();
|
||||
const ref = this.itemAnchorRefs_[item.id];
|
||||
if (!itemAnchorRefs_.current[item.id]) itemAnchorRefs_.current[item.id] = React.createRef();
|
||||
const ref = itemAnchorRefs_.current[item.id];
|
||||
|
||||
return <NoteListItem
|
||||
ref={ref}
|
||||
key={item.id}
|
||||
style={this.style()}
|
||||
style={style}
|
||||
item={item}
|
||||
index={index}
|
||||
themeId={this.props.themeId}
|
||||
width={this.state.width}
|
||||
height={this.itemHeight}
|
||||
dragItemIndex={this.state.dragOverTargetNoteIndex}
|
||||
themeId={props.themeId}
|
||||
width={width}
|
||||
height={itemHeight}
|
||||
dragItemIndex={dragOverTargetNoteIndex}
|
||||
highlightedWords={highlightedWords()}
|
||||
isProvisional={this.props.provisionalNoteIds.includes(item.id)}
|
||||
isSelected={this.props.selectedNoteIds.indexOf(item.id) >= 0}
|
||||
isWatched={this.props.watchedNoteFiles.indexOf(item.id) < 0}
|
||||
itemCount={this.props.notes.length}
|
||||
onCheckboxClick={this.noteItem_checkboxClick}
|
||||
onDragStart={this.noteItem_dragStart}
|
||||
onNoteDragOver={this.noteItem_noteDragOver}
|
||||
onNoteDrop={this.noteItem_noteDrop}
|
||||
onTitleClick={this.noteItem_titleClick}
|
||||
onContextMenu={this.itemContextMenu}
|
||||
isProvisional={props.provisionalNoteIds.includes(item.id)}
|
||||
isSelected={props.selectedNoteIds.indexOf(item.id) >= 0}
|
||||
isWatched={props.watchedNoteFiles.indexOf(item.id) < 0}
|
||||
itemCount={props.notes.length}
|
||||
onCheckboxClick={noteItem_checkboxClick}
|
||||
onDragStart={noteItem_dragStart}
|
||||
onNoteDragOver={noteItem_noteDragOver}
|
||||
onNoteDrop={noteItem_noteDrop}
|
||||
onTitleClick={noteItem_titleClick}
|
||||
onContextMenu={itemContextMenu}
|
||||
/>;
|
||||
}
|
||||
}, [style, props.themeId, width, itemHeight, dragOverTargetNoteIndex, props.provisionalNoteIds, props.selectedNoteIds, props.watchedNoteFiles,
|
||||
props.notes,
|
||||
props.notesParentType,
|
||||
props.searches,
|
||||
props.selectedSearchId,
|
||||
props.highlightedWords,
|
||||
]);
|
||||
|
||||
itemAnchorRef(itemId: string) {
|
||||
if (this.itemAnchorRefs_[itemId] && this.itemAnchorRefs_[itemId].current) return this.itemAnchorRefs_[itemId].current;
|
||||
return null;
|
||||
}
|
||||
const previousSelectedNoteIds = usePrevious(props.selectedNoteIds, []);
|
||||
const previousNotes = usePrevious(props.notes, []);
|
||||
const previousVisible = usePrevious(props.visible, false);
|
||||
|
||||
componentDidUpdate(prevProps: any) {
|
||||
if (prevProps.selectedNoteIds !== this.props.selectedNoteIds && this.props.selectedNoteIds.length === 1) {
|
||||
const id = this.props.selectedNoteIds[0];
|
||||
const doRefocus = this.props.notes.length < prevProps.notes.length;
|
||||
useEffect(() => {
|
||||
if (previousSelectedNoteIds !== props.selectedNoteIds && props.selectedNoteIds.length === 1) {
|
||||
const id = props.selectedNoteIds[0];
|
||||
const doRefocus = props.notes.length < previousNotes.length;
|
||||
|
||||
for (let i = 0; i < this.props.notes.length; i++) {
|
||||
if (this.props.notes[i].id === id) {
|
||||
this.itemListRef.current.makeItemIndexVisible(i);
|
||||
for (let i = 0; i < props.notes.length; i++) {
|
||||
if (props.notes[i].id === id) {
|
||||
itemListRef.current.makeItemIndexVisible(i);
|
||||
if (doRefocus) {
|
||||
const ref = this.itemAnchorRef(id);
|
||||
const ref = itemAnchorRef(id);
|
||||
if (ref) ref.focus();
|
||||
}
|
||||
break;
|
||||
@@ -302,24 +302,24 @@ class NoteListComponent extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
if (prevProps.visible !== this.props.visible) {
|
||||
this.updateSizeState();
|
||||
if (previousVisible !== props.visible) {
|
||||
updateSizeState();
|
||||
}
|
||||
}
|
||||
}, [previousSelectedNoteIds,previousNotes, previousVisible, props.selectedNoteIds, props.notes]);
|
||||
|
||||
scrollNoteIndex_(keyCode: any, ctrlKey: any, metaKey: any, noteIndex: any) {
|
||||
const scrollNoteIndex_ = (keyCode: any, ctrlKey: any, metaKey: any, noteIndex: any) => {
|
||||
|
||||
if (keyCode === 33) {
|
||||
// Page Up
|
||||
noteIndex -= (this.itemListRef.current.visibleItemCount() - 1);
|
||||
noteIndex -= (itemListRef.current.visibleItemCount() - 1);
|
||||
|
||||
} else if (keyCode === 34) {
|
||||
// Page Down
|
||||
noteIndex += (this.itemListRef.current.visibleItemCount() - 1);
|
||||
noteIndex += (itemListRef.current.visibleItemCount() - 1);
|
||||
|
||||
} else if ((keyCode === 35 && ctrlKey) || (keyCode === 40 && metaKey)) {
|
||||
// CTRL+End, CMD+Down
|
||||
noteIndex = this.props.notes.length - 1;
|
||||
noteIndex = props.notes.length - 1;
|
||||
|
||||
} else if ((keyCode === 36 && ctrlKey) || (keyCode === 38 && metaKey)) {
|
||||
// CTRL+Home, CMD+Up
|
||||
@@ -334,31 +334,31 @@ class NoteListComponent extends React.Component {
|
||||
noteIndex += 1;
|
||||
}
|
||||
if (noteIndex < 0) noteIndex = 0;
|
||||
if (noteIndex > this.props.notes.length - 1) noteIndex = this.props.notes.length - 1;
|
||||
if (noteIndex > props.notes.length - 1) noteIndex = props.notes.length - 1;
|
||||
return noteIndex;
|
||||
}
|
||||
};
|
||||
|
||||
async onKeyDown(event: any) {
|
||||
const onKeyDown = async (event: any) => {
|
||||
const keyCode = event.keyCode;
|
||||
const noteIds = this.props.selectedNoteIds;
|
||||
const noteIds = props.selectedNoteIds;
|
||||
|
||||
if (noteIds.length > 0 && (keyCode === 40 || keyCode === 38 || keyCode === 33 || keyCode === 34 || keyCode === 35 || keyCode == 36)) {
|
||||
// DOWN / UP / PAGEDOWN / PAGEUP / END / HOME
|
||||
const noteId = noteIds[0];
|
||||
let noteIndex = BaseModel.modelIndexById(this.props.notes, noteId);
|
||||
let noteIndex = BaseModel.modelIndexById(props.notes, noteId);
|
||||
|
||||
noteIndex = this.scrollNoteIndex_(keyCode, event.ctrlKey, event.metaKey, noteIndex);
|
||||
noteIndex = scrollNoteIndex_(keyCode, event.ctrlKey, event.metaKey, noteIndex);
|
||||
|
||||
const newSelectedNote = this.props.notes[noteIndex];
|
||||
const newSelectedNote = props.notes[noteIndex];
|
||||
|
||||
this.props.dispatch({
|
||||
props.dispatch({
|
||||
type: 'NOTE_SELECT',
|
||||
id: newSelectedNote.id,
|
||||
});
|
||||
|
||||
this.itemListRef.current.makeItemIndexVisible(noteIndex);
|
||||
itemListRef.current.makeItemIndexVisible(noteIndex);
|
||||
|
||||
this.focusNoteId_(newSelectedNote.id);
|
||||
focusNoteId_(newSelectedNote.id);
|
||||
|
||||
event.preventDefault();
|
||||
}
|
||||
@@ -373,7 +373,7 @@ class NoteListComponent extends React.Component {
|
||||
// SPACE
|
||||
event.preventDefault();
|
||||
|
||||
const notes = BaseModel.modelsByIds(this.props.notes, noteIds);
|
||||
const notes = BaseModel.modelsByIds(props.notes, noteIds);
|
||||
const todos = notes.filter((n: any) => !!n.is_todo);
|
||||
if (!todos.length) return;
|
||||
|
||||
@@ -382,7 +382,7 @@ class NoteListComponent extends React.Component {
|
||||
await Note.save(toggledTodo);
|
||||
}
|
||||
|
||||
this.focusNoteId_(todos[0].id);
|
||||
focusNoteId_(todos[0].id);
|
||||
}
|
||||
|
||||
if (keyCode === 9) {
|
||||
@@ -400,62 +400,63 @@ class NoteListComponent extends React.Component {
|
||||
// Ctrl+A key
|
||||
event.preventDefault();
|
||||
|
||||
this.props.dispatch({
|
||||
props.dispatch({
|
||||
type: 'NOTE_SELECT_ALL',
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
focusNoteId_(noteId: string) {
|
||||
const focusNoteId_ = (noteId: string) => {
|
||||
// - We need to focus the item manually otherwise focus might be lost when the
|
||||
// list is scrolled and items within it are being rebuilt.
|
||||
// - We need to use an interval because when leaving the arrow pressed, the rendering
|
||||
// of items might lag behind and so the ref is not yet available at this point.
|
||||
if (!this.itemAnchorRef(noteId)) {
|
||||
if (this.focusItemIID_) shim.clearInterval(this.focusItemIID_);
|
||||
this.focusItemIID_ = shim.setInterval(() => {
|
||||
if (this.itemAnchorRef(noteId)) {
|
||||
this.itemAnchorRef(noteId).focus();
|
||||
shim.clearInterval(this.focusItemIID_);
|
||||
this.focusItemIID_ = null;
|
||||
if (!itemAnchorRef(noteId)) {
|
||||
if (focusItemIID_.current) shim.clearInterval(focusItemIID_.current);
|
||||
focusItemIID_.current = shim.setInterval(() => {
|
||||
if (itemAnchorRef(noteId)) {
|
||||
itemAnchorRef(noteId).focus();
|
||||
shim.clearInterval(focusItemIID_.current);
|
||||
focusItemIID_.current = null;
|
||||
}
|
||||
}, 10);
|
||||
} else {
|
||||
this.itemAnchorRef(noteId).focus();
|
||||
itemAnchorRef(noteId).focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateSizeState() {
|
||||
this.setState({
|
||||
width: this.noteListRef.current.clientWidth,
|
||||
height: this.noteListRef.current.clientHeight,
|
||||
});
|
||||
}
|
||||
const updateSizeState = () => {
|
||||
setWidth(noteListRef.current.clientWidth);
|
||||
setHeight(noteListRef.current.clientHeight);
|
||||
};
|
||||
|
||||
resizableLayout_resize() {
|
||||
this.updateSizeState();
|
||||
}
|
||||
const resizableLayout_resize = () => {
|
||||
updateSizeState();
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.props.resizableLayoutEventEmitter.on('resize', this.resizableLayout_resize);
|
||||
this.updateSizeState();
|
||||
}
|
||||
useEffect(() => {
|
||||
props.resizableLayoutEventEmitter.on('resize', resizableLayout_resize);
|
||||
return () => {
|
||||
props.resizableLayoutEventEmitter.off('resize', resizableLayout_resize);
|
||||
};
|
||||
}, [props.resizableLayoutEventEmitter]);
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.focusItemIID_) {
|
||||
shim.clearInterval(this.focusItemIID_);
|
||||
this.focusItemIID_ = null;
|
||||
}
|
||||
useEffect(() => {
|
||||
updateSizeState();
|
||||
|
||||
this.props.resizableLayoutEventEmitter.off('resize', this.resizableLayout_resize);
|
||||
return () => {
|
||||
if (focusItemIID_.current) {
|
||||
shim.clearInterval(focusItemIID_.current);
|
||||
focusItemIID_.current = null;
|
||||
}
|
||||
CommandService.instance().componentUnregisterCommands(commands);
|
||||
};
|
||||
}, []);
|
||||
|
||||
CommandService.instance().componentUnregisterCommands(commands);
|
||||
}
|
||||
const renderEmptyList = () => {
|
||||
if (props.notes.length) return null;
|
||||
|
||||
renderEmptyList() {
|
||||
if (this.props.notes.length) return null;
|
||||
|
||||
const theme = themeStyle(this.props.themeId);
|
||||
const theme = themeStyle(props.themeId);
|
||||
const padding = 10;
|
||||
const emptyDivStyle = {
|
||||
padding: `${padding}px`,
|
||||
@@ -464,39 +465,35 @@ class NoteListComponent extends React.Component {
|
||||
backgroundColor: theme.backgroundColor,
|
||||
fontFamily: theme.fontFamily,
|
||||
};
|
||||
// emptyDivStyle.width = emptyDivStyle.width - padding * 2;
|
||||
// emptyDivStyle.height = emptyDivStyle.height - padding * 2;
|
||||
return <div style={emptyDivStyle}>{this.props.folders.length ? _('No notes in here. Create one by clicking on "New note".') : _('There is currently no notebook. Create one by clicking on "New notebook".')}</div>;
|
||||
}
|
||||
return <div style={emptyDivStyle}>{props.folders.length ? _('No notes in here. Create one by clicking on "New note".') : _('There is currently no notebook. Create one by clicking on "New notebook".')}</div>;
|
||||
};
|
||||
|
||||
renderItemList(style: any) {
|
||||
if (!this.props.notes.length) return null;
|
||||
const renderItemList = () => {
|
||||
if (!props.notes.length) return null;
|
||||
|
||||
return (
|
||||
<ItemList
|
||||
ref={this.itemListRef}
|
||||
disabled={this.props.isInsertingNotes}
|
||||
itemHeight={this.style().listItem.height}
|
||||
ref={itemListRef}
|
||||
disabled={props.isInsertingNotes}
|
||||
itemHeight={style.listItem.height}
|
||||
className={'note-list'}
|
||||
items={this.props.notes}
|
||||
style={style}
|
||||
itemRenderer={this.renderItem}
|
||||
onKeyDown={this.onKeyDown}
|
||||
items={props.notes}
|
||||
style={props.size}
|
||||
itemRenderer={renderItem}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
if (!this.props.size) throw new Error('props.size is required');
|
||||
if (!props.size) throw new Error('props.size is required');
|
||||
|
||||
return (
|
||||
<StyledRoot ref={this.noteListRef}>
|
||||
{this.renderEmptyList()}
|
||||
{this.renderItemList(this.props.size)}
|
||||
</StyledRoot>
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<StyledRoot ref={noteListRef}>
|
||||
{renderEmptyList()}
|
||||
{renderItemList()}
|
||||
</StyledRoot>
|
||||
);
|
||||
};
|
||||
|
||||
const mapStateToProps = (state: AppState) => {
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
|
||||
import { _ } from '@joplin/lib/locale';
|
||||
import { stateUtils } from '@joplin/lib/reducer';
|
||||
import { itemAnchorRef } from '../NoteList';
|
||||
|
||||
export const declaration: CommandDeclaration = {
|
||||
name: 'focusElementNoteList',
|
||||
@@ -8,13 +9,13 @@ export const declaration: CommandDeclaration = {
|
||||
parentLabel: () => _('Focus'),
|
||||
};
|
||||
|
||||
export const runtime = (comp: any): CommandRuntime => {
|
||||
export const runtime = (): CommandRuntime => {
|
||||
return {
|
||||
execute: async (context: CommandContext, noteId: string = null) => {
|
||||
noteId = noteId || stateUtils.selectedNoteId(context.state);
|
||||
|
||||
if (noteId) {
|
||||
const ref = comp.itemAnchorRef(noteId);
|
||||
const ref = itemAnchorRef(noteId);
|
||||
if (ref) ref.focus();
|
||||
}
|
||||
},
|
||||
|
||||
24
packages/app-desktop/gui/NoteList/types.ts
Normal file
24
packages/app-desktop/gui/NoteList/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { FolderEntity, NoteEntity } from '@joplin/lib/services/database/types';
|
||||
import { PluginStates } from '@joplin/lib/services/plugins/reducer';
|
||||
|
||||
export interface Props {
|
||||
themeId: any;
|
||||
selectedNoteIds: string[];
|
||||
notes: NoteEntity[];
|
||||
dispatch: Function;
|
||||
watchedNoteFiles: any[];
|
||||
plugins: PluginStates;
|
||||
selectedFolderId: string;
|
||||
customCss: string;
|
||||
notesParentType: string;
|
||||
noteSortOrder: string;
|
||||
resizableLayoutEventEmitter: any;
|
||||
isInsertingNotes: boolean;
|
||||
folders: FolderEntity[];
|
||||
size: any;
|
||||
searches: any[];
|
||||
selectedSearchId: string;
|
||||
highlightedWords: string[];
|
||||
provisionalNoteIds: string[];
|
||||
visible: boolean;
|
||||
}
|
||||
@@ -110,7 +110,7 @@ function NoteListItem(props: NoteListItemProps, ref: any) {
|
||||
|
||||
let listItemTitleStyle = Object.assign({}, props.style.listItemTitle);
|
||||
listItemTitleStyle.paddingLeft = !item.is_todo ? hPadding : 4;
|
||||
if (item.is_shared) listItemTitleStyle.color = theme.colorWarn2;
|
||||
if (item.is_shared) listItemTitleStyle.color = theme.colorWarn3;
|
||||
if (item.is_todo && !!item.todo_completed) listItemTitleStyle = Object.assign(listItemTitleStyle, props.style.listItemTitleCompleted);
|
||||
|
||||
const displayTitle = Note.displayTitle(item);
|
||||
|
||||
@@ -231,6 +231,13 @@ class PromptDialog extends React.Component {
|
||||
}
|
||||
|
||||
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) {
|
||||
buttonComps.push(
|
||||
<button key="ok" disabled={!this.state.answer} style={styles.button} onClick={() => onClose(true, 'ok')}>
|
||||
|
||||
@@ -15,7 +15,7 @@ import Button from './Button/Button';
|
||||
import { connect } from 'react-redux';
|
||||
import { AppState } from '../app.reducer';
|
||||
import { getEncryptionEnabled } from '@joplin/lib/services/synchronizer/syncInfoUtils';
|
||||
import SyncTargetRegistry from '../../lib/SyncTargetRegistry';
|
||||
import SyncTargetRegistry from '@joplin/lib/SyncTargetRegistry';
|
||||
const { clipboard } = require('electron');
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -60,5 +60,10 @@ export default function() {
|
||||
'gotoAnything',
|
||||
'commandPalette',
|
||||
'openMasterPasswordDialog',
|
||||
'addProfile',
|
||||
'editProfileConfig',
|
||||
'switchProfile1',
|
||||
'switchProfile2',
|
||||
'switchProfile3',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -275,4 +275,5 @@ Component-specific classes
|
||||
|
||||
.master-password-dialog .fa-times {
|
||||
color: var(--joplin-color-error);
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/app-desktop",
|
||||
"version": "2.7.14",
|
||||
"version": "2.8.1",
|
||||
"description": "Joplin for Desktop",
|
||||
"main": "main.js",
|
||||
"private": true,
|
||||
@@ -105,7 +105,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/laurent22/joplin#readme",
|
||||
"devDependencies": {
|
||||
"@joplin/tools": "~2.7",
|
||||
"@joplin/tools": "~2.8",
|
||||
"@testing-library/react-hooks": "^3.4.2",
|
||||
"@types/jest": "^26.0.15",
|
||||
"@types/node": "^14.14.6",
|
||||
@@ -138,8 +138,8 @@
|
||||
"@electron/remote": "^2.0.1",
|
||||
"@fortawesome/fontawesome-free": "^5.13.0",
|
||||
"@joeattardi/emoji-button": "^4.6.0",
|
||||
"@joplin/lib": "~2.7",
|
||||
"@joplin/renderer": "~2.7",
|
||||
"@joplin/lib": "~2.8",
|
||||
"@joplin/renderer": "~2.8",
|
||||
"async-mutex": "^0.1.3",
|
||||
"codemirror": "^5.56.0",
|
||||
"color": "^3.1.2",
|
||||
|
||||
@@ -92,7 +92,7 @@ do
|
||||
echo "config sync.target 10" >> "$CMD_FILE"
|
||||
# echo "config sync.10.path http://api.joplincloud.local:22300" >> "$CMD_FILE"
|
||||
echo "config sync.10.username $USER_EMAIL" >> "$CMD_FILE"
|
||||
echo "config sync.10.password hunter1hunter2hunter3" >> "$CMD_FILE"
|
||||
echo "config sync.10.password 111111" >> "$CMD_FILE"
|
||||
|
||||
elif [[ $CMD == "e2ee" ]]; then
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ android {
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 2097667
|
||||
versionName "2.7.2"
|
||||
versionName "2.8.0"
|
||||
ndk {
|
||||
abiFilters "armeabi-v7a", "x86", "arm64-v8a", "x86_64"
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ class ConfigScreenComponent extends BaseScreenComponent {
|
||||
|
||||
this.scrollViewRef_ = React.createRef();
|
||||
|
||||
shared.init(this);
|
||||
shared.init(this, reg);
|
||||
|
||||
this.checkSyncConfig_ = async () => {
|
||||
// to ignore TLS erros we need to chage the global state of the app, if the check fails we need to restore the original state
|
||||
|
||||
@@ -498,7 +498,7 @@
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 12.7.1;
|
||||
MARKETING_VERSION = 12.8.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -526,7 +526,7 @@
|
||||
INFOPLIST_FILE = Joplin/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 12.7.1;
|
||||
MARKETING_VERSION = 12.8.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
"-ObjC",
|
||||
@@ -674,7 +674,7 @@
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 12.7.1;
|
||||
MARKETING_VERSION = 12.8.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin.ShareExtension;
|
||||
@@ -705,7 +705,7 @@
|
||||
INFOPLIST_FILE = ShareExtension/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 11.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
||||
MARKETING_VERSION = 12.7.1;
|
||||
MARKETING_VERSION = 12.8.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.cozic.joplin.ShareExtension;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
||||
@@ -507,7 +507,7 @@ async function initialize(dispatch: Function) {
|
||||
|
||||
// Setting.setValue('sync.target', 10);
|
||||
// Setting.setValue('sync.10.username', 'user1@example.com');
|
||||
// Setting.setValue('sync.10.password', 'hunter1hunter2hunter3');
|
||||
// Setting.setValue('sync.10.password', '111111');
|
||||
}
|
||||
|
||||
if (Setting.value('db.ftsEnabled') === -1) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"manifest_version": 1,
|
||||
"id": "<%= pluginId %>",
|
||||
"app_min_version": "2.7",
|
||||
"app_min_version": "2.8",
|
||||
"version": "1.0.0",
|
||||
"name": "<%= pluginName %>",
|
||||
"description": "<%= pluginDescription %>",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "generator-joplin",
|
||||
"version": "2.7.3",
|
||||
"version": "2.8.0",
|
||||
"description": "Scaffolds out a new Joplin plugin",
|
||||
"homepage": "https://github.com/laurent22/joplin/tree/dev/packages/generator-joplin",
|
||||
"author": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/htmlpack",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.0",
|
||||
"description": "Pack an HTML file and all its linked resources into a single HTML file",
|
||||
"main": "dist/index.js",
|
||||
"types": "src/index.ts",
|
||||
|
||||
@@ -55,6 +55,8 @@ import SyncTargetNone from './SyncTargetNone';
|
||||
import { setRSA } from './services/e2ee/ppk';
|
||||
import RSA from './services/e2ee/RSA.node';
|
||||
import Resource from './models/Resource';
|
||||
import { ProfileConfig } from './services/profileConfig/types';
|
||||
import initProfile from './services/profileConfig/initProfile';
|
||||
|
||||
const appLogger: LoggerWrapper = Logger.create('App');
|
||||
|
||||
@@ -70,6 +72,7 @@ export default class BaseApplication {
|
||||
private eventEmitter_: any;
|
||||
private scheduleAutoAddResourcesIID_: any = null;
|
||||
private database_: any = null;
|
||||
private profileConfig_: ProfileConfig = null;
|
||||
|
||||
protected showStackTraces_: boolean = false;
|
||||
protected showPromptString_: boolean = false;
|
||||
@@ -646,6 +649,12 @@ export default class BaseApplication {
|
||||
public initRedux() {
|
||||
this.store_ = createStore(this.reducer, applyMiddleware(this.generalMiddlewareFn() as any));
|
||||
setStore(this.store_);
|
||||
|
||||
this.store_.dispatch({
|
||||
type: 'PROFILE_CONFIG_SET',
|
||||
value: this.profileConfig_,
|
||||
});
|
||||
|
||||
BaseModel.dispatch = this.store().dispatch;
|
||||
FoldersScreenUtils.dispatch = this.store().dispatch;
|
||||
// reg.dispatch = this.store().dispatch;
|
||||
@@ -714,14 +723,16 @@ export default class BaseApplication {
|
||||
// https://immerjs.github.io/immer/docs/freezing
|
||||
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 resourceDir = `${profileDir}/${resourceDirName}`;
|
||||
const tempDir = `${profileDir}/tmp`;
|
||||
const cacheDir = `${profileDir}/cache`;
|
||||
|
||||
Setting.setConstant('env', initArgs.env);
|
||||
Setting.setConstant('profileDir', profileDir);
|
||||
Setting.setConstant('resourceDirName', resourceDirName);
|
||||
Setting.setConstant('resourceDir', resourceDir);
|
||||
Setting.setConstant('tempDir', tempDir);
|
||||
@@ -778,6 +789,7 @@ export default class BaseApplication {
|
||||
|
||||
|
||||
appLogger.info(`Profile directory: ${profileDir}`);
|
||||
appLogger.info(`Root profile directory: ${rootProfileDir}`);
|
||||
|
||||
this.database_ = new JoplinDatabase(new DatabaseDriverNode());
|
||||
this.database_.setLogExcludedQueryTypes(['SELECT']);
|
||||
@@ -838,6 +850,7 @@ export default class BaseApplication {
|
||||
}
|
||||
|
||||
if ('welcomeDisabled' in initArgs) Setting.setValue('welcome.enabled', !initArgs.welcomeDisabled);
|
||||
if (isSubProfile) Setting.setValue('welcome.enabled', false);
|
||||
|
||||
if (!Setting.value('api.token')) {
|
||||
void EncryptionService.instance()
|
||||
|
||||
@@ -22,7 +22,7 @@ import TaskQueue from './TaskQueue';
|
||||
import ItemUploader from './services/synchronizer/ItemUploader';
|
||||
import { FileApi, RemoteItem } from './file-api';
|
||||
import JoplinDatabase from './JoplinDatabase';
|
||||
import { fetchSyncInfo, getActiveMasterKey, localSyncInfo, mergeSyncInfos, saveLocalSyncInfo, SyncInfo, syncInfoEquals, uploadSyncInfo } from './services/synchronizer/syncInfoUtils';
|
||||
import { fetchSyncInfo, getActiveMasterKey, localSyncInfo, mergeSyncInfos, saveLocalSyncInfo, setMasterKeyHasBeenUsed, SyncInfo, syncInfoEquals, uploadSyncInfo } from './services/synchronizer/syncInfoUtils';
|
||||
import { getMasterPassword, setupAndDisableEncryption, setupAndEnableEncryption } from './services/e2ee/utils';
|
||||
import { generateKeyPair } from './services/e2ee/ppk';
|
||||
import syncDebugLog from './services/synchronizer/syncDebugLog';
|
||||
@@ -439,10 +439,13 @@ export default class Synchronizer {
|
||||
let remoteInfo = await fetchSyncInfo(this.api());
|
||||
logger.info('Sync target remote info:', remoteInfo);
|
||||
|
||||
let syncTargetIsNew = false;
|
||||
|
||||
if (!remoteInfo.version) {
|
||||
logger.info('Sync target is new - setting it up...');
|
||||
await this.migrationHandler().upgrade(Setting.value('syncVersion'));
|
||||
remoteInfo = await fetchSyncInfo(this.api());
|
||||
syncTargetIsNew = true;
|
||||
}
|
||||
|
||||
logger.info('Sync target is already setup - checking it...');
|
||||
@@ -455,11 +458,16 @@ export default class Synchronizer {
|
||||
|
||||
localInfo = await this.setPpkIfNotExist(localInfo, remoteInfo);
|
||||
|
||||
if (syncTargetIsNew && localInfo.activeMasterKeyId) {
|
||||
localInfo = setMasterKeyHasBeenUsed(localInfo, localInfo.activeMasterKeyId);
|
||||
}
|
||||
|
||||
// console.info('LOCAL', localInfo);
|
||||
// console.info('REMOTE', remoteInfo);
|
||||
|
||||
if (!syncInfoEquals(localInfo, remoteInfo)) {
|
||||
const newInfo = mergeSyncInfos(localInfo, remoteInfo);
|
||||
let newInfo = mergeSyncInfos(localInfo, remoteInfo);
|
||||
if (newInfo.activeMasterKeyId) newInfo = setMasterKeyHasBeenUsed(newInfo, newInfo.activeMasterKeyId);
|
||||
const previousE2EE = localInfo.e2ee;
|
||||
logger.info('Sync target info differs between local and remote - merging infos: ', newInfo.toObject());
|
||||
|
||||
|
||||
@@ -3,15 +3,35 @@ const SyncTargetRegistry = require('../../SyncTargetRegistry').default;
|
||||
const ObjectUtils = require('../../ObjectUtils');
|
||||
const { _ } = require('../../locale');
|
||||
const { createSelector } = require('reselect');
|
||||
const Logger = require('@joplin/lib/Logger').default;
|
||||
|
||||
const logger = Logger.create('config/lib');
|
||||
|
||||
const shared = {};
|
||||
|
||||
shared.init = function(comp) {
|
||||
shared.onSettingsSaved = () => {};
|
||||
|
||||
shared.init = function(comp, reg) {
|
||||
if (!comp.state) comp.state = {};
|
||||
comp.state.checkSyncConfigResult = null;
|
||||
comp.state.settings = {};
|
||||
comp.state.changedSettingKeys = [];
|
||||
comp.state.showAdvancedSettings = false;
|
||||
|
||||
shared.onSettingsSaved = (event) => {
|
||||
const savedSettingKeys = event.savedSettingKeys;
|
||||
|
||||
// After changing the sync settings we immediately trigger a sync
|
||||
// operation. This will ensure that the client gets the sync info as
|
||||
// early as possible, in particular the encryption state (encryption
|
||||
// keys, whether it's enabled, etc.). This should prevent situations
|
||||
// where the user tried to setup E2EE on the client even though it's
|
||||
// already been done on another client.
|
||||
if (savedSettingKeys.find(s => s.startsWith('sync.'))) {
|
||||
logger.info('Sync settings have been changed - scheduling a sync');
|
||||
void reg.scheduleSync();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
shared.advancedSettingsButton_click = (comp) => {
|
||||
@@ -79,6 +99,8 @@ shared.scheduleSaveSettings = function(comp) {
|
||||
};
|
||||
|
||||
shared.saveSettings = function(comp) {
|
||||
const savedSettingKeys = comp.state.changedSettingKeys.slice();
|
||||
|
||||
for (const key in comp.state.settings) {
|
||||
if (!comp.state.settings.hasOwnProperty(key)) continue;
|
||||
if (comp.state.changedSettingKeys.indexOf(key) < 0) continue;
|
||||
@@ -86,6 +108,8 @@ shared.saveSettings = function(comp) {
|
||||
}
|
||||
|
||||
comp.setState({ changedSettingKeys: [] });
|
||||
|
||||
shared.onSettingsSaved({ savedSettingKeys });
|
||||
};
|
||||
|
||||
shared.settingsToComponents = function(comp, device, settings) {
|
||||
|
||||
9
packages/lib/dom.ts
Normal file
9
packages/lib/dom.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
|
||||
export const isInsideContainer = (node: any, className: string): boolean => {
|
||||
while (node) {
|
||||
if (node.classList && node.classList.contains(className)) return true;
|
||||
node = node.parentNode;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
@@ -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 * as fs from 'fs-extra';
|
||||
import { readFile, stat, mkdirp, writeFile, pathExists, readdir } from 'fs-extra';
|
||||
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> {
|
||||
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() {
|
||||
|
||||
beforeEach(async (done) => {
|
||||
@@ -180,19 +196,19 @@ describe('models/Setting', function() {
|
||||
{
|
||||
// Double-check that timestamp is indeed changed when the content is
|
||||
// changed.
|
||||
const beforeStat = await fs.stat(Setting.settingFilePath);
|
||||
const beforeStat = await stat(Setting.settingFilePath);
|
||||
await msleep(1001);
|
||||
Setting.setValue('sync.mobileWifiOnly', false);
|
||||
await Setting.saveAll();
|
||||
const afterStat = await fs.stat(Setting.settingFilePath);
|
||||
const afterStat = await stat(Setting.settingFilePath);
|
||||
expect(afterStat.mtime.getTime()).toBeGreaterThan(beforeStat.mtime.getTime());
|
||||
}
|
||||
|
||||
{
|
||||
const beforeStat = await fs.stat(Setting.settingFilePath);
|
||||
const beforeStat = await stat(Setting.settingFilePath);
|
||||
await msleep(1001);
|
||||
Setting.setValue('sync.mobileWifiOnly', false);
|
||||
const afterStat = await fs.stat(Setting.settingFilePath);
|
||||
const afterStat = await stat(Setting.settingFilePath);
|
||||
await Setting.saveAll();
|
||||
expect(afterStat.mtime.getTime()).toBe(beforeStat.mtime.getTime());
|
||||
}
|
||||
@@ -200,7 +216,7 @@ describe('models/Setting', function() {
|
||||
|
||||
it('should handle invalid JSON', (async () => {
|
||||
const badContent = '{ oopsIforgotTheQuotes: true}';
|
||||
await fs.writeFile(Setting.settingFilePath, badContent, 'utf8');
|
||||
await writeFile(Setting.settingFilePath, badContent, 'utf8');
|
||||
await Setting.reset();
|
||||
|
||||
Logger.globalLogger.enabled = false;
|
||||
@@ -208,12 +224,12 @@ describe('models/Setting', function() {
|
||||
Logger.globalLogger.enabled = true;
|
||||
|
||||
// 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[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 () => {
|
||||
@@ -256,4 +272,67 @@ describe('models/Setting', function() {
|
||||
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"`);
|
||||
}
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -7,6 +7,8 @@ import SyncTargetRegistry from '../SyncTargetRegistry';
|
||||
import time from '../time';
|
||||
import FileHandler, { SettingValues } from './settings/FileHandler';
|
||||
import Logger from '../Logger';
|
||||
import mergeGlobalAndLocalSettings from '../services/profileConfig/mergeGlobalAndLocalSettings';
|
||||
import splitGlobalAndLocalSettings from '../services/profileConfig/splitGlobalAndLocalSettings';
|
||||
const { sprintf } = require('sprintf-js');
|
||||
const ObjectUtils = require('../ObjectUtils');
|
||||
const { toTitleCase } = require('../string-utils.js');
|
||||
@@ -59,6 +61,18 @@ export interface SettingItem {
|
||||
autoSave?: boolean;
|
||||
storage?: SettingStorage;
|
||||
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 {
|
||||
@@ -112,6 +126,7 @@ export interface Constants {
|
||||
resourceDirName: string;
|
||||
resourceDir: string;
|
||||
profileDir: string;
|
||||
rootProfileDir: string;
|
||||
tempDir: string;
|
||||
pluginDataDir: string;
|
||||
cacheDir: string;
|
||||
@@ -119,6 +134,7 @@ export interface Constants {
|
||||
flagOpenDevTools: boolean;
|
||||
syncVersion: number;
|
||||
startupDevPlugins: string[];
|
||||
isSubProfile: boolean;
|
||||
}
|
||||
|
||||
interface SettingSections {
|
||||
@@ -243,6 +259,7 @@ class Setting extends BaseModel {
|
||||
resourceDirName: '',
|
||||
resourceDir: '',
|
||||
profileDir: '',
|
||||
rootProfileDir: '',
|
||||
tempDir: '',
|
||||
pluginDataDir: '',
|
||||
cacheDir: '',
|
||||
@@ -250,6 +267,7 @@ class Setting extends BaseModel {
|
||||
flagOpenDevTools: false,
|
||||
syncVersion: 3,
|
||||
startupDevPlugins: [],
|
||||
isSubProfile: false,
|
||||
};
|
||||
|
||||
public static autoSaveEnabled = true;
|
||||
@@ -264,6 +282,7 @@ class Setting extends BaseModel {
|
||||
private static customSections_: SettingSections = {};
|
||||
private static changedKeys_: string[] = [];
|
||||
private static fileHandler_: FileHandler = null;
|
||||
private static rootFileHandler_: FileHandler = null;
|
||||
private static settingFilename_: string = 'settings.json';
|
||||
|
||||
static tableName() {
|
||||
@@ -291,6 +310,10 @@ class Setting extends BaseModel {
|
||||
return `${this.value('profileDir')}/${this.settingFilename_}`;
|
||||
}
|
||||
|
||||
public static get rootSettingFilePath(): string {
|
||||
return `${this.value('rootProfileDir')}/${this.settingFilename_}`;
|
||||
}
|
||||
|
||||
public static get settingFilename(): string {
|
||||
return this.settingFilename_;
|
||||
}
|
||||
@@ -306,6 +329,13 @@ class Setting extends BaseModel {
|
||||
return this.fileHandler_;
|
||||
}
|
||||
|
||||
public static get rootFileHandler(): FileHandler {
|
||||
if (!this.rootFileHandler_) {
|
||||
this.rootFileHandler_ = new FileHandler(this.rootSettingFilePath);
|
||||
}
|
||||
return this.rootFileHandler_;
|
||||
}
|
||||
|
||||
static keychainService() {
|
||||
if (!this.keychainService_) throw new Error('keychainService has not been set!!');
|
||||
return this.keychainService_;
|
||||
@@ -359,6 +389,7 @@ class Setting extends BaseModel {
|
||||
public: false,
|
||||
appTypes: [AppType.Desktop],
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'sync.openSyncWizard': {
|
||||
@@ -669,6 +700,7 @@ class Setting extends BaseModel {
|
||||
};
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'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.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
|
||||
// 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.
|
||||
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 },
|
||||
locale: {
|
||||
@@ -708,6 +740,7 @@ class Setting extends BaseModel {
|
||||
return ObjectUtils.sortByValue(supportedLocalesToLanguages({ includeStats: true }));
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
dateFormat: {
|
||||
value: Setting.DATE_FORMAT_1,
|
||||
@@ -730,6 +763,7 @@ class Setting extends BaseModel {
|
||||
return options;
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
timeFormat: {
|
||||
value: Setting.TIME_FORMAT_1,
|
||||
@@ -746,6 +780,7 @@ class Setting extends BaseModel {
|
||||
return options;
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
theme: {
|
||||
@@ -761,6 +796,7 @@ class Setting extends BaseModel {
|
||||
section: 'appearance',
|
||||
options: () => themeOptions(),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
themeAutoDetect: {
|
||||
@@ -771,6 +807,7 @@ class Setting extends BaseModel {
|
||||
public: true,
|
||||
label: () => _('Automatically switch theme to match system theme'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
preferredLightTheme: {
|
||||
@@ -786,6 +823,7 @@ class Setting extends BaseModel {
|
||||
section: 'appearance',
|
||||
options: () => themeOptions(),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
preferredDarkTheme: {
|
||||
@@ -801,6 +839,7 @@ class Setting extends BaseModel {
|
||||
section: 'appearance',
|
||||
options: () => themeOptions(),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
notificationPermission: {
|
||||
@@ -809,7 +848,7 @@ class Setting extends BaseModel {
|
||||
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: {
|
||||
value: Setting.LAYOUT_ALL,
|
||||
@@ -824,9 +863,10 @@ class Setting extends BaseModel {
|
||||
[Setting.LAYOUT_VIEWER_SPLIT]: _('%s / %s', _('Viewer'), _('Split View')),
|
||||
}),
|
||||
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') },
|
||||
showCompletedTodos: { value: true, type: SettingItemType.Bool, storage: SettingStorage.File, section: 'note', public: true, appTypes: [AppType.Cli], label: () => _('Show completed to-dos') },
|
||||
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, isGlobal: true, section: 'note', public: true, appTypes: [AppType.Cli], label: () => _('Show completed to-dos') },
|
||||
'notes.sortOrder.field': {
|
||||
value: 'user_updated_time',
|
||||
type: SettingItemType.String,
|
||||
@@ -845,6 +885,7 @@ class Setting extends BaseModel {
|
||||
return options;
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'editor.autoMatchingBraces': {
|
||||
value: true,
|
||||
@@ -854,8 +895,9 @@ class Setting extends BaseModel {
|
||||
appTypes: [AppType.Desktop],
|
||||
label: () => _('Auto-pair braces, parenthesis, quotations, etc.'),
|
||||
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,
|
||||
// which implies changing the setting automatically triggers the reflesh of notes.
|
||||
// See lib/BaseApplication.ts/generalMiddleware() for details.
|
||||
@@ -868,6 +910,7 @@ class Setting extends BaseModel {
|
||||
label: () => _('Show sort order buttons'),
|
||||
// description: () => _('If true, sort order buttons (field + reverse) for notes are shown at the top of Note List.'),
|
||||
appTypes: [AppType.Desktop],
|
||||
isGlobal: true,
|
||||
},
|
||||
'notes.perFieldReversalEnabled': {
|
||||
value: true,
|
||||
@@ -931,8 +974,8 @@ class Setting extends BaseModel {
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
'folders.sortOrder.reverse': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, 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') },
|
||||
'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, isGlobal: true, public: true, label: () => _('Save geo-location with notes') },
|
||||
|
||||
// 2020-10-29: For now disable the beta editor due to
|
||||
// underlying bugs in the TextInput component which we cannot
|
||||
@@ -947,6 +990,8 @@ class Setting extends BaseModel {
|
||||
appTypes: [AppType.Mobile],
|
||||
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.',
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
newTodoFocus: {
|
||||
@@ -964,6 +1009,7 @@ class Setting extends BaseModel {
|
||||
};
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
newNoteFocus: {
|
||||
value: 'body',
|
||||
@@ -980,6 +1026,7 @@ class Setting extends BaseModel {
|
||||
};
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'plugins.states': {
|
||||
@@ -1005,31 +1052,31 @@ class Setting extends BaseModel {
|
||||
},
|
||||
|
||||
// Deprecated - use markdown.plugin.*
|
||||
'markdown.softbreaks': { storage: SettingStorage.File, 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.softbreaks': { storage: SettingStorage.File, isGlobal: true, 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
|
||||
|
||||
'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.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.linkify': { storage: SettingStorage.File, value: true, type: SettingItemType.Bool, section: 'markdownPlugins', public: true, appTypes: [AppType.Mobile, AppType.Desktop], label: () => `${_('Enable Linkify')}${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, 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, 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.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.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.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, 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, 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.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.pdfViewer': { storage: SettingStorage.File, 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.footnote': { storage: SettingStorage.File, 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.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.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.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.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.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.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.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.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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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, 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
|
||||
// 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.');
|
||||
},
|
||||
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 },
|
||||
|
||||
@@ -1072,9 +1120,9 @@ class Setting extends BaseModel {
|
||||
},
|
||||
|
||||
// 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':
|
||||
(mobilePlatform) ?
|
||||
({
|
||||
@@ -1101,6 +1149,7 @@ class Setting extends BaseModel {
|
||||
};
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
}) : {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
@@ -1111,6 +1160,7 @@ class Setting extends BaseModel {
|
||||
description: () =>
|
||||
_('Used for most text in the markdown editor. If not found, a generic proportional (variable width) font is used.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'style.editor.monospaceFontFamily': {
|
||||
value: '',
|
||||
@@ -1122,9 +1172,10 @@ class Setting extends BaseModel {
|
||||
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.'),
|
||||
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] },
|
||||
|
||||
@@ -1149,6 +1200,8 @@ class Setting extends BaseModel {
|
||||
label: () => _('Custom stylesheet for rendered Markdown'),
|
||||
section: 'appearance',
|
||||
advanced: true,
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'style.customCss.joplinApp': {
|
||||
value: null,
|
||||
@@ -1167,6 +1220,8 @@ class Setting extends BaseModel {
|
||||
section: 'appearance',
|
||||
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.',
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'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') },
|
||||
'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') },
|
||||
'clipperServer.autoStart': { value: false, type: SettingItemType.Bool, storage: SettingStorage.File, public: false },
|
||||
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, 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, isGlobal: true, public: false },
|
||||
'sync.interval': {
|
||||
value: 300,
|
||||
type: SettingItemType.Int,
|
||||
@@ -1214,6 +1269,7 @@ class Setting extends BaseModel {
|
||||
};
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'sync.mobileWifiOnly': {
|
||||
value: false,
|
||||
@@ -1223,12 +1279,13 @@ class Setting extends BaseModel {
|
||||
label: () => _('Synchronise only over WiFi connection'),
|
||||
storage: SettingStorage.File,
|
||||
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] },
|
||||
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.') },
|
||||
'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: () => {
|
||||
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, isGlobal: true, isEnum: true, public: true, appTypes: [AppType.Desktop], label: () => _('Page size for PDF export'), options: () => {
|
||||
return {
|
||||
'A4': _('A4'),
|
||||
'Letter': _('Letter'),
|
||||
@@ -1238,7 +1295,7 @@ class Setting extends BaseModel {
|
||||
'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 {
|
||||
'portrait': _('Portrait'),
|
||||
'landscape': _('Landscape'),
|
||||
@@ -1261,6 +1318,7 @@ class Setting extends BaseModel {
|
||||
return output;
|
||||
},
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'editor.spellcheckBeta': {
|
||||
@@ -1270,6 +1328,8 @@ class Setting extends BaseModel {
|
||||
appTypes: [AppType.Desktop],
|
||||
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)',
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'net.customCertificates': {
|
||||
@@ -1324,8 +1384,8 @@ class Setting extends BaseModel {
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'api.token': { value: null, type: SettingItemType.String, public: false, storage: SettingStorage.File },
|
||||
'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.token': { value: null, type: SettingItemType.String, public: false, storage: SettingStorage.File, isGlobal: true },
|
||||
'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 },
|
||||
'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.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.language': { value: '', type: SettingItemType.String, 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, isGlobal: true, storage: SettingStorage.File, public: false },
|
||||
|
||||
windowContentZoomFactor: {
|
||||
value: 100,
|
||||
@@ -1369,6 +1429,7 @@ class Setting extends BaseModel {
|
||||
maximum: 300,
|
||||
step: 10,
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'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.' +
|
||||
'Restart app to see changes.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'layout.noteList.factor': {
|
||||
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.' +
|
||||
'Restart app to see changes.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
'layout.note.factor': {
|
||||
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.' +
|
||||
'Restart app to see changes.'),
|
||||
storage: SettingStorage.File,
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'syncInfoCache': {
|
||||
@@ -1452,9 +1516,17 @@ class Setting extends BaseModel {
|
||||
|
||||
this.metadata_ = Object.assign(this.metadata_, this.customMetadata_);
|
||||
|
||||
if (this.constants_.env === Env.Dev) this.validateMetadata(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() {
|
||||
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.
|
||||
public static async loadOne(key: string): Promise<CacheItem | null> {
|
||||
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 {
|
||||
key,
|
||||
value: fromFile[key],
|
||||
value: fileSettings[key],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1664,11 +1743,17 @@ class Setting extends BaseModel {
|
||||
const itemsFromFile: CacheItem[] = [];
|
||||
|
||||
if (this.canUseFileStorage()) {
|
||||
const fromFile = await this.fileHandler.load();
|
||||
for (const k of Object.keys(fromFile)) {
|
||||
let fileSettings = await this.fileHandler.load();
|
||||
|
||||
if (this.value('isSubProfile')) {
|
||||
const rootFileSettings = await this.rootFileHandler.load();
|
||||
fileSettings = mergeGlobalAndLocalSettings(rootFileSettings, fileSettings);
|
||||
}
|
||||
|
||||
for (const k of Object.keys(fileSettings)) {
|
||||
itemsFromFile.push({
|
||||
key: k,
|
||||
value: fromFile[k],
|
||||
value: fileSettings[k],
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2018,7 +2103,15 @@ class Setting extends BaseModel {
|
||||
|
||||
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.');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/lib",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.0",
|
||||
"description": "Joplin Core library",
|
||||
"author": "Laurent Cozic",
|
||||
"homepage": "",
|
||||
@@ -33,10 +33,11 @@
|
||||
"@joplin/fork-htmlparser2": "^4.1.39",
|
||||
"@joplin/fork-sax": "^1.2.43",
|
||||
"@joplin/fork-uslug": "^1.0.4",
|
||||
"@joplin/htmlpack": "~2.7",
|
||||
"@joplin/renderer": "~2.7",
|
||||
"@joplin/htmlpack": "~2.8",
|
||||
"@joplin/renderer": "~2.8",
|
||||
"@joplin/turndown": "^4.0.61",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.43",
|
||||
"@types/nanoid": "^3.0.0",
|
||||
"async-mutex": "^0.1.3",
|
||||
"base-64": "^0.1.0",
|
||||
"base64-stream": "^1.0.0",
|
||||
|
||||
@@ -5,6 +5,7 @@ import Note from './models/Note';
|
||||
import Folder from './models/Folder';
|
||||
import BaseModel from './BaseModel';
|
||||
import { Store } from 'redux';
|
||||
import { ProfileConfig } from './services/profileConfig/types';
|
||||
const ArrayUtils = require('./ArrayUtils.js');
|
||||
const { ALL_NOTES_FILTER_ID } = require('./reserved-ids');
|
||||
const { createSelectorCreator, defaultMemoize } = require('reselect');
|
||||
@@ -92,6 +93,7 @@ export interface State {
|
||||
isInsertingNotes: boolean;
|
||||
hasEncryptedItems: boolean;
|
||||
needApiAuth: boolean;
|
||||
profileConfig: ProfileConfig;
|
||||
|
||||
// Extra reducer keys go here:
|
||||
pluginService: PluginServiceState;
|
||||
@@ -162,6 +164,7 @@ export const defaultState: State = {
|
||||
isInsertingNotes: false,
|
||||
hasEncryptedItems: false,
|
||||
needApiAuth: false,
|
||||
profileConfig: null,
|
||||
|
||||
pluginService: pluginServiceDefaultState,
|
||||
shareService: shareServiceDefaultState,
|
||||
@@ -1138,6 +1141,10 @@ const reducer = produce((draft: Draft<State> = defaultState, action: any) => {
|
||||
draft.needApiAuth = action.value;
|
||||
break;
|
||||
|
||||
case 'PROFILE_CONFIG_SET':
|
||||
draft.profileConfig = action.value;
|
||||
break;
|
||||
|
||||
}
|
||||
} catch (error) {
|
||||
error.message = `In reducer: ${error.message} Action: ${JSON.stringify(action)}`;
|
||||
|
||||
@@ -199,6 +199,18 @@ export default class CommandService extends BaseService {
|
||||
command.runtime = runtime;
|
||||
}
|
||||
|
||||
public registerCommands(commands: any[]) {
|
||||
for (const command of commands) {
|
||||
CommandService.instance().registerRuntime(command.declaration.name, command.runtime());
|
||||
}
|
||||
}
|
||||
|
||||
public unregisterCommands(commands: any[]) {
|
||||
for (const command of commands) {
|
||||
CommandService.instance().unregisterRuntime(command.declaration.name);
|
||||
}
|
||||
}
|
||||
|
||||
public componentRegisterCommands(component: any, commands: any[]) {
|
||||
for (const command of commands) {
|
||||
CommandService.instance().registerRuntime(command.declaration.name, command.runtime(component));
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import Logger from '../Logger';
|
||||
import Setting from '../models/Setting';
|
||||
import shim from '../shim';
|
||||
import { fileExtension, basename, toSystemSlashes } from '../path-utils';
|
||||
import { basename, toSystemSlashes } from '../path-utils';
|
||||
import time from '../time';
|
||||
import { NoteEntity } from './database/types';
|
||||
|
||||
import Note from '../models/Note';
|
||||
import { openFileWithExternalEditor } from './ExternalEditWatcher/utils';
|
||||
const EventEmitter = require('events');
|
||||
const { splitCommandString } = require('../string-utils');
|
||||
const spawn = require('child_process').spawn;
|
||||
const chokidar = require('chokidar');
|
||||
const { ErrorNotFound } = require('./rest/utils/errors');
|
||||
|
||||
@@ -213,68 +211,6 @@ export default class ExternalEditWatcher {
|
||||
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) {
|
||||
if (!note || !note.id) {
|
||||
this.logger().warn('ExternalEditWatcher: Cannot open note: ', note);
|
||||
@@ -285,13 +221,7 @@ export default class ExternalEditWatcher {
|
||||
if (!filePath) return;
|
||||
this.watch(filePath);
|
||||
|
||||
const cmd = this.textEditorCommand();
|
||||
if (!cmd) {
|
||||
this.bridge_().openExternal(`file://${filePath}`);
|
||||
} else {
|
||||
cmd.args.push(filePath);
|
||||
await this.spawnCommand(cmd.path, cmd.args, { detached: true });
|
||||
}
|
||||
await openFileWithExternalEditor(filePath, this.bridge_());
|
||||
|
||||
this.dispatch({
|
||||
type: 'NOTE_FILE_WATCHER_ADD',
|
||||
|
||||
82
packages/lib/services/ExternalEditWatcher/utils.ts
Normal file
82
packages/lib/services/ExternalEditWatcher/utils.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -55,6 +55,9 @@ const defaultKeymapItems = {
|
||||
{ accelerator: 'Option+Cmd+A', command: 'editor.sortSelectedLines' },
|
||||
{ accelerator: 'Option+Up', command: 'editor.swapLineUp' },
|
||||
{ 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: [
|
||||
{ accelerator: 'Ctrl+N', command: 'newNote' },
|
||||
@@ -97,6 +100,9 @@ const defaultKeymapItems = {
|
||||
{ accelerator: 'Ctrl+Alt+S', command: 'editor.sortSelectedLines' },
|
||||
{ accelerator: 'Alt+Up', command: 'editor.swapLineUp' },
|
||||
{ accelerator: 'Alt+Down', command: 'editor.swapLineDown' },
|
||||
{ accelerator: 'Ctrl+Alt+1', command: 'switchProfile1' },
|
||||
{ accelerator: 'Ctrl+Alt+2', command: 'switchProfile2' },
|
||||
{ accelerator: 'Ctrl+Alt+3', command: 'switchProfile3' },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -6,13 +6,15 @@ import propsHaveChanged from './propsHaveChanged';
|
||||
const { createSelectorCreator, defaultMemoize } = require('reselect');
|
||||
const { createCachedSelector } = require('re-reselect');
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
click: Function;
|
||||
export interface MenuItem {
|
||||
id?: string;
|
||||
label?: string;
|
||||
click?: Function;
|
||||
role?: any;
|
||||
type?: string;
|
||||
accelerator?: string;
|
||||
enabled: boolean;
|
||||
checked?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
interface MenuItems {
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface WhenClauseContext {
|
||||
folderIsShared: boolean;
|
||||
folderIsShareRoot: boolean;
|
||||
joplinServerConnected: boolean;
|
||||
hasMultiProfiles: boolean;
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
joplinServerConnected: [9, 10].includes(state.settings['sync.target']),
|
||||
|
||||
hasMultiProfiles: state.profileConfig && state.profileConfig.profiles.length > 1,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -254,6 +254,7 @@ export default class EncryptionService {
|
||||
model.created_time = now;
|
||||
model.updated_time = now;
|
||||
model.source_application = Setting.value('appId');
|
||||
model.hasBeenUsed = false;
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface MasterKeyEntity {
|
||||
content?: string;
|
||||
type_?: number;
|
||||
enabled?: number;
|
||||
hasBeenUsed?: boolean;
|
||||
}
|
||||
|
||||
export type RSAKeyPair = any; // Depends on implementation
|
||||
|
||||
@@ -46,7 +46,6 @@ export default class InteropService_Importer_Md extends InteropService_Importer_
|
||||
}
|
||||
|
||||
async importDirectory(dirPath: string, parentFolderId: string) {
|
||||
console.info(`Import: ${dirPath}`);
|
||||
const supportedFileExtension = this.metadata().fileExtensions;
|
||||
const stats = await shim.fsDriver().readDirStats(dirPath);
|
||||
for (let i = 0; i < stats.length; i++) {
|
||||
@@ -54,7 +53,6 @@ export default class InteropService_Importer_Md extends InteropService_Importer_
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
if (await this.isDirectoryEmpty(`${dirPath}/${stat.path}`)) {
|
||||
console.info(`Ignoring empty directory: ${stat.path}`);
|
||||
continue;
|
||||
}
|
||||
const folderTitle = await Folder.findUniqueItemTitle(basename(stat.path));
|
||||
|
||||
85
packages/lib/services/profileConfig/index.test.ts
Normal file
85
packages/lib/services/profileConfig/index.test.ts
Normal 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);
|
||||
});
|
||||
|
||||
});
|
||||
64
packages/lib/services/profileConfig/index.ts
Normal file
64
packages/lib/services/profileConfig/index.ts
Normal 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;
|
||||
};
|
||||
16
packages/lib/services/profileConfig/initProfile.ts
Normal file
16
packages/lib/services/profileConfig/initProfile.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
};
|
||||
27
packages/lib/services/profileConfig/types.ts
Normal file
27
packages/lib/services/profileConfig/types.ts
Normal 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;
|
||||
@@ -379,6 +379,28 @@ describe('services_rest_Api', function() {
|
||||
expect(resourceV2.size).toBe((await shim.fsDriver().stat(Resource.fullPath(resourceV2))).size);
|
||||
}));
|
||||
|
||||
it('should allow updating a resource file only', (async () => {
|
||||
await api.route(RequestMethod.POST, 'resources', null, JSON.stringify({
|
||||
title: 'resource',
|
||||
}), [{ path: `${supportDir}/photo.jpg` }]);
|
||||
|
||||
const resourceV1: ResourceEntity = (await Resource.all())[0];
|
||||
|
||||
await msleep(1);
|
||||
|
||||
await api.route(RequestMethod.PUT, `resources/${resourceV1.id}`, null, null, [
|
||||
{
|
||||
path: `${supportDir}/photo-large.png`,
|
||||
},
|
||||
]);
|
||||
|
||||
const resourceV2: ResourceEntity = (await Resource.all())[0];
|
||||
|
||||
// It should have updated the file content, but not the metadata
|
||||
expect(resourceV2.title).toBe(resourceV1.title);
|
||||
expect(resourceV2.size).toBeGreaterThan(resourceV1.size);
|
||||
}));
|
||||
|
||||
it('should update resource properties', (async () => {
|
||||
await api.route(RequestMethod.POST, 'resources', null, JSON.stringify({
|
||||
title: 'resource',
|
||||
|
||||
@@ -26,6 +26,7 @@ const input: Theme = {
|
||||
selectedColor2: '#131313',
|
||||
colorError2: '#ff6c6c',
|
||||
colorWarn2: '#ffcb81',
|
||||
colorWarn3: '#ff7626',
|
||||
|
||||
// Color scheme "3" is used for the config screens for example/
|
||||
// It's dark text over gray background.
|
||||
@@ -76,6 +77,7 @@ const expected = `
|
||||
--joplin-selected-color2: #131313;
|
||||
--joplin-color-error2: #ff6c6c;
|
||||
--joplin-color-warn2: #ffcb81;
|
||||
--joplin-color-warn3: #ff7626;
|
||||
--joplin-background-color3: #F4F5F6;
|
||||
--joplin-background-color-hover3: #CBDAF1;
|
||||
--joplin-color3: #738598;
|
||||
|
||||
@@ -9,7 +9,7 @@ import ResourceFetcher from '../../services/ResourceFetcher';
|
||||
import MasterKey from '../../models/MasterKey';
|
||||
import BaseItem from '../../models/BaseItem';
|
||||
import Synchronizer from '../../Synchronizer';
|
||||
import { getEncryptionEnabled, setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
|
||||
import { fetchSyncInfo, getEncryptionEnabled, localSyncInfo, setEncryptionEnabled } from '../synchronizer/syncInfoUtils';
|
||||
import { loadMasterKeysFromSettings, setupAndDisableEncryption, setupAndEnableEncryption } from '../e2ee/utils';
|
||||
|
||||
let insideBeforeEach = false;
|
||||
@@ -73,6 +73,32 @@ describe('Synchronizer.e2ee', function() {
|
||||
expect(!folder1_2.encryption_cipher_text).toBe(true);
|
||||
}));
|
||||
|
||||
it('should mark the key has having been used when synchronising the first time', (async () => {
|
||||
setEncryptionEnabled(true);
|
||||
await loadEncryptionMasterKey();
|
||||
await Folder.save({ title: 'folder1' });
|
||||
await synchronizerStart();
|
||||
|
||||
const localInfo = localSyncInfo();
|
||||
const remoteInfo = await fetchSyncInfo(fileApi());
|
||||
expect(localInfo.masterKeys[0].hasBeenUsed).toBe(true);
|
||||
expect(remoteInfo.masterKeys[0].hasBeenUsed).toBe(true);
|
||||
}));
|
||||
|
||||
it('should mark the key has having been used when synchronising after enabling encryption', (async () => {
|
||||
await Folder.save({ title: 'folder1' });
|
||||
await synchronizerStart();
|
||||
|
||||
setEncryptionEnabled(true);
|
||||
await loadEncryptionMasterKey();
|
||||
await synchronizerStart();
|
||||
|
||||
const localInfo = localSyncInfo();
|
||||
const remoteInfo = await fetchSyncInfo(fileApi());
|
||||
expect(localInfo.masterKeys[0].hasBeenUsed).toBe(true);
|
||||
expect(remoteInfo.masterKeys[0].hasBeenUsed).toBe(true);
|
||||
}));
|
||||
|
||||
it('should enable encryption automatically when downloading new master key (and none was previously available)',(async () => {
|
||||
// Enable encryption on client 1 and sync an item
|
||||
setEncryptionEnabled(true);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterAllCleanUp, setupDatabaseAndSynchronizer, switchClient, encryptionService } from '../../testing/test-utils';
|
||||
import { afterAllCleanUp, setupDatabaseAndSynchronizer, switchClient, encryptionService, msleep } from '../../testing/test-utils';
|
||||
import MasterKey from '../../models/MasterKey';
|
||||
import { masterKeyEnabled, setMasterKeyEnabled, SyncInfo, syncInfoEquals } from './syncInfoUtils';
|
||||
import { masterKeyEnabled, mergeSyncInfos, setMasterKeyEnabled, SyncInfo, syncInfoEquals } from './syncInfoUtils';
|
||||
|
||||
describe('syncInfoUtils', function() {
|
||||
|
||||
@@ -92,4 +92,33 @@ describe('syncInfoUtils', function() {
|
||||
}
|
||||
});
|
||||
|
||||
it('should merge sync target info and takes into account usage of master key - 1', async () => {
|
||||
const syncInfo1 = new SyncInfo();
|
||||
syncInfo1.masterKeys = [{
|
||||
id: '1',
|
||||
content: 'content1',
|
||||
hasBeenUsed: true,
|
||||
}];
|
||||
syncInfo1.activeMasterKeyId = '1';
|
||||
|
||||
await msleep(1);
|
||||
|
||||
const syncInfo2 = new SyncInfo();
|
||||
syncInfo2.masterKeys = [{
|
||||
id: '2',
|
||||
content: 'content2',
|
||||
hasBeenUsed: false,
|
||||
}];
|
||||
syncInfo2.activeMasterKeyId = '2';
|
||||
|
||||
// If one master key has been used and the other not, it should select
|
||||
// the one that's been used regardless of timestamps.
|
||||
expect(mergeSyncInfos(syncInfo1, syncInfo2).activeMasterKeyId).toBe('1');
|
||||
|
||||
// If both master keys have been used it should rely on timestamp
|
||||
// (latest modified is picked).
|
||||
syncInfo2.masterKeys[0].hasBeenUsed = true;
|
||||
expect(mergeSyncInfos(syncInfo1, syncInfo2).activeMasterKeyId).toBe('2');
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -90,14 +90,62 @@ export function localSyncInfoFromState(state: State): SyncInfo {
|
||||
return new SyncInfo(state.settings['syncInfoCache']);
|
||||
}
|
||||
|
||||
// When deciding which master key should be active we should take into account
|
||||
// whether it's been used or not. If it's been used before it should most likely
|
||||
// remain the active one, regardless of timestamps. This is because the extra
|
||||
// key was most likely created by mistake by the user, in particular in this
|
||||
// kind of scenario:
|
||||
//
|
||||
// - Client 1 setup sync with sync target
|
||||
// - Client 1 enable encryption
|
||||
// - Client 1 sync
|
||||
//
|
||||
// Then user 2 does the same:
|
||||
//
|
||||
// - Client 2 setup sync with sync target
|
||||
// - Client 2 enable encryption
|
||||
// - Client 2 sync
|
||||
//
|
||||
// The problem is that enabling encryption was not needed since it was already
|
||||
// done (and recorded in info.json) on the sync target. As a result an extra key
|
||||
// has been created and it has been set as the active one, but we shouldn't use
|
||||
// it. Instead the key created by client 1 should be used and made active again.
|
||||
//
|
||||
// And we can do this using the "hasBeenUsed" field which tells us which keys
|
||||
// has already been used to encrypt data. In this case, at the moment we compare
|
||||
// local and remote sync info (before synchronising the data), key1.hasBeenUsed
|
||||
// is true, but key2.hasBeenUsed is false.
|
||||
const mergeActiveMasterKeys = (s1: SyncInfo, s2: SyncInfo, output: SyncInfo) => {
|
||||
const activeMasterKey1 = getActiveMasterKey(s1);
|
||||
const activeMasterKey2 = getActiveMasterKey(s2);
|
||||
let doDefaultAction = false;
|
||||
|
||||
if (activeMasterKey1 && activeMasterKey2) {
|
||||
if (activeMasterKey1.hasBeenUsed && !activeMasterKey2.hasBeenUsed) {
|
||||
output.setWithTimestamp(s1, 'activeMasterKeyId');
|
||||
} else if (!activeMasterKey1.hasBeenUsed && activeMasterKey2.hasBeenUsed) {
|
||||
output.setWithTimestamp(s2, 'activeMasterKeyId');
|
||||
} else {
|
||||
doDefaultAction = true;
|
||||
}
|
||||
} else {
|
||||
doDefaultAction = true;
|
||||
}
|
||||
|
||||
if (doDefaultAction) {
|
||||
output.setWithTimestamp(s1.keyTimestamp('activeMasterKeyId') > s2.keyTimestamp('activeMasterKeyId') ? s1 : s2, 'activeMasterKeyId');
|
||||
}
|
||||
};
|
||||
|
||||
export function mergeSyncInfos(s1: SyncInfo, s2: SyncInfo): SyncInfo {
|
||||
const output: SyncInfo = new SyncInfo();
|
||||
|
||||
output.setWithTimestamp(s1.keyTimestamp('e2ee') > s2.keyTimestamp('e2ee') ? s1 : s2, 'e2ee');
|
||||
output.setWithTimestamp(s1.keyTimestamp('activeMasterKeyId') > s2.keyTimestamp('activeMasterKeyId') ? s1 : s2, 'activeMasterKeyId');
|
||||
output.setWithTimestamp(s1.keyTimestamp('ppk') > s2.keyTimestamp('ppk') ? s1 : s2, 'ppk');
|
||||
output.version = s1.version > s2.version ? s1.version : s2.version;
|
||||
|
||||
mergeActiveMasterKeys(s1, s2, output);
|
||||
|
||||
output.masterKeys = s1.masterKeys.slice();
|
||||
|
||||
for (const mk of s2.masterKeys) {
|
||||
@@ -154,6 +202,14 @@ export class SyncInfo {
|
||||
this.activeMasterKeyId_ = 'activeMasterKeyId' in s ? s.activeMasterKeyId : { value: '', updatedTime: 0 };
|
||||
this.masterKeys_ = 'masterKeys' in s ? s.masterKeys : [];
|
||||
this.ppk_ = 'ppk' in s ? s.ppk : { value: null, updatedTime: 0 };
|
||||
|
||||
// Migration for master keys that didn't have "hasBeenUsed" property -
|
||||
// in that case we assume they've been used at least once.
|
||||
for (const mk of this.masterKeys_) {
|
||||
if (!('hasBeenUsed' in mk) || mk.hasBeenUsed === undefined) {
|
||||
mk.hasBeenUsed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public setWithTimestamp(fromSyncInfo: SyncInfo, propName: string) {
|
||||
@@ -275,6 +331,21 @@ export function setMasterKeyEnabled(mkId: string, enabled: boolean = true) {
|
||||
saveLocalSyncInfo(s);
|
||||
}
|
||||
|
||||
export const setMasterKeyHasBeenUsed = (s: SyncInfo, mkId: string) => {
|
||||
const idx = s.masterKeys.findIndex(mk => mk.id === mkId);
|
||||
if (idx < 0) throw new Error(`No such master key: ${mkId}`);
|
||||
|
||||
s.masterKeys[idx] = {
|
||||
...s.masterKeys[idx],
|
||||
hasBeenUsed: true,
|
||||
updated_time: Date.now(),
|
||||
};
|
||||
|
||||
saveLocalSyncInfo(s);
|
||||
|
||||
return s;
|
||||
};
|
||||
|
||||
export function masterKeyEnabled(mk: MasterKeyEntity): boolean {
|
||||
if ('enabled' in mk) return !!mk.enabled;
|
||||
return true;
|
||||
|
||||
@@ -235,6 +235,8 @@ function shimInit(options = null) {
|
||||
const readChunk = require('read-chunk');
|
||||
const imageType = require('image-type');
|
||||
|
||||
const isUpdate = !!options.destinationResourceId;
|
||||
|
||||
const uuid = require('./uuid').default;
|
||||
|
||||
if (!(await fs.pathExists(filePath))) throw new Error(_('Cannot access %s', filePath));
|
||||
@@ -242,12 +244,16 @@ function shimInit(options = null) {
|
||||
defaultProps = defaultProps ? defaultProps : {};
|
||||
|
||||
let resourceId = defaultProps.id ? defaultProps.id : uuid.create();
|
||||
if (options.destinationResourceId) resourceId = options.destinationResourceId;
|
||||
if (isUpdate) resourceId = options.destinationResourceId;
|
||||
|
||||
let resource = options.destinationResourceId ? {} : Resource.new();
|
||||
let resource = isUpdate ? {} : Resource.new();
|
||||
resource.id = resourceId;
|
||||
|
||||
// When this is an update we auto-update the mime type, in case the
|
||||
// content type has changed, but we keep the title. It is still possible
|
||||
// to modify the title on update using defaultProps.
|
||||
resource.mime = mimeUtils.fromFilename(filePath);
|
||||
resource.title = basename(filePath);
|
||||
if (!isUpdate) resource.title = basename(filePath);
|
||||
|
||||
let fileExt = safeFileExtension(fileExtension(filePath));
|
||||
|
||||
@@ -288,7 +294,7 @@ function shimInit(options = null) {
|
||||
const saveOptions = { isNew: true };
|
||||
if (options.userSideValidation) saveOptions.userSideValidation = true;
|
||||
|
||||
if (options.destinationResourceId) {
|
||||
if (isUpdate) {
|
||||
saveOptions.isNew = false;
|
||||
const tempPath = `${targetPath}.tmp`;
|
||||
await shim.fsDriver().move(targetPath, tempPath);
|
||||
|
||||
@@ -106,6 +106,7 @@ const supportDir = `${oldTestDir}/support`;
|
||||
// various space-in-path issues.
|
||||
const dataDir = `${oldTestDir}/test data/${suiteName_}`;
|
||||
const profileDir = `${dataDir}/profile`;
|
||||
const rootProfileDir = profileDir;
|
||||
|
||||
fs.mkdirpSync(logDir);
|
||||
fs.mkdirpSync(baseTempDir);
|
||||
@@ -185,6 +186,7 @@ Setting.setConstant('tempDir', baseTempDir);
|
||||
Setting.setConstant('cacheDir', baseTempDir);
|
||||
Setting.setConstant('pluginDataDir', `${profileDir}/profile/plugin-data`);
|
||||
Setting.setConstant('profileDir', profileDir);
|
||||
Setting.setConstant('rootProfileDir', rootProfileDir);
|
||||
Setting.setConstant('env', 'dev');
|
||||
|
||||
BaseService.logger_ = logger;
|
||||
@@ -271,6 +273,8 @@ async function switchClient(id: number, options: any = null) {
|
||||
await Setting.reset();
|
||||
Setting.settingFilename = `settings-${id}.json`;
|
||||
|
||||
Setting.setConstant('profileDir', rootProfileDir);
|
||||
Setting.setConstant('rootProfileDir', rootProfileDir);
|
||||
Setting.setConstant('resourceDirName', resourceDirName(id));
|
||||
Setting.setConstant('resourceDir', resourceDir(id));
|
||||
Setting.setConstant('pluginDir', pluginDir(id));
|
||||
@@ -330,6 +334,9 @@ async function setupDatabase(id: number = null, options: any = null) {
|
||||
// running.
|
||||
await Setting.reset();
|
||||
|
||||
Setting.setConstant('profileDir', rootProfileDir);
|
||||
Setting.setConstant('rootProfileDir', rootProfileDir);
|
||||
|
||||
if (databases_[id]) {
|
||||
BaseModel.setDb(databases_[id]);
|
||||
await clearDatabase(id);
|
||||
|
||||
@@ -29,6 +29,7 @@ const theme: Theme = {
|
||||
selectedColor2: '#013F74',
|
||||
colorError2: '#ff6c6c',
|
||||
colorWarn2: '#ffcb81',
|
||||
colorWarn3: '#ffcb81',
|
||||
|
||||
// Color scheme "3" is used for the config screens for example/
|
||||
// It's dark text over gray background.
|
||||
|
||||
@@ -26,6 +26,7 @@ const theme: Theme = {
|
||||
selectedColor2: '#131313',
|
||||
colorError2: '#ff6c6c',
|
||||
colorWarn2: '#ffcb81',
|
||||
colorWarn3: '#ff7626',
|
||||
|
||||
// Color scheme "3" is used for the config screens for example/
|
||||
// It's dark text over gray background.
|
||||
|
||||
@@ -27,7 +27,8 @@ export interface Theme {
|
||||
color2: string;
|
||||
selectedColor2: string;
|
||||
colorError2: string;
|
||||
colorWarn2: string;
|
||||
colorWarn2: string; // On a darker background (eg. sidebar)
|
||||
colorWarn3: string; // On a lighter background (eg. note list)
|
||||
|
||||
// Color scheme "3" is used for the config screens for example/
|
||||
// It's dark text over gray background.
|
||||
|
||||
@@ -129,11 +129,11 @@ const features: Record<FeatureId, PlanFeature> = {
|
||||
pro: true,
|
||||
teams: true,
|
||||
basicInfo: '1 GB storage space',
|
||||
proInfo: '200 GB storage space',
|
||||
teamsInfo: '200 GB storage space',
|
||||
proInfo: '10 GB storage space',
|
||||
teamsInfo: '10 GB storage space',
|
||||
basicInfoShort: '1 GB',
|
||||
proInfoShort: '200 GB',
|
||||
teamsInfoShort: '200 GB',
|
||||
proInfoShort: '10 GB',
|
||||
teamsInfoShort: '10 GB',
|
||||
},
|
||||
publishNote: {
|
||||
title: 'Publish notes to the internet',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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://security.stackexchange.com/a/41749/1873
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/plugin-repo-cli",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"bin": "./dist/index.js",
|
||||
@@ -18,8 +18,8 @@
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@joplin/lib": "~2.7",
|
||||
"@joplin/tools": "~2.7",
|
||||
"@joplin/lib": "~2.8",
|
||||
"@joplin/tools": "~2.8",
|
||||
"fs-extra": "^9.0.1",
|
||||
"gh-release-assets": "^2.0.0",
|
||||
"node-fetch": "^2.6.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/renderer",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.0",
|
||||
"description": "The Joplin note renderer, used the mobile and desktop application",
|
||||
"repository": "https://github.com/laurent22/joplin/tree/dev/packages/renderer",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/server",
|
||||
"version": "2.7.4",
|
||||
"version": "2.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"start-dev": "yarn run build && JOPLIN_IS_TESTING=1 nodemon --config nodemon.json --ext ts,js,mustache,css,tsx dist/app.js --env dev",
|
||||
@@ -23,8 +23,8 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.40.0",
|
||||
"@fortawesome/fontawesome-free": "^5.15.1",
|
||||
"@joplin/lib": "~2.7",
|
||||
"@joplin/renderer": "~2.7",
|
||||
"@joplin/lib": "~2.8",
|
||||
"@joplin/renderer": "~2.8",
|
||||
"@koa/cors": "^3.1.0",
|
||||
"@types/uuid": "^8.3.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -58,7 +58,7 @@
|
||||
"zxcvbn": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@joplin/tools": "~2.7",
|
||||
"@joplin/tools": "~2.8",
|
||||
"@rmp135/sql-ts": "^1.12.1",
|
||||
"@types/fs-extra": "^8.0.0",
|
||||
"@types/jest": "^26.0.15",
|
||||
|
||||
@@ -5,7 +5,7 @@ import uuidgen from '../utils/uuidgen';
|
||||
import { ErrorUnprocessableEntity, ErrorBadRequest } from '../utils/errors';
|
||||
import { Models, NewModelFactoryHandler } from './factory';
|
||||
import * as EventEmitter from 'events';
|
||||
import { Config } from '../utils/types';
|
||||
import { Config, Env } from '../utils/types';
|
||||
import personalizedUserContentBaseUrl from '@joplin/lib/services/joplinServer/personalizedUserContentBaseUrl';
|
||||
import Logger from '@joplin/lib/Logger';
|
||||
import dbuuid from '../utils/dbuuid';
|
||||
@@ -85,6 +85,10 @@ export default abstract class BaseModel<T> {
|
||||
return this.config_.userContentBaseUrl;
|
||||
}
|
||||
|
||||
protected get env(): Env {
|
||||
return this.config_.env;
|
||||
}
|
||||
|
||||
protected personalizedUserContentBaseUrl(userId: Uuid): string {
|
||||
return personalizedUserContentBaseUrl(userId, this.baseUrl, this.userContentBaseUrl);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import changeEmailConfirmationTemplate from '../views/emails/changeEmailConfirma
|
||||
import changeEmailNotificationTemplate from '../views/emails/changeEmailNotificationTemplate';
|
||||
import { NotificationKey } from './NotificationModel';
|
||||
import prettyBytes = require('pretty-bytes');
|
||||
import { Env } from '../utils/types';
|
||||
|
||||
const logger = Logger.create('UserModel');
|
||||
|
||||
@@ -237,6 +238,8 @@ export default class UserModel extends BaseModel<User> {
|
||||
}
|
||||
|
||||
private validatePassword(password: string) {
|
||||
if (this.env === Env.Dev) return;
|
||||
|
||||
const result = zxcvbn(password);
|
||||
if (result.score < 3) {
|
||||
let msg: string[] = [result.feedback.warning];
|
||||
|
||||
@@ -161,8 +161,8 @@ describe('admin/users', function() {
|
||||
await patchUser(session.id, {
|
||||
id: user.id,
|
||||
email: 'changed@example.com',
|
||||
password: 'hunter11hunter22hunter33',
|
||||
password2: 'hunter11hunter22hunter33',
|
||||
password: '111111',
|
||||
password2: '111111',
|
||||
}, '/admin/users/me');
|
||||
|
||||
const sessions = await models().session().all();
|
||||
|
||||
@@ -33,7 +33,7 @@ export async function createTestUsers(db: DbConnection, config: Config, options:
|
||||
...options,
|
||||
};
|
||||
|
||||
const password = 'hunter1hunter2hunter3';
|
||||
const password = '111111';
|
||||
|
||||
const models = newModelFactory(db, config);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@joplin/tools",
|
||||
"version": "2.7.0",
|
||||
"version": "2.8.0",
|
||||
"description": "Various tools for Joplin",
|
||||
"main": "index.js",
|
||||
"author": "Laurent Cozic",
|
||||
@@ -20,8 +20,8 @@
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@joplin/lib": "~2.7",
|
||||
"@joplin/renderer": "~2.7",
|
||||
"@joplin/lib": "~2.8",
|
||||
"@joplin/renderer": "~2.8",
|
||||
"@types/node-fetch": "1.6.9",
|
||||
"dayjs": "^1.10.7",
|
||||
"execa": "^4.1.0",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { execCommand2, githubRelease, gitPullTry, rootDir } from './tool-utils';
|
||||
import { execCommand2, gitCurrentBranch, githubRelease, gitPullTry, rootDir } from './tool-utils';
|
||||
|
||||
const appDir = `${rootDir}/packages/app-desktop`;
|
||||
|
||||
@@ -27,10 +27,12 @@ async function main() {
|
||||
console.info('Release options: ', releaseOptions);
|
||||
|
||||
const release = await githubRelease('joplin', tagName, releaseOptions);
|
||||
const currentBranch = await gitCurrentBranch();
|
||||
|
||||
console.info(`Created GitHub release: ${release.html_url}`);
|
||||
console.info('GitHub release page: https://github.com/laurent22/joplin/releases');
|
||||
console.info(`To create changelog: node packages/tools/git-changelog.js ${version}`);
|
||||
console.info(`To merge the version update: git checkout dev && git mergeff ${currentBranch} && git push && git checkout ${currentBranch}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
|
||||
@@ -335,6 +335,11 @@ export async function gitPullTry(ignoreIfNotBranch = true) {
|
||||
}
|
||||
}
|
||||
|
||||
export const gitCurrentBranch = async (): Promise<string> => {
|
||||
const output = await execCommand2('git rev-parse --abbrev-ref HEAD', { quiet: true });
|
||||
return output.trim();
|
||||
};
|
||||
|
||||
export async function githubUsername(email: string, name: string) {
|
||||
const cache = await loadGitHubUsernameCache();
|
||||
const cacheKey = `${email}:${name}`;
|
||||
|
||||
@@ -1,5 +1,22 @@
|
||||
# Joplin changelog
|
||||
|
||||
## [v2.8.2](https://github.com/laurent22/joplin/releases/tag/v2.8.2) (Pre-release) - 2022-04-14T11:35:45Z
|
||||
|
||||
- New: Add support for multiple profiles ([#6385](https://github.com/laurent22/joplin/issues/6385)) ([#591](https://github.com/laurent22/joplin/issues/591))
|
||||
- New: Allow saving a Mermaid graph as a PNG or SVG via context menu ([#6126](https://github.com/laurent22/joplin/issues/6126)) ([#6100](https://github.com/laurent22/joplin/issues/6100) by [@asrient](https://github.com/asrient))
|
||||
- New: Support for Joplin Cloud recursive linked notes (9d9420a)
|
||||
- Improved: Don’t unpin app from taskbar on update ([#6271](https://github.com/laurent22/joplin/issues/6271)) ([#4155](https://github.com/laurent22/joplin/issues/4155) by Daniel Aleksandersen)
|
||||
- Improved: Make search engine filter keywords case insensitive ([#6267](https://github.com/laurent22/joplin/issues/6267)) ([#6266](https://github.com/laurent22/joplin/issues/6266) by [@JackGruber](https://github.com/JackGruber))
|
||||
- Improved: Plugins: Add support for "categories" manifest field ([#6109](https://github.com/laurent22/joplin/issues/6109)) ([#5867](https://github.com/laurent22/joplin/issues/5867) by Mayank Bondre)
|
||||
- Improved: Plugins: Allow updating a resource via the data API (74273cd)
|
||||
- Improved: Automatically start sync after setting the sync parameters (ff066ba)
|
||||
- Improved: Improve E2EE usability when accidentally creating multiple keys ([#6399](https://github.com/laurent22/joplin/issues/6399)) ([#6338](https://github.com/laurent22/joplin/issues/6338))
|
||||
- Improved: Improved handling of ENTER and ESCAPE keys in dialogs ([#6194](https://github.com/laurent22/joplin/issues/6194))
|
||||
- Fixed: Fixed color of published note on Light theme (21706fa)
|
||||
- Fixed: Fixed creation of empty notebooks when importing directory of files ([#6274](https://github.com/laurent22/joplin/issues/6274)) ([#6197](https://github.com/laurent22/joplin/issues/6197) by [@Retrove](https://github.com/Retrove))
|
||||
- Fixed: Fixes right click menu on Markdown Editor ([#6132](https://github.com/laurent22/joplin/issues/6132) by [@bishoy-magdy](https://github.com/bishoy-magdy))
|
||||
- Fixed: Scroll jumps when typing if heavy scripts or many large elements are used ([#6383](https://github.com/laurent22/joplin/issues/6383)) ([#6074](https://github.com/laurent22/joplin/issues/6074) by Kenichi Kobayashi)
|
||||
|
||||
## [v2.7.15](https://github.com/laurent22/joplin/releases/tag/v2.7.15) - 2022-03-17T13:03:23Z
|
||||
|
||||
- Improved: Handle invalid revision patches ([#6209](https://github.com/laurent22/joplin/issues/6209))
|
||||
|
||||
57
yarn.lock
57
yarn.lock
@@ -2896,9 +2896,9 @@ __metadata:
|
||||
"@electron/remote": ^2.0.1
|
||||
"@fortawesome/fontawesome-free": ^5.13.0
|
||||
"@joeattardi/emoji-button": ^4.6.0
|
||||
"@joplin/lib": ~2.7
|
||||
"@joplin/renderer": ~2.7
|
||||
"@joplin/tools": ~2.7
|
||||
"@joplin/lib": ~2.8
|
||||
"@joplin/renderer": ~2.8
|
||||
"@joplin/tools": ~2.8
|
||||
"@testing-library/react-hooks": ^3.4.2
|
||||
"@types/jest": ^26.0.15
|
||||
"@types/node": ^14.14.6
|
||||
@@ -3089,7 +3089,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@joplin/htmlpack@^2.6.1, @joplin/htmlpack@workspace:packages/htmlpack, @joplin/htmlpack@~2.7":
|
||||
"@joplin/htmlpack@^2.6.1, @joplin/htmlpack@workspace:packages/htmlpack, @joplin/htmlpack@~2.8":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@joplin/htmlpack@workspace:packages/htmlpack"
|
||||
dependencies:
|
||||
@@ -3102,7 +3102,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@joplin/lib@^2.6.3, @joplin/lib@workspace:packages/lib, @joplin/lib@~2.7":
|
||||
"@joplin/lib@^2.6.3, @joplin/lib@workspace:packages/lib, @joplin/lib@~2.8":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@joplin/lib@workspace:packages/lib"
|
||||
dependencies:
|
||||
@@ -3111,13 +3111,14 @@ __metadata:
|
||||
"@joplin/fork-htmlparser2": ^4.1.39
|
||||
"@joplin/fork-sax": ^1.2.43
|
||||
"@joplin/fork-uslug": ^1.0.4
|
||||
"@joplin/htmlpack": ~2.7
|
||||
"@joplin/renderer": ~2.7
|
||||
"@joplin/htmlpack": ~2.8
|
||||
"@joplin/renderer": ~2.8
|
||||
"@joplin/turndown": ^4.0.61
|
||||
"@joplin/turndown-plugin-gfm": ^1.0.43
|
||||
"@types/fs-extra": ^9.0.6
|
||||
"@types/jest": ^26.0.15
|
||||
"@types/js-yaml": ^4.0.2
|
||||
"@types/nanoid": ^3.0.0
|
||||
"@types/node": ^14.14.6
|
||||
"@types/node-rsa": ^1.1.1
|
||||
"@types/react": ^17.0.20
|
||||
@@ -3256,8 +3257,8 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@joplin/plugin-repo-cli@workspace:packages/plugin-repo-cli"
|
||||
dependencies:
|
||||
"@joplin/lib": ~2.7
|
||||
"@joplin/tools": ~2.7
|
||||
"@joplin/lib": ~2.8
|
||||
"@joplin/tools": ~2.8
|
||||
"@types/fs-extra": ^9.0.6
|
||||
"@types/jest": ^26.0.15
|
||||
"@types/node": ^14.14.6
|
||||
@@ -3282,7 +3283,7 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@joplin/renderer@^2.6.3, @joplin/renderer@workspace:packages/renderer, @joplin/renderer@~2.7":
|
||||
"@joplin/renderer@^2.6.3, @joplin/renderer@workspace:packages/renderer, @joplin/renderer@~2.8":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@joplin/renderer@workspace:packages/renderer"
|
||||
dependencies:
|
||||
@@ -3353,9 +3354,9 @@ __metadata:
|
||||
dependencies:
|
||||
"@aws-sdk/client-s3": ^3.40.0
|
||||
"@fortawesome/fontawesome-free": ^5.15.1
|
||||
"@joplin/lib": ~2.7
|
||||
"@joplin/renderer": ~2.7
|
||||
"@joplin/tools": ~2.7
|
||||
"@joplin/lib": ~2.8
|
||||
"@joplin/renderer": ~2.8
|
||||
"@joplin/tools": ~2.8
|
||||
"@koa/cors": ^3.1.0
|
||||
"@rmp135/sql-ts": ^1.12.1
|
||||
"@types/fs-extra": ^8.0.0
|
||||
@@ -3433,12 +3434,12 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@joplin/tools@workspace:packages/tools, @joplin/tools@~2.7":
|
||||
"@joplin/tools@workspace:packages/tools, @joplin/tools@~2.8":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@joplin/tools@workspace:packages/tools"
|
||||
dependencies:
|
||||
"@joplin/lib": ~2.7
|
||||
"@joplin/renderer": ~2.7
|
||||
"@joplin/lib": ~2.8
|
||||
"@joplin/renderer": ~2.8
|
||||
"@rmp135/sql-ts": ^1.6.0
|
||||
"@types/fs-extra": ^9.0.6
|
||||
"@types/jest": ^26.0.15
|
||||
@@ -5576,6 +5577,15 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 1.6.9
|
||||
resolution: "@types/node-fetch@npm:1.6.9"
|
||||
@@ -18634,9 +18644,9 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "joplin@workspace:packages/app-cli"
|
||||
dependencies:
|
||||
"@joplin/lib": ~2.7
|
||||
"@joplin/renderer": ~2.7
|
||||
"@joplin/tools": ~2.7
|
||||
"@joplin/lib": ~2.8
|
||||
"@joplin/renderer": ~2.8
|
||||
"@joplin/tools": ~2.8
|
||||
"@types/fs-extra": ^9.0.6
|
||||
"@types/jest": ^26.0.15
|
||||
"@types/node": ^14.14.6
|
||||
@@ -21819,6 +21829,15 @@ __metadata:
|
||||
languageName: node
|
||||
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":
|
||||
version: 2.1.11
|
||||
resolution: "nanoid@npm:2.1.11"
|
||||
|
||||
Reference in New Issue
Block a user