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ξf(x) = \int_{-\infty}^\infty \hat f(\xi)\,e^{2 \pi i \xi x} \,d\xi
\ No newline at end of file +

Hello World:katexcodef(x)=f^(ξ)e2πiξxdξf(x) = \int_{-\infty}^\infty \hat f(\xi)\,e^{2 \pi i \xi x} \,d\xi

\ 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ξf(x) = \int_{-\infty}^\infty \hat f(\xi)\,e^{2 \pi i \xi x} \,d\xi
\ No newline at end of file +

Hello World :

+
+
\sqrt{3x}
+ f(x)=f^(ξ)e2πiξxdξf(x) = \int_{-\infty}^\infty \hat f(\xi)\,e^{2 \pi i \xi x} \,d\xi +
\ 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 @@