Merge branch 'dev' into release-2.0

test_gh_pr
Laurent Cozic 2021-06-10 14:03:50 +02:00
commit 4098c01e7c
40 changed files with 480 additions and 222 deletions

View File

@ -140,6 +140,9 @@ packages/app-desktop/commands/openProfileDirectory.js.map
packages/app-desktop/commands/replaceMisspelling.d.ts packages/app-desktop/commands/replaceMisspelling.d.ts
packages/app-desktop/commands/replaceMisspelling.js packages/app-desktop/commands/replaceMisspelling.js
packages/app-desktop/commands/replaceMisspelling.js.map 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.d.ts
packages/app-desktop/commands/startExternalEditing.js packages/app-desktop/commands/startExternalEditing.js
packages/app-desktop/commands/startExternalEditing.js.map packages/app-desktop/commands/startExternalEditing.js.map

127
.github/scripts/run_ci.sh vendored Executable file
View File

@ -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

View File

@ -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"

3
.gitignore vendored
View File

@ -126,6 +126,9 @@ packages/app-desktop/commands/openProfileDirectory.js.map
packages/app-desktop/commands/replaceMisspelling.d.ts packages/app-desktop/commands/replaceMisspelling.d.ts
packages/app-desktop/commands/replaceMisspelling.js packages/app-desktop/commands/replaceMisspelling.js
packages/app-desktop/commands/replaceMisspelling.js.map 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.d.ts
packages/app-desktop/commands/startExternalEditing.js packages/app-desktop/commands/startExternalEditing.js
packages/app-desktop/commands/startExternalEditing.js.map packages/app-desktop/commands/startExternalEditing.js.map

View File

@ -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

File diff suppressed because one or more lines are too long

View File

@ -1 +1 @@
$katexcode$ Hello World:$katexcode$

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,5 @@
Hello World :
$$ $$
katexcode \sqrt{3x}
$$ $$

View File

@ -96,6 +96,7 @@ const globalCommands = [
require('./commands/stopExternalEditing'), require('./commands/stopExternalEditing'),
require('./commands/toggleExternalEditing'), require('./commands/toggleExternalEditing'),
require('./commands/toggleSafeMode'), require('./commands/toggleSafeMode'),
require('./commands/restoreNoteRevision'),
require('@joplin/lib/commands/historyBackward'), require('@joplin/lib/commands/historyBackward'),
require('@joplin/lib/commands/historyForward'), require('@joplin/lib/commands/historyForward'),
require('@joplin/lib/commands/synchronize'), require('@joplin/lib/commands/synchronize'),

View File

@ -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);
}
},
};
};

View File

@ -13,7 +13,7 @@ const shared = require('@joplin/lib/components/shared/note-screen-shared.js');
const { MarkupToHtml } = require('@joplin/renderer'); const { MarkupToHtml } = require('@joplin/renderer');
const time = require('@joplin/lib/time').default; const time = require('@joplin/lib/time').default;
const ReactTooltip = require('react-tooltip'); 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 bridge = require('electron').remote.require('./bridge').default;
const markupLanguageUtils = require('../utils/markupLanguageUtils').default; const markupLanguageUtils = require('../utils/markupLanguageUtils').default;
@ -75,7 +75,7 @@ class NoteRevisionViewerComponent extends React.PureComponent {
this.setState({ restoring: true }); this.setState({ restoring: true });
await RevisionService.instance().importRevisionNote(this.state.note); await RevisionService.instance().importRevisionNote(this.state.note);
this.setState({ restoring: false }); 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() { backButton_click() {

View File

@ -147,7 +147,10 @@ function StatusScreen(props: Props) {
} }
if (section.canRetryAll) { if (section.canRetryAll) {
itemsHtml.push(renderSectionRetryAllHtml(section.title, section.retryAllHandler)); itemsHtml.push(renderSectionRetryAllHtml(section.title, async () => {
await section.retryAllHandler();
void resfreshScreen();
}));
} }
return <div key={key}>{itemsHtml}</div>; return <div key={key}>{itemsHtml}</div>;

View File

@ -47,6 +47,12 @@ interface State {
listType: number; listType: number;
showHelp: boolean; showHelp: boolean;
resultsInBody: boolean; resultsInBody: boolean;
commandArgs: string[];
}
interface CommandQuery {
name: string;
args: string[];
} }
class GotoAnything { class GotoAnything {
@ -87,6 +93,7 @@ class Dialog extends React.PureComponent<Props, State> {
listType: BaseModel.TYPE_NOTE, listType: BaseModel.TYPE_NOTE,
showHelp: false, showHelp: false,
resultsInBody: false, resultsInBody: false,
commandArgs: [],
}; };
this.styles_ = {}; this.styles_ = {};
@ -250,6 +257,15 @@ class Dialog extends React.PureComponent<Props, State> {
return this.markupToHtml_; 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() { async updateList() {
let resultsInBody = false; let resultsInBody = false;
@ -260,13 +276,16 @@ class Dialog extends React.PureComponent<Props, State> {
let listType = null; let listType = null;
let searchQuery = ''; let searchQuery = '';
let keywords = null; let keywords = null;
let commandArgs: string[] = [];
if (this.state.query.indexOf(':') === 0) { // COMMANDS if (this.state.query.indexOf(':') === 0) { // COMMANDS
const query = this.state.query.substr(1); const commandQuery = this.parseCommandQuery(this.state.query.substr(1));
listType = BaseModel.TYPE_COMMAND;
keywords = [query];
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) => { results = commandResults.map((result: CommandSearchResult) => {
return { return {
@ -367,6 +386,7 @@ class Dialog extends React.PureComponent<Props, State> {
keywords: keywords ? keywords : await this.keywords(searchQuery), keywords: keywords ? keywords : await this.keywords(searchQuery),
selectedItemId: results.length === 0 ? null : results[0].id, selectedItemId: results.length === 0 ? null : results[0].id,
resultsInBody: resultsInBody, resultsInBody: resultsInBody,
commandArgs: commandArgs,
}); });
} }
} }
@ -379,7 +399,7 @@ class Dialog extends React.PureComponent<Props, State> {
}); });
if (item.type === BaseModel.TYPE_COMMAND) { 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()); void focusEditorIfEditorCommand(item.id, CommandService.instance());
return; return;
} }
@ -426,6 +446,7 @@ class Dialog extends React.PureComponent<Props, State> {
id: itemId, id: itemId,
parent_id: parentId, parent_id: parentId,
type: itemType, type: itemType,
commandArgs: this.state.commandArgs,
}); });
} }
@ -466,7 +487,7 @@ class Dialog extends React.PureComponent<Props, State> {
selectedItem() { selectedItem() {
const index = this.selectedItemIndex(); const index = this.selectedItemIndex();
if (index < 0) return null; if (index < 0) return null;
return this.state.results[index]; return { ...this.state.results[index], commandArgs: this.state.commandArgs };
} }
input_onKeyDown(event: any) { input_onKeyDown(event: any) {

View File

@ -16,6 +16,7 @@ import com.facebook.soloader.SoLoader;
import net.cozic.joplin.share.SharePackage; import net.cozic.joplin.share.SharePackage;
import net.cozic.joplin.ssl.SslPackage; import net.cozic.joplin.ssl.SslPackage;
import net.cozic.joplin.textinput.TextInputPackage;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException; 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 that cannot be autolinked yet can be added manually here, for example:
packages.add(new SharePackage()); packages.add(new SharePackage());
packages.add(new SslPackage()); packages.add(new SslPackage());
packages.add(new TextInputPackage());
return packages; return packages;
} }

View File

@ -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 <a href="https://github.com/facebook/react-native/issues/29911">
* https://github.com/facebook/react-native/issues/29911</a>
*
* The reason the editor is scrolled seems to be due to this block in
* <pre>android.widget.Editor#onFocusChanged:</pre>
*
* <pre>
* // 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);
* }
* </pre>
* 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 <pre>selStart == selEnd == -1</pre> and no scrolling
* happens.
*/
public class TextInputPackage implements com.facebook.react.ReactPackage {
@NonNull
@Override
public List<NativeModule> createNativeModules(@NonNull ReactApplicationContext reactContext) {
return Collections.emptyList();
}
@NonNull
@Override
public List<ViewManager> 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);
}
});
}
}

View File

@ -766,9 +766,10 @@ export default class BaseApplication {
} }
if (Setting.value('env') === Env.Dev) { if (Setting.value('env') === Env.Dev) {
// Setting.setValue('sync.10.path', 'https://api.joplincloud.com'); Setting.setValue('sync.10.path', 'https://api.joplincloud.com');
Setting.setValue('sync.10.path', 'http://api-joplincloud.local:22300'); Setting.setValue('sync.10.userContentPath', 'https://joplinusercontent.com');
Setting.setValue('sync.10.userContentPath', 'http://joplinusercontent.local:22300'); // 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: // For now always disable fuzzy search due to performance issues:

View File

@ -48,7 +48,7 @@ export default class JoplinServerApi {
this.options_ = options; this.options_ = options;
if (options.env === Env.Dev) { 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); 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 // 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) => { 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 // 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. // 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(`${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; let responseJson_: any = null;
@ -207,7 +210,21 @@ export default class JoplinServerApi {
throw newError(`${json.error}`, json.code ? json.code : response.status); 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:
//
// <html>
// <head><title>413 Request Entity Too Large</title></head>
// <body>
// <center><h1>413 Request Entity Too Large</h1></center>
// <hr><center>nginx/1.18.0 (Ubuntu)</center>
// </body>
// </html>
throw newError(`Unknown error: ${shortResponseText()}`, response.status);
} }
if (options.responseFormat === 'text') return responseText; if (options.responseFormat === 'text') return responseText;

View File

@ -41,11 +41,11 @@ export default class SyncTargetJoplinCloud extends BaseSyncTarget {
public static async checkConfig(options: FileApiOptions) { public static async checkConfig(options: FileApiOptions) {
return SyncTargetJoplinServer.checkConfig({ return SyncTargetJoplinServer.checkConfig({
...options, ...options,
}); }, SyncTargetJoplinCloud.id());
} }
protected async initFileApi() { protected async initFileApi() {
return initFileApi(this.logger(), { return initFileApi(SyncTargetJoplinCloud.id(), this.logger(), {
path: () => Setting.value('sync.10.path'), path: () => Setting.value('sync.10.path'),
userContentPath: () => Setting.value('sync.10.userContentPath'), userContentPath: () => Setting.value('sync.10.userContentPath'),
username: () => Setting.value('sync.10.username'), username: () => Setting.value('sync.10.username'),

View File

@ -31,8 +31,8 @@ export async function newFileApi(id: number, options: FileApiOptions) {
return fileApi; return fileApi;
} }
export async function initFileApi(logger: Logger, options: FileApiOptions) { export async function initFileApi(syncTargetId: number, logger: Logger, options: FileApiOptions) {
const fileApi = await newFileApi(SyncTargetJoplinServer.id(), options); const fileApi = await newFileApi(syncTargetId, options);
fileApi.setLogger(logger); fileApi.setLogger(logger);
return fileApi; return fileApi;
} }
@ -63,14 +63,16 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
return super.fileApi(); return super.fileApi();
} }
public static async checkConfig(options: FileApiOptions) { public static async checkConfig(options: FileApiOptions, syncTargetId: number = null) {
const output = { const output = {
ok: false, ok: false,
errorMessage: '', errorMessage: '',
}; };
syncTargetId = syncTargetId === null ? SyncTargetJoplinServer.id() : syncTargetId;
try { try {
const fileApi = await newFileApi(SyncTargetJoplinServer.id(), options); const fileApi = await newFileApi(syncTargetId, options);
fileApi.requestRepeatCount_ = 0; fileApi.requestRepeatCount_ = 0;
await fileApi.put('testing.txt', 'testing'); await fileApi.put('testing.txt', 'testing');
@ -87,7 +89,7 @@ export default class SyncTargetJoplinServer extends BaseSyncTarget {
} }
protected async initFileApi() { protected async initFileApi() {
return initFileApi(this.logger(), { return initFileApi(SyncTargetJoplinServer.id(), this.logger(), {
path: () => Setting.value('sync.9.path'), path: () => Setting.value('sync.9.path'),
userContentPath: () => Setting.value('sync.9.userContentPath'), userContentPath: () => Setting.value('sync.9.userContentPath'),
username: () => Setting.value('sync.9.username'), username: () => Setting.value('sync.9.username'),

View File

@ -284,8 +284,20 @@ describe('models_Note', function() {
expect(externalToInternal).toBe(input); 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 () => { it('should perform natural sorting', (async () => {

View File

@ -208,9 +208,9 @@ export default class Note extends BaseItem {
for (const basePath of pathsToTry) { for (const basePath of pathsToTry) {
const reStrings = [ const reStrings = [
// Handles file://path/to/abcdefg.jpg?t=12345678 // 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 // 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) { for (const reString of reStrings) {
const re = new RegExp(reString, 'gi'); const re = new RegExp(reString, 'gi');

View File

@ -140,6 +140,28 @@ export default class ReportService {
return output; 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<ReportSection[]> { async status(syncTarget: number): Promise<ReportSection[]> {
const r = await this.syncStatus(syncTarget); const r = await this.syncStatus(syncTarget);
const sections: ReportSection[] = []; const sections: ReportSection[] = [];
@ -175,6 +197,8 @@ export default class ReportService {
section.body.push({ type: ReportItemType.CloseList }); section.body.push({ type: ReportItemType.CloseList });
section = this.addRetryAllHandler(section);
sections.push(section); sections.push(section);
} }
@ -200,23 +224,7 @@ export default class ReportService {
}); });
} }
const retryHandlers: Function[] = []; section = this.addRetryAllHandler(section);
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();
}
};
}
sections.push(section); sections.push(section);
} }

View File

@ -9,6 +9,7 @@ import shim from '../shim';
import BaseService from './BaseService'; import BaseService from './BaseService';
import { _ } from '../locale'; import { _ } from '../locale';
import { ItemChangeEntity, NoteEntity, RevisionEntity } from './database/types'; import { ItemChangeEntity, NoteEntity, RevisionEntity } from './database/types';
const { substrWithEllipsis } = require('../string-utils');
const { sprintf } = require('sprintf-js'); const { sprintf } = require('sprintf-js');
const { wrapError } = require('../errorUtils'); const { wrapError } = require('../errorUtils');
@ -230,7 +231,23 @@ export default class RevisionService extends BaseService {
return folder; 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<NoteEntity> {
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<NoteEntity> {
const toImport = Object.assign({}, note); const toImport = Object.assign({}, note);
delete toImport.id; delete toImport.id;
delete toImport.updated_time; delete toImport.updated_time;
@ -242,7 +259,7 @@ export default class RevisionService extends BaseService {
toImport.parent_id = folder.id; toImport.parent_id = folder.id;
await Note.save(toImport); return Note.save(toImport);
} }
async maintenance() { async maintenance() {

View File

@ -141,7 +141,7 @@ export default async function(args: Args) {
await doSaveStats(); await doSaveStats();
} else { } else {
const fileInfo = await stat(statFilePath); 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(); await doSaveStats();
} }
} }

View File

@ -38,6 +38,8 @@ export interface EnvVariables {
SIGNUP_ENABLED?: string; SIGNUP_ENABLED?: string;
TERMS_ENABLED?: string; TERMS_ENABLED?: string;
ERROR_STACK_TRACES?: string;
} }
let runningInDocker_: boolean = false; let runningInDocker_: boolean = false;
@ -145,6 +147,7 @@ export async function initConfig(envType: Env, env: EnvVariables, overrides: any
stripe: stripeConfigFromEnv(env), stripe: stripeConfigFromEnv(env),
port: appPort, port: appPort,
baseUrl, 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, apiBaseUrl: env.API_BASE_URL ? env.API_BASE_URL : baseUrl,
userContentBaseUrl: env.USER_CONTENT_BASE_URL ? env.USER_CONTENT_BASE_URL : baseUrl, userContentBaseUrl: env.USER_CONTENT_BASE_URL ? env.USER_CONTENT_BASE_URL : baseUrl,
signupEnabled: env.SIGNUP_ENABLED === '1', signupEnabled: env.SIGNUP_ENABLED === '1',

View File

@ -1,6 +1,7 @@
import { routeResponseFormat, Response, RouteResponseFormat, execRequest } from '../utils/routeUtils'; import { routeResponseFormat, Response, RouteResponseFormat, execRequest } from '../utils/routeUtils';
import { AppContext, Env } from '../utils/types'; import { AppContext, Env } from '../utils/types';
import { isView, View } from '../services/MustacheService'; import { isView, View } from '../services/MustacheService';
import config from '../config';
export default async function(ctx: AppContext) { export default async function(ctx: AppContext) {
const requestStartTime = Date.now(); const requestStartTime = Date.now();
@ -11,8 +12,9 @@ export default async function(ctx: AppContext) {
if (responseObject instanceof Response) { if (responseObject instanceof Response) {
ctx.response = responseObject.response; ctx.response = responseObject.response;
} else if (isView(responseObject)) { } else if (isView(responseObject)) {
ctx.response.status = 200; const view = responseObject as View;
ctx.response.body = await ctx.services.mustache.renderView(responseObject, { ctx.response.status = view?.content?.error ? view?.content?.error?.httpCode || 500 : 200;
ctx.response.body = await ctx.services.mustache.renderView(view, {
notifications: ctx.notifications || [], notifications: ctx.notifications || [],
hasNotifications: !!ctx.notifications && !!ctx.notifications.length, hasNotifications: !!ctx.notifications && !!ctx.notifications.length,
owner: ctx.owner, owner: ctx.owner,
@ -44,7 +46,7 @@ export default async function(ctx: AppContext) {
path: 'index/error', path: 'index/error',
content: { content: {
error, error,
stack: ctx.env === Env.Dev ? error.stack : '', stack: config().showErrorStackTraces ? error.stack : '',
owner: ctx.owner, owner: ctx.owner,
}, },
}; };

View File

@ -13,7 +13,6 @@ function makeView(error: any = null): View {
error, error,
signupUrl: config().signupEnabled ? makeUrl(UrlType.Signup) : '', signupUrl: config().signupEnabled ? makeUrl(UrlType.Signup) : '',
}; };
view.navbar = false;
return view; return view;
} }

View File

@ -1,8 +1,10 @@
import config from '../../config';
import { NotificationKey } from '../../models/NotificationModel'; import { NotificationKey } from '../../models/NotificationModel';
import { AccountType } from '../../models/UserModel'; import { AccountType } from '../../models/UserModel';
import { MB } from '../../utils/bytes'; import { MB } from '../../utils/bytes';
import { execRequestC } from '../../utils/testing/apiUtils'; import { execRequestC } from '../../utils/testing/apiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../../utils/testing/testUtils'; import { beforeAllDb, afterAllTests, beforeEachDb, models } from '../../utils/testing/testUtils';
import { FormUser } from './signup';
describe('index_signup', function() { describe('index_signup', function() {
@ -19,12 +21,22 @@ describe('index_signup', function() {
}); });
test('should create a new account', async function() { test('should create a new account', async function() {
const context = await execRequestC('', 'POST', 'signup', { const formUser: FormUser = {
full_name: 'Toto', full_name: 'Toto',
email: 'toto@example.com', email: 'toto@example.com',
password: 'testing', password: 'testing',
password2: '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 // Check that the user has been created
const user = await models().user().loadByEmail('toto@example.com'); const user = await models().user().loadByEmail('toto@example.com');

View File

@ -18,11 +18,10 @@ function makeView(error: Error = null): View {
postUrl: makeUrl(UrlType.Signup), postUrl: makeUrl(UrlType.Signup),
loginUrl: makeUrl(UrlType.Login), loginUrl: makeUrl(UrlType.Login),
}; };
view.navbar = false;
return view; return view;
} }
interface FormUser { export interface FormUser {
full_name: string; full_name: string;
email: string; email: string;
password: string; password: string;

View File

@ -5,7 +5,7 @@ import { ErrorForbidden } from '../../utils/errors';
import { execRequest, execRequestC } from '../../utils/testing/apiUtils'; import { execRequest, execRequestC } from '../../utils/testing/apiUtils';
import { beforeAllDb, afterAllTests, beforeEachDb, koaAppContext, createUserAndSession, models, parseHtml, checkContextError, expectHttpError } from '../../utils/testing/testUtils'; 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<User> { export async function postUser(sessionId: string, email: string, password: string, props: any = null): Promise<User> {
const context = await koaAppContext({ const context = await koaAppContext({
sessionId: sessionId, sessionId: sessionId,
request: { request: {
@ -16,6 +16,7 @@ export async function postUser(sessionId: string, email: string, password: strin
password: password, password: password,
password2: password, password2: password,
post_button: true, post_button: true,
...props,
}, },
}, },
}); });
@ -74,13 +75,16 @@ describe('index_users', function() {
test('should create a new user', async function() { test('should create a new user', async function() {
const { session } = await createUserAndSession(1, true); 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'); const newUser = await models().user().loadByEmail('test@example.com');
expect(!!newUser).toBe(true); expect(!!newUser).toBe(true);
expect(!!newUser.id).toBe(true); expect(!!newUser.id).toBe(true);
expect(!!newUser.is_admin).toBe(false); expect(!!newUser.is_admin).toBe(false);
expect(!!newUser.email).toBe(true); expect(!!newUser.email).toBe(true);
expect(newUser.max_item_size).toBe(0);
const userModel = models().user(); const userModel = models().user();
const userFromModel: User = await userModel.load(newUser.id); const userFromModel: User = await userModel.load(newUser.id);
@ -108,7 +112,11 @@ describe('index_users', function() {
const beforeUserCount = (await userModel.all()).length; const beforeUserCount = (await userModel.all()).length;
expect(beforeUserCount).toBe(2); 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; const afterUserCount = (await userModel.all()).length;
expect(beforeUserCount).toBe(afterUserCount); expect(beforeUserCount).toBe(afterUserCount);

View File

@ -34,7 +34,7 @@ function makeUser(isNew: boolean, fields: any): User {
if ('email' in fields) user.email = fields.email; if ('email' in fields) user.email = fields.email;
if ('full_name' in fields) user.full_name = fields.full_name; if ('full_name' in fields) user.full_name = fields.full_name;
if ('is_admin' in fields) user.is_admin = fields.is_admin; 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; user.can_share = fields.can_share ? 1 : 0;
const password = checkPassword(fields, false); const password = checkPassword(fields, false);

View File

@ -32,6 +32,7 @@ interface GlobalParams {
appName?: string; appName?: string;
termsUrl?: string; termsUrl?: string;
privacyUrl?: string; privacyUrl?: string;
showErrorStackTraces?: boolean;
} }
export function isView(o: any): boolean { export function isView(o: any): boolean {
@ -85,6 +86,7 @@ export default class MustacheService {
appName: config().appName, appName: config().appName,
termsUrl: config().termsEnabled ? makeUrl(UrlType.Terms) : '', termsUrl: config().termsEnabled ? makeUrl(UrlType.Terms) : '',
privacyUrl: config().termsEnabled ? makeUrl(UrlType.Privacy) : '', privacyUrl: config().termsEnabled ? makeUrl(UrlType.Privacy) : '',
showErrorStackTraces: config().showErrorStackTraces,
}; };
} }

View File

@ -194,6 +194,7 @@ export async function koaAppContext(options: AppContextTestOptions = null): Prom
appContext.req = req; appContext.req = req;
appContext.query = req.query; appContext.query = req.query;
appContext.method = req.method; appContext.method = req.method;
appContext.redirect = () => {};
if (options.sessionId) { if (options.sessionId) {
appContext.cookies.set('sessionId', options.sessionId); appContext.cookies.set('sessionId', options.sessionId);

View File

@ -80,6 +80,7 @@ export interface Config {
userContentBaseUrl: string; userContentBaseUrl: string;
signupEnabled: boolean; signupEnabled: boolean;
termsEnabled: boolean; termsEnabled: boolean;
showErrorStackTraces: boolean;
database: DatabaseConfig; database: DatabaseConfig;
mailer: MailerConfig; mailer: MailerConfig;
stripe: StripeConfig; stripe: StripeConfig;

View File

@ -1,4 +1,7 @@
<section class="section login-box"> <section class="section login-box">
<h1 class="title">Login to {{global.appName}}</h1>
<p class="subtitle">Please input your details to login to {{global.appName}}</p>
<div class="container block"> <div class="container block">
{{> errorBanner}} {{> errorBanner}}
<form action="{{{global.baseUrl}}}/login" method="POST"> <form action="{{{global.baseUrl}}}/login" method="POST">

View File

@ -1,5 +1,8 @@
{{> errorBanner}} {{> errorBanner}}
<section class="section login-box"> <section class="section login-box">
<h1 class="title">Sign up for {{global.appName}}</h1>
<p class="subtitle">Please input your details to sign up for {{global.appName}}</p>
<div class="container block"> <div class="container block">
<form action="{{{postUrl}}}" method="POST"> <form action="{{{postUrl}}}" method="POST">
<div class="field"> <div class="field">

View File

@ -1,8 +1,10 @@
{{#error}} {{#error}}
<div class="notification is-danger"> <div class="notification is-danger">
<strong>{{message}}</strong> <strong>{{message}}</strong>
{{#stack}} {{#global.showErrorStackTraces}}
<pre>{{.}}</pre> {{#stack}}
{{/stack}} <pre>{{.}}</pre>
{{/stack}}
{{/global.showErrorStackTraces}}
</div> </div>
{{/error}} {{/error}}

View File

@ -6,25 +6,36 @@
<img class="logo" src="{{{global.baseUrl}}}/images/Logo.png"/> <img class="logo" src="{{{global.baseUrl}}}/images/Logo.png"/>
</a> </a>
</div> </div>
<div class="navbar-menu is-active">
<div class="navbar-start"> {{#global.owner}}
<a class="navbar-item" href="{{{global.baseUrl}}}/home">Home</a> <div class="navbar-menu is-active">
{{#global.owner.is_admin}} <div class="navbar-start">
<a class="navbar-item" href="{{{global.baseUrl}}}/users">Users</a> <a class="navbar-item" href="{{{global.baseUrl}}}/home">Home</a>
{{/global.owner.is_admin}} {{#global.owner.is_admin}}
<a class="navbar-item" href="{{{global.baseUrl}}}/items">Items</a> <a class="navbar-item" href="{{{global.baseUrl}}}/users">Users</a>
<a class="navbar-item" href="{{{global.baseUrl}}}/changes">Log</a> {{/global.owner.is_admin}}
</div> <a class="navbar-item" href="{{{global.baseUrl}}}/items">Items</a>
<div class="navbar-end"> <a class="navbar-item" href="{{{global.baseUrl}}}/changes">Log</a>
<div class="navbar-item">{{global.owner.email}}</div> </div>
<a class="navbar-item" href="{{{global.baseUrl}}}/users/me">Profile</a> <div class="navbar-end">
<div class="navbar-item"> <div class="navbar-item">{{global.owner.email}}</div>
<form method="post" action="{{{global.baseUrl}}}/logout"> <a class="navbar-item" href="{{{global.baseUrl}}}/users/me">Profile</a>
<button class="button is-primary">Logout</button> <div class="navbar-item">
</form> <form method="post" action="{{{global.baseUrl}}}/logout">
<button class="button is-primary">Logout</button>
</form>
</div>
</div> </div>
</div> </div>
</div> {{/global.owner}}
{{^global.owner}}
<div class="navbar-menu is-active">
<div class="navbar-start">
<span class="navbar-item">{{global.appName}}</span>
</div>
</div>
{{/global.owner}}
</div> </div>
</nav> </nav>
{{/navbar}} {{/navbar}}

View File

@ -608,6 +608,7 @@ function joplinEditableBlockInfo(node) {
if (!node.classList.contains('joplin-editable')) return null; if (!node.classList.contains('joplin-editable')) return null;
let sourceNode = null; let sourceNode = null;
let isInline = false;
for (const childNode of node.childNodes) { for (const childNode of node.childNodes) {
if (childNode.classList.contains('joplin-source')) { if (childNode.classList.contains('joplin-source')) {
sourceNode = childNode; sourceNode = childNode;
@ -616,11 +617,13 @@ function joplinEditableBlockInfo(node) {
} }
if (!sourceNode) return null; if (!sourceNode) return null;
if (!node.isBlock) isInline = true;
return { return {
openCharacters: sourceNode.getAttribute('data-joplin-source-open'), openCharacters: sourceNode.getAttribute('data-joplin-source-open'),
closeCharacters: sourceNode.getAttribute('data-joplin-source-close'), closeCharacters: sourceNode.getAttribute('data-joplin-source-close'),
content: sourceNode.textContent, content: sourceNode.textContent,
isInline
}; };
} }
@ -637,7 +640,8 @@ rules.joplinSourceBlock = {
const info = joplinEditableBlockInfo(node); const info = joplinEditableBlockInfo(node);
if (!info) return; if (!info) return;
return '\n\n' + info.openCharacters + info.content + info.closeCharacters + '\n\n'; const surroundingCharacter = info.isInline? '' : '\n\n';
return surroundingCharacter + info.openCharacters + info.content + info.closeCharacters + surroundingCharacter;
} }
} }