diff --git a/.eslintignore b/.eslintignore
index d22c4db917..43d5398466 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -140,6 +140,9 @@ packages/app-desktop/commands/openProfileDirectory.js.map
packages/app-desktop/commands/replaceMisspelling.d.ts
packages/app-desktop/commands/replaceMisspelling.js
packages/app-desktop/commands/replaceMisspelling.js.map
+packages/app-desktop/commands/restoreNoteRevision.d.ts
+packages/app-desktop/commands/restoreNoteRevision.js
+packages/app-desktop/commands/restoreNoteRevision.js.map
packages/app-desktop/commands/startExternalEditing.d.ts
packages/app-desktop/commands/startExternalEditing.js
packages/app-desktop/commands/startExternalEditing.js.map
diff --git a/.github/scripts/run_ci.sh b/.github/scripts/run_ci.sh
new file mode 100755
index 0000000000..2bbac32830
--- /dev/null
+++ b/.github/scripts/run_ci.sh
@@ -0,0 +1,127 @@
+#!/bin/bash
+
+# =============================================================================
+# Setup environment variables
+# =============================================================================
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
+ROOT_DIR="$SCRIPT_DIR/../.."
+
+IS_PULL_REQUEST=0
+IS_DEV_BRANCH=0
+IS_LINUX=0
+IS_MACOS=0
+
+if [ "$GITHUB_EVENT_NAME" == "pull_request" ]; then
+ IS_PULL_REQUEST=1
+fi
+
+if [ "$GITHUB_REF" == "refs/heads/dev" ]; then
+ IS_DEV_BRANCH=1
+fi
+
+if [ "$RUNNER_OS" == "Linux" ]; then
+ IS_LINUX=1
+ IS_MACOS=0
+else
+ IS_LINUX=0
+ IS_MACOS=1
+fi
+
+# =============================================================================
+# Print environment
+# =============================================================================
+
+echo "GITHUB_WORKFLOW=$GITHUB_WORKFLOW"
+echo "GITHUB_EVENT_NAME=$GITHUB_EVENT_NAME"
+echo "GITHUB_REF=$GITHUB_REF"
+echo "RUNNER_OS=$RUNNER_OS"
+echo "GIT_TAG_NAME=$GIT_TAG_NAME"
+
+echo "IS_PULL_REQUEST=$IS_PULL_REQUEST"
+echo "IS_DEV_BRANCH=$IS_DEV_BRANCH"
+echo "IS_LINUX=$IS_LINUX"
+echo "IS_MACOS=$IS_MACOS"
+
+echo "Node $( node -v )"
+echo "Npm $( npm -v )"
+
+# =============================================================================
+# Install packages
+# =============================================================================
+
+cd "$ROOT_DIR"
+npm install
+
+# =============================================================================
+# Run test units. Only do it for pull requests and dev branch because we don't
+# want it to randomly fail when trying to create a desktop release.
+# =============================================================================
+
+if [ "$IS_PULL_REQUEST" == "1" ] || [ "$IS_DEV_BRANCH" = "1" ]; then
+ npm run test-ci
+ testResult=$?
+ if [ $testResult -ne 0 ]; then
+ exit $testResult
+ fi
+fi
+
+# =============================================================================
+# Run linter for pull requests only. We also don't want this to make the desktop
+# release randomly fail.
+# =============================================================================
+
+if [ "$IS_PULL_REQUEST" != "1" ]; then
+ npm run linter-ci ./
+ testResult=$?
+ if [ $testResult -ne 0 ]; then
+ exit $testResult
+ fi
+fi
+
+# =============================================================================
+# Validate translations - this is needed as some users manually edit .po files
+# (and often make mistakes) instead of using a proper tool like poedit. Doing it
+# for Linux only is sufficient.
+# =============================================================================
+
+if [ "$IS_PULL_REQUEST" == "1" ]; then
+ if [ "$IS_LINUX" == "1" ]; then
+ node packages/tools/validate-translation.js
+ testResult=$?
+ if [ $testResult -ne 0 ]; then
+ exit $testResult
+ fi
+ fi
+fi
+
+# =============================================================================
+# Find out if we should run the build or not. Electron-builder gets stuck when
+# building PRs so we disable it in this case. The Linux build should provide
+# enough info if the app builds or not.
+# https://github.com/electron-userland/electron-builder/issues/4263
+# =============================================================================
+
+if [ "$IS_PULL_REQUEST" == "1" ]; then
+ if [ "$IS_MACOS" == "1" ]; then
+ exit 0
+ fi
+fi
+
+# =============================================================================
+# Prepare the Electron app and build it
+#
+# If the current tag is a desktop release tag (starts with "v", such as
+# "v1.4.7"), we build and publish to github
+#
+# Otherwise we only build but don't publish to GitHub. It helps finding
+# out any issue in pull requests and dev branch.
+# =============================================================================
+
+cd "$ROOT_DIR/packages/app-desktop"
+
+if [[ $GIT_TAG_NAME = v* ]]; then
+ USE_HARD_LINKS=false npm run dist
+else
+ USE_HARD_LINKS=false npm run dist -- --publish=never
+fi
diff --git a/.github/workflows/github-actions-main.yml b/.github/workflows/github-actions-main.yml
new file mode 100644
index 0000000000..ee2a0fcb46
--- /dev/null
+++ b/.github/workflows/github-actions-main.yml
@@ -0,0 +1,37 @@
+name: Joplin Continuous Integration
+on: [push]
+jobs:
+ Main:
+ runs-on: ${{ matrix.os }}
+ strategy:
+ matrix:
+ os: [macos-latest, ubuntu-latest]
+ steps:
+
+ # Silence apt-get update errors (for example when a module doesn't
+ # exist) since otherwise it will make the whole build fails, even though
+ # it might work without update. libsecret-1-dev is required for keytar -
+ # https://github.com/atom/node-keytar
+ - name: Install Linux dependencies
+ if: runner.os == 'Linux'
+ run: |
+ sudo apt-get update || true
+ sudo apt-get install -y gettext
+ sudo apt-get install -y libsecret-1-dev
+
+ - uses: actions/checkout@v2
+ - uses: olegtarasov/get-tag@v2.1
+ - uses: actions/setup-node@v2
+ with:
+ node-version: '12'
+
+ - name: Run script...
+ env:
+ APPLE_ASC_PROVIDER: ${{ secrets.APPLE_ASC_PROVIDER }}
+ APPLE_ID: ${{ secrets.APPLE_ID }}
+ APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }}
+ CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
+ CSC_LINK: ${{ secrets.CSC_LINK }}
+ GH_TOKEN: ${{ secrets.GH_TOKEN }}
+ run: |
+ "${GITHUB_WORKSPACE}/.github/scripts/run_ci.sh"
diff --git a/.gitignore b/.gitignore
index 997fc70da0..0ae1096293 100644
--- a/.gitignore
+++ b/.gitignore
@@ -126,6 +126,9 @@ packages/app-desktop/commands/openProfileDirectory.js.map
packages/app-desktop/commands/replaceMisspelling.d.ts
packages/app-desktop/commands/replaceMisspelling.js
packages/app-desktop/commands/replaceMisspelling.js.map
+packages/app-desktop/commands/restoreNoteRevision.d.ts
+packages/app-desktop/commands/restoreNoteRevision.js
+packages/app-desktop/commands/restoreNoteRevision.js.map
packages/app-desktop/commands/startExternalEditing.d.ts
packages/app-desktop/commands/startExternalEditing.js
packages/app-desktop/commands/startExternalEditing.js.map
diff --git a/.travis.yml b/.travis.yml
deleted file mode 100644
index 90aecf2e91..0000000000
--- a/.travis.yml
+++ /dev/null
@@ -1,138 +0,0 @@
-# Only build tags (Doesn't work - doesn't build anything)
-if: tag IS present OR type = pull_request OR branch = dev
-
-rvm: 2.3.3
-
-# It's important to only build production branches otherwise Electron Builder
-# might take assets from dev branches and overwrite those of production.
-# https://docs.travis-ci.com/user/customizing-the-build/#Building-Specific-Branches
-branches:
- only:
- - master
- - dev
- - /^v\d+\.\d+(\.\d+)?(-\S*)?$/
-
-matrix:
- include:
- - os: osx
- osx_image: xcode12
- language: node_js
- node_js: "12"
- cache:
- npm: false
- # Cache was disabled because when changing from node_js 10 to node_js 12
- # it was still using build files from Node 10 when building SQLite which
- # was making it fail. Might be ok to re-enable later on, although it doesn't
- # make build that much faster.
- #
- # env:
- # - ELECTRON_CACHE=$HOME/.cache/electron
- # - ELECTRON_BUILDER_CACHE=$HOME/.cache/electron-builder
-
- - os: linux
- sudo: required
- dist: trusty
- language: node_js
- node_js: "12"
- cache:
- npm: false
- # env:
- # - ELECTRON_CACHE=$HOME/.cache/electron
- # - ELECTRON_BUILDER_CACHE=$HOME/.cache/electron-builder
-
-# cache:
-# directories:
-# - node_modules
-# - $HOME/.cache/electron
-# - $HOME/.cache/electron-builder
-
-before_install:
- # HOMEBREW_NO_AUTO_UPDATE needed so that Homebrew doesn't upgrade to the next
- # version, which requires Ruby 2.3, which is not available on the Travis VM.
-
- # Silence apt-get update errors (for example when a module doesn't exist) since
- # otherwise it will make the whole build fails, even though all we need is yarn.
-
- # libsecret-1-dev is required for keytar - https://github.com/atom/node-keytar
- - |
- if [ "$TRAVIS_OS_NAME" == "osx" ]; then
- HOMEBREW_NO_AUTO_UPDATE=1 brew install yarn
- else
- curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
- echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
- sudo apt-get update || true
- sudo apt-get install -y yarn
- sudo apt-get install -y gettext
- sudo apt-get install -y libsecret-1-dev
- fi
-
-script:
- - |
- # Prints some env variables
- echo "TRAVIS_OS_NAME=$TRAVIS_OS_NAME"
- echo "TRAVIS_BRANCH=$TRAVIS_BRANCH"
- echo "TRAVIS_PULL_REQUEST=$TRAVIS_PULL_REQUEST"
- echo "TRAVIS_TAG=$TRAVIS_TAG"
-
- # Install tools
- npm install
-
- # Run test units.
- # Only do it for pull requests because Travis randomly fails to run them
- # and that would break the desktop release.
- if [ "$TRAVIS_PULL_REQUEST" != "false" ] || [ "$TRAVIS_BRANCH" = "dev" ]; then
- npm run test-ci
- testResult=$?
- if [ $testResult -ne 0 ]; then
- exit $testResult
- fi
- fi
-
- # Run linter for pull requests only - this is so that
- # bypassing eslint is allowed for urgent fixes.
- if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then
- npm run linter-ci ./
- testResult=$?
- if [ $testResult -ne 0 ]; then
- exit $testResult
- fi
- fi
-
- # Validate translations - this is needed as some users manually
- # edit .po files (and often make mistakes) instead of using a proper
- # tool like poedit. Doing it for Linux only is sufficient.
- if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then
- if [ "$TRAVIS_OS_NAME" != "osx" ]; then
- node packages/tools/validate-translation.js
- testResult=$?
- if [ $testResult -ne 0 ]; then
- exit $testResult
- fi
- fi
- fi
-
- # Find out if we should run the build or not. Electron-builder gets stuck when
- # building PRs so we disable it in this case. The Linux build should provide
- # enough info if the app builds or not.
- # https://github.com/electron-userland/electron-builder/issues/4263
- if [ "$TRAVIS_PULL_REQUEST" != "false" ]; then
- if [ "$TRAVIS_OS_NAME" == "osx" ]; then
- exit 0
- fi
- fi
-
- # Prepare the Electron app and build it
- #
- # If the current tag is a desktop release tag (starts with "v", such as
- # "v1.4.7"), we build and publish to github
- #
- # Otherwise we only build but don't publish to GitHub. It helps finding
- # out any issue in pull requests and dev branch.
-
- cd packages/app-desktop
-
- if [[ $TRAVIS_TAG = v* ]]; then
- USE_HARD_LINKS=false npm run dist
- else
- USE_HARD_LINKS=false npm run dist -- --publish=never
- fi
diff --git a/packages/app-cli/tests/html_to_md/joplin_source_1.html b/packages/app-cli/tests/html_to_md/joplin_source_1.html
index b063e72627..42ab20a731 100644
--- a/packages/app-cli/tests/html_to_md/joplin_source_1.html
+++ b/packages/app-cli/tests/html_to_md/joplin_source_1.html
@@ -1 +1 @@
-katexcode
f(x)=∫−∞∞f^(ξ)e2πiξxdξ
\ No newline at end of file
+
Hello World:katexcodef(x)=∫−∞∞f^(ξ)e2πiξxdξ
\ No newline at end of file
diff --git a/packages/app-cli/tests/html_to_md/joplin_source_1.md b/packages/app-cli/tests/html_to_md/joplin_source_1.md
index 5e53b8c9dc..e02891356c 100644
--- a/packages/app-cli/tests/html_to_md/joplin_source_1.md
+++ b/packages/app-cli/tests/html_to_md/joplin_source_1.md
@@ -1 +1 @@
-$katexcode$
\ No newline at end of file
+Hello World:$katexcode$
\ No newline at end of file
diff --git a/packages/app-cli/tests/html_to_md/joplin_source_2.html b/packages/app-cli/tests/html_to_md/joplin_source_2.html
index 92b3e00f28..9f8d1a8575 100644
--- a/packages/app-cli/tests/html_to_md/joplin_source_2.html
+++ b/packages/app-cli/tests/html_to_md/joplin_source_2.html
@@ -1 +1,8 @@
-katexcode
f(x)=∫−∞∞f^(ξ)e2πiξxdξ
\ No newline at end of file
+Hello World :
+
+
\sqrt{3x}
+
f(x)=∫−∞∞f^(ξ)e2πiξxdξ
+
\ No newline at end of file
diff --git a/packages/app-cli/tests/html_to_md/joplin_source_2.md b/packages/app-cli/tests/html_to_md/joplin_source_2.md
index 3cca001b07..c2fa4e6b08 100644
--- a/packages/app-cli/tests/html_to_md/joplin_source_2.md
+++ b/packages/app-cli/tests/html_to_md/joplin_source_2.md
@@ -1,3 +1,5 @@
+Hello World :
+
$$
-katexcode
+\sqrt{3x}
$$
\ No newline at end of file
diff --git a/packages/app-desktop/app.ts b/packages/app-desktop/app.ts
index 5c87536498..ae6aee24b1 100644
--- a/packages/app-desktop/app.ts
+++ b/packages/app-desktop/app.ts
@@ -96,6 +96,7 @@ const globalCommands = [
require('./commands/stopExternalEditing'),
require('./commands/toggleExternalEditing'),
require('./commands/toggleSafeMode'),
+ require('./commands/restoreNoteRevision'),
require('@joplin/lib/commands/historyBackward'),
require('@joplin/lib/commands/historyForward'),
require('@joplin/lib/commands/synchronize'),
diff --git a/packages/app-desktop/commands/restoreNoteRevision.ts b/packages/app-desktop/commands/restoreNoteRevision.ts
new file mode 100644
index 0000000000..45681188de
--- /dev/null
+++ b/packages/app-desktop/commands/restoreNoteRevision.ts
@@ -0,0 +1,20 @@
+import { CommandRuntime, CommandDeclaration, CommandContext } from '@joplin/lib/services/CommandService';
+import RevisionService from '@joplin/lib/services/RevisionService';
+
+export const declaration: CommandDeclaration = {
+ name: 'restoreNoteRevision',
+ label: 'Restore a note from history',
+};
+
+export const runtime = (): CommandRuntime => {
+ return {
+ execute: async (_context: CommandContext, noteId: string, reverseRevIndex: number = 0) => {
+ try {
+ const note = await RevisionService.instance().restoreNoteById(noteId, reverseRevIndex);
+ alert(RevisionService.instance().restoreSuccessMessage(note));
+ } catch (error) {
+ alert(error.message);
+ }
+ },
+ };
+};
diff --git a/packages/app-desktop/gui/NoteRevisionViewer.jsx b/packages/app-desktop/gui/NoteRevisionViewer.jsx
index 350faa125e..7aad41ed3a 100644
--- a/packages/app-desktop/gui/NoteRevisionViewer.jsx
+++ b/packages/app-desktop/gui/NoteRevisionViewer.jsx
@@ -13,7 +13,7 @@ const shared = require('@joplin/lib/components/shared/note-screen-shared.js');
const { MarkupToHtml } = require('@joplin/renderer');
const time = require('@joplin/lib/time').default;
const ReactTooltip = require('react-tooltip');
-const { urlDecode, substrWithEllipsis } = require('@joplin/lib/string-utils');
+const { urlDecode } = require('@joplin/lib/string-utils');
const bridge = require('electron').remote.require('./bridge').default;
const markupLanguageUtils = require('../utils/markupLanguageUtils').default;
@@ -75,7 +75,7 @@ class NoteRevisionViewerComponent extends React.PureComponent {
this.setState({ restoring: true });
await RevisionService.instance().importRevisionNote(this.state.note);
this.setState({ restoring: false });
- alert(_('The note "%s" has been successfully restored to the notebook "%s".', substrWithEllipsis(this.state.note.title, 0, 32), RevisionService.instance().restoreFolderTitle()));
+ alert(RevisionService.instance().restoreSuccessMessage(this.state.note));
}
backButton_click() {
diff --git a/packages/app-desktop/gui/StatusScreen/StatusScreen.tsx b/packages/app-desktop/gui/StatusScreen/StatusScreen.tsx
index a7eb5cf25b..800cad2932 100644
--- a/packages/app-desktop/gui/StatusScreen/StatusScreen.tsx
+++ b/packages/app-desktop/gui/StatusScreen/StatusScreen.tsx
@@ -147,7 +147,10 @@ function StatusScreen(props: Props) {
}
if (section.canRetryAll) {
- itemsHtml.push(renderSectionRetryAllHtml(section.title, section.retryAllHandler));
+ itemsHtml.push(renderSectionRetryAllHtml(section.title, async () => {
+ await section.retryAllHandler();
+ void resfreshScreen();
+ }));
}
return {itemsHtml}
;
diff --git a/packages/app-desktop/plugins/GotoAnything.tsx b/packages/app-desktop/plugins/GotoAnything.tsx
index c5b0cbf367..45a473d2e0 100644
--- a/packages/app-desktop/plugins/GotoAnything.tsx
+++ b/packages/app-desktop/plugins/GotoAnything.tsx
@@ -47,6 +47,12 @@ interface State {
listType: number;
showHelp: boolean;
resultsInBody: boolean;
+ commandArgs: string[];
+}
+
+interface CommandQuery {
+ name: string;
+ args: string[];
}
class GotoAnything {
@@ -87,6 +93,7 @@ class Dialog extends React.PureComponent {
listType: BaseModel.TYPE_NOTE,
showHelp: false,
resultsInBody: false,
+ commandArgs: [],
};
this.styles_ = {};
@@ -250,6 +257,15 @@ class Dialog extends React.PureComponent {
return this.markupToHtml_;
}
+ private parseCommandQuery(query: string): CommandQuery {
+ const fullQuery = query;
+ const splitted = fullQuery.split(/\s+/);
+ return {
+ name: splitted.length ? splitted[0] : '',
+ args: splitted.slice(1),
+ };
+ }
+
async updateList() {
let resultsInBody = false;
@@ -260,13 +276,16 @@ class Dialog extends React.PureComponent {
let listType = null;
let searchQuery = '';
let keywords = null;
+ let commandArgs: string[] = [];
if (this.state.query.indexOf(':') === 0) { // COMMANDS
- const query = this.state.query.substr(1);
- listType = BaseModel.TYPE_COMMAND;
- keywords = [query];
+ const commandQuery = this.parseCommandQuery(this.state.query.substr(1));
- const commandResults = CommandService.instance().searchCommands(query, true);
+ listType = BaseModel.TYPE_COMMAND;
+ keywords = [commandQuery.name];
+ commandArgs = commandQuery.args;
+
+ const commandResults = CommandService.instance().searchCommands(commandQuery.name, true);
results = commandResults.map((result: CommandSearchResult) => {
return {
@@ -367,6 +386,7 @@ class Dialog extends React.PureComponent {
keywords: keywords ? keywords : await this.keywords(searchQuery),
selectedItemId: results.length === 0 ? null : results[0].id,
resultsInBody: resultsInBody,
+ commandArgs: commandArgs,
});
}
}
@@ -379,7 +399,7 @@ class Dialog extends React.PureComponent {
});
if (item.type === BaseModel.TYPE_COMMAND) {
- void CommandService.instance().execute(item.id);
+ void CommandService.instance().execute(item.id, ...item.commandArgs);
void focusEditorIfEditorCommand(item.id, CommandService.instance());
return;
}
@@ -426,6 +446,7 @@ class Dialog extends React.PureComponent {
id: itemId,
parent_id: parentId,
type: itemType,
+ commandArgs: this.state.commandArgs,
});
}
@@ -466,7 +487,7 @@ class Dialog extends React.PureComponent {
selectedItem() {
const index = this.selectedItemIndex();
if (index < 0) return null;
- return this.state.results[index];
+ return { ...this.state.results[index], commandArgs: this.state.commandArgs };
}
input_onKeyDown(event: any) {
diff --git a/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/MainApplication.java b/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/MainApplication.java
index f4792fe3e6..3ebccf791e 100644
--- a/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/MainApplication.java
+++ b/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/MainApplication.java
@@ -16,6 +16,7 @@ import com.facebook.soloader.SoLoader;
import net.cozic.joplin.share.SharePackage;
import net.cozic.joplin.ssl.SslPackage;
+import net.cozic.joplin.textinput.TextInputPackage;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
@@ -44,6 +45,7 @@ public class MainApplication extends Application implements ReactApplication {
// Packages that cannot be autolinked yet can be added manually here, for example:
packages.add(new SharePackage());
packages.add(new SslPackage());
+ packages.add(new TextInputPackage());
return packages;
}
diff --git a/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/textinput/TextInputPackage.java b/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/textinput/TextInputPackage.java
new file mode 100644
index 0000000000..2a11c295d2
--- /dev/null
+++ b/packages/app-mobile/android/app/src/main/java/net/cozic/joplin/textinput/TextInputPackage.java
@@ -0,0 +1,63 @@
+package net.cozic.joplin.textinput;
+
+import android.text.Selection;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.facebook.react.bridge.NativeModule;
+import com.facebook.react.bridge.ReactApplicationContext;
+import com.facebook.react.bridge.ReadableArray;
+import com.facebook.react.uimanager.ViewManager;
+import com.facebook.react.views.textinput.ReactEditText;
+import com.facebook.react.views.textinput.ReactTextInputManager;
+
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * This class provides a workaround for
+ * https://github.com/facebook/react-native/issues/29911
+ *
+ * The reason the editor is scrolled seems to be due to this block in
+ * android.widget.Editor#onFocusChanged:
+ *
+ *
+ * // The DecorView does not have focus when the 'Done' ExtractEditText button is
+ * // pressed. Since it is the ViewAncestor's mView, it requests focus before
+ * // ExtractEditText clears focus, which gives focus to the ExtractEditText.
+ * // This special case ensure that we keep current selection in that case.
+ * // It would be better to know why the DecorView does not have focus at that time.
+ * if (((mTextView.isInExtractedMode()) || mSelectionMoved)
+ * && selStart >= 0 && selEnd >= 0) {
+ * Selection.setSelection((Spannable)mTextView.getText(),selStart,selEnd);
+ * }
+ *
+ * When using native Android TextView mSelectionMoved is false so this block is skipped,
+ * with RN however it's true and this is where the scrolling comes from.
+ *
+ * The below workaround resets the selection before a focus event is passed on to the native component.
+ * This way when the above condition is reached selStart == selEnd == -1
and no scrolling
+ * happens.
+ */
+public class TextInputPackage implements com.facebook.react.ReactPackage {
+ @NonNull
+ @Override
+ public List createNativeModules(@NonNull ReactApplicationContext reactContext) {
+ return Collections.emptyList();
+ }
+
+ @NonNull
+ @Override
+ public List createViewManagers(@NonNull ReactApplicationContext reactContext) {
+ return Collections.singletonList(new ReactTextInputManager() {
+ @Override
+ public void receiveCommand(ReactEditText reactEditText, String commandId, @Nullable ReadableArray args) {
+ if ("focus".equals(commandId) || "focusTextInput".equals(commandId)) {
+ Selection.removeSelection(reactEditText.getText());
+ }
+ super.receiveCommand(reactEditText, commandId, args);
+ }
+ });
+ }
+}
diff --git a/packages/lib/BaseApplication.ts b/packages/lib/BaseApplication.ts
index 4cf4fa1ca5..9e517ff750 100644
--- a/packages/lib/BaseApplication.ts
+++ b/packages/lib/BaseApplication.ts
@@ -766,9 +766,10 @@ export default class BaseApplication {
}
if (Setting.value('env') === Env.Dev) {
- // Setting.setValue('sync.10.path', 'https://api.joplincloud.com');
- Setting.setValue('sync.10.path', 'http://api-joplincloud.local:22300');
- Setting.setValue('sync.10.userContentPath', 'http://joplinusercontent.local:22300');
+ Setting.setValue('sync.10.path', 'https://api.joplincloud.com');
+ Setting.setValue('sync.10.userContentPath', 'https://joplinusercontent.com');
+ // Setting.setValue('sync.10.path', 'http://api-joplincloud.local:22300');
+ // Setting.setValue('sync.10.userContentPath', 'http://joplinusercontent.local:22300');
}
// For now always disable fuzzy search due to performance issues:
diff --git a/packages/lib/JoplinServerApi.ts b/packages/lib/JoplinServerApi.ts
index 6016183e93..7df852fb23 100644
--- a/packages/lib/JoplinServerApi.ts
+++ b/packages/lib/JoplinServerApi.ts
@@ -48,7 +48,7 @@ export default class JoplinServerApi {
this.options_ = options;
if (options.env === Env.Dev) {
- this.debugRequests_ = true;
+ // this.debugRequests_ = true;
}
}
@@ -175,13 +175,16 @@ export default class JoplinServerApi {
logger.debug('Response', Date.now() - startTime, options.responseFormat, responseText);
}
+ const shortResponseText = () => {
+ return (`${responseText}`).substr(0, 1024);
+ };
+
// Creates an error object with as much data as possible as it will appear in the log, which will make debugging easier
const newError = (message: string, code: number = 0) => {
// Gives a shorter response for error messages. Useful for cases where a full HTML page is accidentally loaded instead of
// JSON. That way the error message will still show there's a problem but without filling up the log or screen.
- const shortResponseText = (`${responseText}`).substr(0, 1024);
// return new JoplinError(`${method} ${path}: ${message} (${code}): ${shortResponseText}`, code);
- return new JoplinError(message, code, `${method} ${path}: ${message} (${code}): ${shortResponseText}`);
+ return new JoplinError(message, code, `${method} ${path}: ${message} (${code}): ${shortResponseText()}`);
};
let responseJson_: any = null;
@@ -207,7 +210,21 @@ export default class JoplinServerApi {
throw newError(`${json.error}`, json.code ? json.code : response.status);
}
- throw newError('Unknown error', response.status);
+ // "Unknown error" means it probably wasn't generated by the
+ // application but for example by the Nginx or Apache reverse
+ // proxy. So in that case we attach the response content to the
+ // error message so that it shows up in logs. It might be for
+ // example an error returned by the Nginx or Apache reverse
+ // proxy. For example:
+ //
+ //
+ // 413 Request Entity Too Large
+ //
+ // 413 Request Entity Too Large
+ //
nginx/1.18.0 (Ubuntu)
+ //
+ //
+ throw newError(`Unknown error: ${shortResponseText()}`, response.status);
}
if (options.responseFormat === 'text') return responseText;
diff --git a/packages/lib/SyncTargetJoplinCloud.ts b/packages/lib/SyncTargetJoplinCloud.ts
index fa2ca8fadc..1f8cd1a766 100644
--- a/packages/lib/SyncTargetJoplinCloud.ts
+++ b/packages/lib/SyncTargetJoplinCloud.ts
@@ -41,11 +41,11 @@ export default class SyncTargetJoplinCloud extends BaseSyncTarget {
public static async checkConfig(options: FileApiOptions) {
return SyncTargetJoplinServer.checkConfig({
...options,
- });
+ }, SyncTargetJoplinCloud.id());
}
protected async initFileApi() {
- return initFileApi(this.logger(), {
+ return initFileApi(SyncTargetJoplinCloud.id(), this.logger(), {
path: () => Setting.value('sync.10.path'),
userContentPath: () => Setting.value('sync.10.userContentPath'),
username: () => Setting.value('sync.10.username'),
diff --git a/packages/lib/SyncTargetJoplinServer.ts b/packages/lib/SyncTargetJoplinServer.ts
index 3ca25ec6e0..9677c37325 100644
--- a/packages/lib/SyncTargetJoplinServer.ts
+++ b/packages/lib/SyncTargetJoplinServer.ts
@@ -31,8 +31,8 @@ export async function newFileApi(id: number, options: FileApiOptions) {
return fileApi;
}
-export async function initFileApi(logger: Logger, options: FileApiOptions) {
- const fileApi = await newFileApi(SyncTargetJoplinServer.id(), options);
+export async function initFileApi(syncTargetId: number, logger: Logger, options: FileApiOptions) {
+ const fileApi = await newFileApi(syncTargetId, options);
fileApi.setLogger(logger);
return fileApi;
}
@@ -63,14 +63,16 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
return super.fileApi();
}
- public static async checkConfig(options: FileApiOptions) {
+ public static async checkConfig(options: FileApiOptions, syncTargetId: number = null) {
const output = {
ok: false,
errorMessage: '',
};
+ syncTargetId = syncTargetId === null ? SyncTargetJoplinServer.id() : syncTargetId;
+
try {
- const fileApi = await newFileApi(SyncTargetJoplinServer.id(), options);
+ const fileApi = await newFileApi(syncTargetId, options);
fileApi.requestRepeatCount_ = 0;
await fileApi.put('testing.txt', 'testing');
@@ -87,7 +89,7 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
}
protected async initFileApi() {
- return initFileApi(this.logger(), {
+ return initFileApi(SyncTargetJoplinServer.id(), this.logger(), {
path: () => Setting.value('sync.9.path'),
userContentPath: () => Setting.value('sync.9.userContentPath'),
username: () => Setting.value('sync.9.username'),
diff --git a/packages/lib/models/Note.test.ts b/packages/lib/models/Note.test.ts
index 886e1657a9..fe6867014d 100644
--- a/packages/lib/models/Note.test.ts
+++ b/packages/lib/models/Note.test.ts
@@ -284,8 +284,20 @@ describe('models_Note', function() {
expect(externalToInternal).toBe(input);
}
- const result = await Note.replaceResourceExternalToInternalLinks(`[](joplin://${note1.id})`);
- expect(result).toBe(`[](:/${note1.id})`);
+ {
+ const result = await Note.replaceResourceExternalToInternalLinks(`[](joplin://${note1.id})`);
+ expect(result).toBe(`[](:/${note1.id})`);
+ }
+
+ {
+ // This is a regular file path that contains the resourceDirName
+ // inside but it shouldn't be changed.
+ //
+ // https://github.com/laurent22/joplin/issues/5034
+ const noChangeInput = `[docs](file:///c:/foo/${resourceDirName}/docs)`;
+ const result = await Note.replaceResourceExternalToInternalLinks(noChangeInput, { useAbsolutePaths: false });
+ expect(result).toBe(noChangeInput);
+ }
}));
it('should perform natural sorting', (async () => {
diff --git a/packages/lib/models/Note.ts b/packages/lib/models/Note.ts
index 217ec903f0..0790fdea91 100644
--- a/packages/lib/models/Note.ts
+++ b/packages/lib/models/Note.ts
@@ -208,9 +208,9 @@ export default class Note extends BaseItem {
for (const basePath of pathsToTry) {
const reStrings = [
// Handles file://path/to/abcdefg.jpg?t=12345678
- `${pregQuote(`${basePath}/`)}[a-zA-Z0-9.]+\\?t=[0-9]+`,
+ `${pregQuote(`${basePath}/`)}[a-zA-Z0-9]{32}\\.[a-zA-Z0-9]+\\?t=[0-9]+`,
// Handles file://path/to/abcdefg.jpg
- `${pregQuote(`${basePath}/`)}[a-zA-Z0-9.]+`,
+ `${pregQuote(`${basePath}/`)}[a-zA-Z0-9]{32}\\.[a-zA-Z0-9]+`,
];
for (const reString of reStrings) {
const re = new RegExp(reString, 'gi');
diff --git a/packages/lib/services/ReportService.ts b/packages/lib/services/ReportService.ts
index adec57ec1e..8fb073efd8 100644
--- a/packages/lib/services/ReportService.ts
+++ b/packages/lib/services/ReportService.ts
@@ -140,6 +140,28 @@ export default class ReportService {
return output;
}
+ private addRetryAllHandler(section: ReportSection): ReportSection {
+ const retryHandlers: Function[] = [];
+
+ for (let i = 0; i < section.body.length; i++) {
+ const item: RerportItemOrString = section.body[i];
+ if (typeof item !== 'string' && item.canRetry) {
+ retryHandlers.push(item.retryHandler);
+ }
+ }
+
+ if (retryHandlers.length) {
+ section.canRetryAll = true;
+ section.retryAllHandler = async () => {
+ for (const retryHandler of retryHandlers) {
+ await retryHandler();
+ }
+ };
+ }
+
+ return section;
+ }
+
async status(syncTarget: number): Promise {
const r = await this.syncStatus(syncTarget);
const sections: ReportSection[] = [];
@@ -175,6 +197,8 @@ export default class ReportService {
section.body.push({ type: ReportItemType.CloseList });
+ section = this.addRetryAllHandler(section);
+
sections.push(section);
}
@@ -200,23 +224,7 @@ export default class ReportService {
});
}
- const retryHandlers: Function[] = [];
-
- for (let i = 0; i < section.body.length; i++) {
- const item: RerportItemOrString = section.body[i];
- if (typeof item !== 'string' && item.canRetry) {
- retryHandlers.push(item.retryHandler);
- }
- }
-
- if (retryHandlers.length > 1) {
- section.canRetryAll = true;
- section.retryAllHandler = async () => {
- for (const retryHandler of retryHandlers) {
- await retryHandler();
- }
- };
- }
+ section = this.addRetryAllHandler(section);
sections.push(section);
}
diff --git a/packages/lib/services/RevisionService.ts b/packages/lib/services/RevisionService.ts
index 97181632db..b13dd58d47 100644
--- a/packages/lib/services/RevisionService.ts
+++ b/packages/lib/services/RevisionService.ts
@@ -9,6 +9,7 @@ import shim from '../shim';
import BaseService from './BaseService';
import { _ } from '../locale';
import { ItemChangeEntity, NoteEntity, RevisionEntity } from './database/types';
+const { substrWithEllipsis } = require('../string-utils');
const { sprintf } = require('sprintf-js');
const { wrapError } = require('../errorUtils');
@@ -230,7 +231,23 @@ export default class RevisionService extends BaseService {
return folder;
}
- async importRevisionNote(note: NoteEntity) {
+ // reverseRevIndex = 0 means restoring the latest version. reverseRevIndex =
+ // 1 means the version before that, etc.
+ public async restoreNoteById(noteId: string, reverseRevIndex: number): Promise {
+ const revisions = await Revision.allByType(BaseModel.TYPE_NOTE, noteId);
+ if (!revisions.length) throw new Error(`No revision for note "${noteId}"`);
+
+ const revIndex = revisions.length - 1 - reverseRevIndex;
+
+ const note = await this.revisionNote(revisions, revIndex);
+ return this.importRevisionNote(note);
+ }
+
+ public restoreSuccessMessage(note: NoteEntity): string {
+ return _('The note "%s" has been successfully restored to the notebook "%s".', substrWithEllipsis(note.title, 0, 32), this.restoreFolderTitle());
+ }
+
+ async importRevisionNote(note: NoteEntity): Promise {
const toImport = Object.assign({}, note);
delete toImport.id;
delete toImport.updated_time;
@@ -242,7 +259,7 @@ export default class RevisionService extends BaseService {
toImport.parent_id = folder.id;
- await Note.save(toImport);
+ return Note.save(toImport);
}
async maintenance() {
diff --git a/packages/plugin-repo-cli/commands/updateRelease.ts b/packages/plugin-repo-cli/commands/updateRelease.ts
index 40e118f5eb..f3c5e70ea2 100644
--- a/packages/plugin-repo-cli/commands/updateRelease.ts
+++ b/packages/plugin-repo-cli/commands/updateRelease.ts
@@ -141,7 +141,7 @@ export default async function(args: Args) {
await doSaveStats();
} else {
const fileInfo = await stat(statFilePath);
- if (Date.now() - fileInfo.mtime.getTime() >= 24 * 60 * 60 * 1000) {
+ if (Date.now() - fileInfo.mtime.getTime() >= 7 * 24 * 60 * 60 * 1000) {
await doSaveStats();
}
}
diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts
index bc98fa83fc..aa1cc1d718 100644
--- a/packages/server/src/config.ts
+++ b/packages/server/src/config.ts
@@ -38,6 +38,8 @@ export interface EnvVariables {
SIGNUP_ENABLED?: string;
TERMS_ENABLED?: string;
+
+ ERROR_STACK_TRACES?: string;
}
let runningInDocker_: boolean = false;
@@ -145,6 +147,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
stripe: stripeConfigFromEnv(env),
port: appPort,
baseUrl,
+ showErrorStackTraces: (env.ERROR_STACK_TRACES === undefined && envType === Env.Dev) || env.ERROR_STACK_TRACES === '1',
apiBaseUrl: env.API_BASE_URL ? env.API_BASE_URL : baseUrl,
userContentBaseUrl: env.USER_CONTENT_BASE_URL ? env.USER_CONTENT_BASE_URL : baseUrl,
signupEnabled: env.SIGNUP_ENABLED === '1',
diff --git a/packages/server/src/middleware/routeHandler.ts b/packages/server/src/middleware/routeHandler.ts
index 98d974b37b..33beb26381 100644
--- a/packages/server/src/middleware/routeHandler.ts
+++ b/packages/server/src/middleware/routeHandler.ts
@@ -1,6 +1,7 @@
import { routeResponseFormat, Response, RouteResponseFormat, execRequest } from '../utils/routeUtils';
import { AppContext, Env } from '../utils/types';
import { isView, View } from '../services/MustacheService';
+import config from '../config';
export default async function(ctx: AppContext) {
const requestStartTime = Date.now();
@@ -11,8 +12,9 @@ export default async function(ctx: AppContext) {
if (responseObject instanceof Response) {
ctx.response = responseObject.response;
} else if (isView(responseObject)) {
- ctx.response.status = 200;
- ctx.response.body = await ctx.services.mustache.renderView(responseObject, {
+ const view = responseObject as View;
+ ctx.response.status = view?.content?.error ? view?.content?.error?.httpCode || 500 : 200;
+ ctx.response.body = await ctx.services.mustache.renderView(view, {
notifications: ctx.notifications || [],
hasNotifications: !!ctx.notifications && !!ctx.notifications.length,
owner: ctx.owner,
@@ -44,7 +46,7 @@ export default async function(ctx: AppContext) {
path: 'index/error',
content: {
error,
- stack: ctx.env === Env.Dev ? error.stack : '',
+ stack: config().showErrorStackTraces ? error.stack : '',
owner: ctx.owner,
},
};
diff --git a/packages/server/src/routes/index/login.ts b/packages/server/src/routes/index/login.ts
index 5379a71d10..f87eace58e 100644
--- a/packages/server/src/routes/index/login.ts
+++ b/packages/server/src/routes/index/login.ts
@@ -13,7 +13,6 @@ function makeView(error: any = null): View {
error,
signupUrl: config().signupEnabled ? makeUrl(UrlType.Signup) : '',
};
- view.navbar = false;
return view;
}
diff --git a/packages/server/src/routes/index/signup.test.ts b/packages/server/src/routes/index/signup.test.ts
index b1b4c33e20..9327efe815 100644
--- a/packages/server/src/routes/index/signup.test.ts
+++ b/packages/server/src/routes/index/signup.test.ts
@@ -1,8 +1,10 @@
+import config from '../../config';
import { NotificationKey } from '../../models/NotificationModel';
import { AccountType } from '../../models/UserModel';
import { MB } from '../../utils/bytes';
import { execRequestC } from '../../utils/testing/apiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../../utils/testing/testUtils';
+import { FormUser } from './signup';
describe('index_signup', function() {
@@ -19,12 +21,22 @@ describe('index_signup', function() {
});
test('should create a new account', async function() {
- const context = await execRequestC('', 'POST', 'signup', {
+ const formUser: FormUser = {
full_name: 'Toto',
email: 'toto@example.com',
password: 'testing',
password2: 'testing',
- });
+ };
+
+ // First confirm that it doesn't work if sign up is disabled
+ {
+ config().signupEnabled = false;
+ await execRequestC('', 'POST', 'signup', formUser);
+ expect(await models().user().loadByEmail('toto@example.com')).toBeFalsy();
+ }
+
+ config().signupEnabled = true;
+ const context = await execRequestC('', 'POST', 'signup', formUser);
// Check that the user has been created
const user = await models().user().loadByEmail('toto@example.com');
diff --git a/packages/server/src/routes/index/signup.ts b/packages/server/src/routes/index/signup.ts
index 79260c4ddb..e53c6ddc3a 100644
--- a/packages/server/src/routes/index/signup.ts
+++ b/packages/server/src/routes/index/signup.ts
@@ -18,11 +18,10 @@ function makeView(error: Error = null): View {
postUrl: makeUrl(UrlType.Signup),
loginUrl: makeUrl(UrlType.Login),
};
- view.navbar = false;
return view;
}
-interface FormUser {
+export interface FormUser {
full_name: string;
email: string;
password: string;
diff --git a/packages/server/src/routes/index/users.test.ts b/packages/server/src/routes/index/users.test.ts
index 3c71640b78..4906029b7a 100644
--- a/packages/server/src/routes/index/users.test.ts
+++ b/packages/server/src/routes/index/users.test.ts
@@ -5,7 +5,7 @@ import { ErrorForbidden } from '../../utils/errors';
import { execRequest, execRequestC } from '../../utils/testing/apiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, parseHtml, checkContextError, expectHttpError } from '../../utils/testing/testUtils';
-export async function postUser(sessionId: string, email: string, password: string): Promise {
+export async function postUser(sessionId: string, email: string, password: string, props: any = null): Promise {
const context = await koaAppContext({
sessionId: sessionId,
request: {
@@ -16,6 +16,7 @@ export async function postUser(sessionId: string, email: string, password: strin
password: password,
password2: password,
post_button: true,
+ ...props,
},
},
});
@@ -74,13 +75,16 @@ describe('index_users', function() {
test('should create a new user', async function() {
const { session } = await createUserAndSession(1, true);
- await postUser(session.id, 'test@example.com', '123456');
+ await postUser(session.id, 'test@example.com', '123456', {
+ max_item_size: '',
+ });
const newUser = await models().user().loadByEmail('test@example.com');
expect(!!newUser).toBe(true);
expect(!!newUser.id).toBe(true);
expect(!!newUser.is_admin).toBe(false);
expect(!!newUser.email).toBe(true);
+ expect(newUser.max_item_size).toBe(0);
const userModel = models().user();
const userFromModel: User = await userModel.load(newUser.id);
@@ -108,7 +112,11 @@ describe('index_users', function() {
const beforeUserCount = (await userModel.all()).length;
expect(beforeUserCount).toBe(2);
- await postUser(session.id, 'test@example.com', '123456');
+ try {
+ await postUser(session.id, 'test@example.com', '123456');
+ } catch {
+ // Ignore
+ }
const afterUserCount = (await userModel.all()).length;
expect(beforeUserCount).toBe(afterUserCount);
diff --git a/packages/server/src/routes/index/users.ts b/packages/server/src/routes/index/users.ts
index 687d1f5ceb..d7ffd6d34b 100644
--- a/packages/server/src/routes/index/users.ts
+++ b/packages/server/src/routes/index/users.ts
@@ -34,7 +34,7 @@ function makeUser(isNew: boolean, fields: any): User {
if ('email' in fields) user.email = fields.email;
if ('full_name' in fields) user.full_name = fields.full_name;
if ('is_admin' in fields) user.is_admin = fields.is_admin;
- if ('max_item_size' in fields) user.max_item_size = fields.max_item_size;
+ if ('max_item_size' in fields) user.max_item_size = fields.max_item_size || 0;
user.can_share = fields.can_share ? 1 : 0;
const password = checkPassword(fields, false);
diff --git a/packages/server/src/services/MustacheService.ts b/packages/server/src/services/MustacheService.ts
index 6ba1a3796e..c8fd2db677 100644
--- a/packages/server/src/services/MustacheService.ts
+++ b/packages/server/src/services/MustacheService.ts
@@ -32,6 +32,7 @@ interface GlobalParams {
appName?: string;
termsUrl?: string;
privacyUrl?: string;
+ showErrorStackTraces?: boolean;
}
export function isView(o: any): boolean {
@@ -85,6 +86,7 @@ export default class MustacheService {
appName: config().appName,
termsUrl: config().termsEnabled ? makeUrl(UrlType.Terms) : '',
privacyUrl: config().termsEnabled ? makeUrl(UrlType.Privacy) : '',
+ showErrorStackTraces: config().showErrorStackTraces,
};
}
diff --git a/packages/server/src/utils/testing/testUtils.ts b/packages/server/src/utils/testing/testUtils.ts
index 390b9c4471..0e374013c3 100644
--- a/packages/server/src/utils/testing/testUtils.ts
+++ b/packages/server/src/utils/testing/testUtils.ts
@@ -194,6 +194,7 @@ export async function koaAppContext(options: AppContextTestOptions = null): Prom
appContext.req = req;
appContext.query = req.query;
appContext.method = req.method;
+ appContext.redirect = () => {};
if (options.sessionId) {
appContext.cookies.set('sessionId', options.sessionId);
diff --git a/packages/server/src/utils/types.ts b/packages/server/src/utils/types.ts
index 1967ff9a02..3e33b1e3e3 100644
--- a/packages/server/src/utils/types.ts
+++ b/packages/server/src/utils/types.ts
@@ -80,6 +80,7 @@ export interface Config {
userContentBaseUrl: string;
signupEnabled: boolean;
termsEnabled: boolean;
+ showErrorStackTraces: boolean;
database: DatabaseConfig;
mailer: MailerConfig;
stripe: StripeConfig;
diff --git a/packages/server/src/views/index/login.mustache b/packages/server/src/views/index/login.mustache
index a5a0ec2727..8092bfab0d 100644
--- a/packages/server/src/views/index/login.mustache
+++ b/packages/server/src/views/index/login.mustache
@@ -1,4 +1,7 @@
+ Login to {{global.appName}}
+ Please input your details to login to {{global.appName}}
+