Merge branch 'dev' into release-2.0

pull/5064/head
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.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

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

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/toggleExternalEditing'),
require('./commands/toggleSafeMode'),
require('./commands/restoreNoteRevision'),
require('@joplin/lib/commands/historyBackward'),
require('@joplin/lib/commands/historyForward'),
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 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() {

View File

@ -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 <div key={key}>{itemsHtml}</div>;

View File

@ -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<Props, State> {
listType: BaseModel.TYPE_NOTE,
showHelp: false,
resultsInBody: false,
commandArgs: [],
};
this.styles_ = {};
@ -250,6 +257,15 @@ class Dialog extends React.PureComponent<Props, State> {
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<Props, State> {
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<Props, State> {
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<Props, State> {
});
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<Props, State> {
id: itemId,
parent_id: parentId,
type: itemType,
commandArgs: this.state.commandArgs,
});
}
@ -466,7 +487,7 @@ class Dialog extends React.PureComponent<Props, State> {
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) {

View File

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

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) {
// 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:

View File

@ -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:
//
// <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;

View File

@ -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'),

View File

@ -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'),

View File

@ -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 () => {

View File

@ -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');

View File

@ -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<ReportSection[]> {
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);
}

View File

@ -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<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);
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() {

View File

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

View File

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

View File

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

View File

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

View File

@ -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');

View File

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

View File

@ -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<User> {
export async function postUser(sessionId: string, email: string, password: string, props: any = null): Promise<User> {
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);

View File

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

View File

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

View File

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

View File

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

View File

@ -1,4 +1,7 @@
<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">
{{> errorBanner}}
<form action="{{{global.baseUrl}}}/login" method="POST">

View File

@ -1,5 +1,8 @@
{{> errorBanner}}
<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">
<form action="{{{postUrl}}}" method="POST">
<div class="field">

View File

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

View File

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

View File

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